From 53e8599bddb37132029b36ef9fec7f54dce41819 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Mon, 21 Sep 2026 16:45:44 +0800 Subject: [PATCH] feat: live module and jenkins pipeline to harbor deploy --- cmd/main.go | 3 +- cmd/wire_gen.go | 97 ++- configs/config.yaml | 10 +- deploy/Jenkinsfile | 338 +++------ deploy/Jenkinsfile2 | 149 ++++ deploy/bt-nginx-live.fuhua91.com.conf | 115 +++ deploy/deploy.sh | 94 +++ docs/LIVE.md | 115 +++ go.mod | 19 +- go.sum | 43 +- internal/biz/analytics/dashboard.go | 44 ++ internal/biz/analytics/provider.go | 5 + internal/biz/asset/asset.go | 90 +++ internal/biz/asset/provider.go | 5 + internal/biz/biz.go | 10 +- internal/biz/gift/gift.go | 158 ++++ internal/biz/gift/provider.go | 5 + internal/biz/live/catalog.go | 330 ++++++++ internal/biz/live/provider.go | 5 + internal/biz/live/stream.go | 84 +++ internal/biz/live/stream_test.go | 60 ++ internal/biz/livechat/chat.go | 85 +++ internal/biz/livechat/provider.go | 5 + internal/biz/liveclient/account.go | 175 +++++ internal/biz/liveclient/provider.go | 5 + internal/biz/payment/payment.go | 4 + internal/biz/platformsetting/provider.go | 5 + internal/biz/platformsetting/setting.go | 24 + internal/biz/recharge/provider.go | 5 + internal/biz/recharge/recharge.go | 197 +++++ internal/biz/recharge/recharge_test.go | 70 ++ internal/biz/system/authentication.go | 63 ++ internal/data/analytics/dashboard.go | 66 ++ internal/data/analytics/provider.go | 5 + internal/data/asset/asset.go | 144 ++++ internal/data/asset/asset_test.go | 53 ++ internal/data/asset/migrations.go | 13 + internal/data/asset/provider.go | 5 + internal/data/data.go | 24 + internal/data/database.go | 11 +- internal/data/database_test.go | 20 +- internal/data/gift/gift.go | 310 ++++++++ internal/data/gift/gift_test.go | 87 +++ internal/data/gift/migrations.go | 23 + internal/data/gift/provider.go | 5 + internal/data/ledger/models.go | 81 ++ internal/data/live/catalog.go | 364 +++++++++ internal/data/live/catalog_test.go | 76 ++ internal/data/live/migrations.go | 22 + internal/data/live/provider.go | 13 + internal/data/live/stream.go | 130 ++++ internal/data/livechat/chat.go | 138 ++++ internal/data/livechat/chat_test.go | 67 ++ internal/data/livechat/migrations.go | 12 + internal/data/livechat/provider.go | 5 + internal/data/liveclient/account.go | 165 ++++ internal/data/liveclient/account_test.go | 72 ++ internal/data/liveclient/provider.go | 5 + internal/data/migrations_test.go | 6 +- internal/data/platformsetting/migrations.go | 18 + internal/data/platformsetting/provider.go | 5 + internal/data/platformsetting/setting.go | 38 + internal/data/recharge/migrations.go | 57 ++ internal/data/recharge/provider.go | 5 + internal/data/recharge/recharge.go | 191 +++++ internal/data/recharge/recharge_test.go | 97 +++ internal/data/system/surface_upgrade.go | 44 ++ internal/integration/live/provider.go | 5 + internal/integration/live/server.go | 714 ++++++++++++++++++ internal/integration/provider.go | 4 + internal/modules/catalog.go | 2 + internal/modules/catalog_test.go | 14 +- internal/modules/live/definition.go | 51 ++ internal/routecatalog/catalog.go | 45 ++ internal/server/gin_test.go | 3 +- internal/server/handler/analytics.go | 20 + internal/server/handler/asset.go | 76 ++ internal/server/handler/business_helpers.go | 35 + internal/server/handler/gift.go | 143 ++++ internal/server/handler/live.go | 226 ++++++ internal/server/handler/live_chat.go | 28 + internal/server/handler/live_client.go | 120 +++ internal/server/handler/platform_setting.go | 30 + internal/server/handler/provider.go | 3 +- internal/server/handler/public.go | 1 + internal/server/handler/recharge.go | 70 ++ internal/server/handler/set.go | 8 + internal/server/router/analytics.go | 10 + internal/server/router/asset.go | 13 + internal/server/router/gift.go | 19 + internal/server/router/live.go | 27 + internal/server/router/live_chat.go | 10 + internal/server/router/live_client.go | 14 + internal/server/router/platform_setting.go | 11 + internal/server/router/recharge.go | 14 + internal/server/router/routes.go | 8 + internal/service/analytics/dashboard.go | 36 + internal/service/analytics/provider.go | 5 + internal/service/asset/asset.go | 68 ++ internal/service/asset/provider.go | 5 + internal/service/dto/analytics.go | 23 + internal/service/dto/asset.go | 46 ++ internal/service/dto/gift.go | 71 ++ internal/service/dto/live.go | 82 ++ internal/service/dto/live_chat.go | 24 + internal/service/dto/live_client.go | 41 + internal/service/dto/platform_setting.go | 5 + internal/service/dto/recharge.go | 58 ++ internal/service/gift/gift.go | 87 +++ internal/service/gift/provider.go | 5 + internal/service/live/catalog.go | 117 +++ internal/service/live/provider.go | 5 + internal/service/live/stream.go | 32 + internal/service/livechat/chat.go | 27 + internal/service/livechat/provider.go | 5 + internal/service/liveclient/account.go | 60 ++ internal/service/liveclient/provider.go | 5 + internal/service/platformsetting/provider.go | 5 + internal/service/platformsetting/setting.go | 21 + internal/service/recharge/provider.go | 5 + internal/service/recharge/recharge.go | 110 +++ internal/service/service.go | 16 + web/.env.production | 3 + web/Dockerfile | 4 +- web/Dockerfile.build | 13 + web/src/modules/live/api/live.js | 51 ++ .../modules/live/components/ImageUploader.vue | 48 ++ .../live/components/LivePageHeader.vue | 14 + .../modules/live/components/VideoUploader.vue | 37 + .../modules/live/composables/useLiveRTC.js | 310 ++++++++ web/src/modules/live/styles/live.scss | 231 ++++++ web/src/modules/live/view/categories.vue | 41 + web/src/modules/live/view/diagnostics.vue | 324 ++++++++ web/src/modules/live/view/gifts.vue | 83 ++ web/src/modules/live/view/orders.vue | 144 ++++ web/src/modules/live/view/room.vue | 47 ++ web/src/modules/live/view/rooms.vue | 96 +++ web/src/modules/live/view/sends.vue | 47 ++ web/src/modules/live/view/settings.vue | 49 ++ web/src/modules/live/view/studio.vue | 37 + web/src/modules/live/view/users.vue | 52 ++ web/src/pathInfo.json | 15 +- web/src/router/index.js | 18 + web/src/view/dashboard/index.vue | 103 ++- web/vite.config.js | 4 +- 145 files changed, 8984 insertions(+), 269 deletions(-) create mode 100644 deploy/Jenkinsfile2 create mode 100644 deploy/bt-nginx-live.fuhua91.com.conf create mode 100644 deploy/deploy.sh create mode 100644 docs/LIVE.md create mode 100644 internal/biz/analytics/dashboard.go create mode 100644 internal/biz/analytics/provider.go create mode 100644 internal/biz/asset/asset.go create mode 100644 internal/biz/asset/provider.go create mode 100644 internal/biz/gift/gift.go create mode 100644 internal/biz/gift/provider.go create mode 100644 internal/biz/live/catalog.go create mode 100644 internal/biz/live/provider.go create mode 100644 internal/biz/live/stream.go create mode 100644 internal/biz/live/stream_test.go create mode 100644 internal/biz/livechat/chat.go create mode 100644 internal/biz/livechat/provider.go create mode 100644 internal/biz/liveclient/account.go create mode 100644 internal/biz/liveclient/provider.go create mode 100644 internal/biz/platformsetting/provider.go create mode 100644 internal/biz/platformsetting/setting.go create mode 100644 internal/biz/recharge/provider.go create mode 100644 internal/biz/recharge/recharge.go create mode 100644 internal/biz/recharge/recharge_test.go create mode 100644 internal/data/analytics/dashboard.go create mode 100644 internal/data/analytics/provider.go create mode 100644 internal/data/asset/asset.go create mode 100644 internal/data/asset/asset_test.go create mode 100644 internal/data/asset/migrations.go create mode 100644 internal/data/asset/provider.go create mode 100644 internal/data/gift/gift.go create mode 100644 internal/data/gift/gift_test.go create mode 100644 internal/data/gift/migrations.go create mode 100644 internal/data/gift/provider.go create mode 100644 internal/data/ledger/models.go create mode 100644 internal/data/live/catalog.go create mode 100644 internal/data/live/catalog_test.go create mode 100644 internal/data/live/migrations.go create mode 100644 internal/data/live/provider.go create mode 100644 internal/data/live/stream.go create mode 100644 internal/data/livechat/chat.go create mode 100644 internal/data/livechat/chat_test.go create mode 100644 internal/data/livechat/migrations.go create mode 100644 internal/data/livechat/provider.go create mode 100644 internal/data/liveclient/account.go create mode 100644 internal/data/liveclient/account_test.go create mode 100644 internal/data/liveclient/provider.go create mode 100644 internal/data/platformsetting/migrations.go create mode 100644 internal/data/platformsetting/provider.go create mode 100644 internal/data/platformsetting/setting.go create mode 100644 internal/data/recharge/migrations.go create mode 100644 internal/data/recharge/provider.go create mode 100644 internal/data/recharge/recharge.go create mode 100644 internal/data/recharge/recharge_test.go create mode 100644 internal/integration/live/provider.go create mode 100644 internal/integration/live/server.go create mode 100644 internal/modules/live/definition.go create mode 100644 internal/server/handler/analytics.go create mode 100644 internal/server/handler/asset.go create mode 100644 internal/server/handler/business_helpers.go create mode 100644 internal/server/handler/gift.go create mode 100644 internal/server/handler/live.go create mode 100644 internal/server/handler/live_chat.go create mode 100644 internal/server/handler/live_client.go create mode 100644 internal/server/handler/platform_setting.go create mode 100644 internal/server/handler/recharge.go create mode 100644 internal/server/router/analytics.go create mode 100644 internal/server/router/asset.go create mode 100644 internal/server/router/gift.go create mode 100644 internal/server/router/live.go create mode 100644 internal/server/router/live_chat.go create mode 100644 internal/server/router/live_client.go create mode 100644 internal/server/router/platform_setting.go create mode 100644 internal/server/router/recharge.go create mode 100644 internal/service/analytics/dashboard.go create mode 100644 internal/service/analytics/provider.go create mode 100644 internal/service/asset/asset.go create mode 100644 internal/service/asset/provider.go create mode 100644 internal/service/dto/analytics.go create mode 100644 internal/service/dto/asset.go create mode 100644 internal/service/dto/gift.go create mode 100644 internal/service/dto/live.go create mode 100644 internal/service/dto/live_chat.go create mode 100644 internal/service/dto/live_client.go create mode 100644 internal/service/dto/platform_setting.go create mode 100644 internal/service/dto/recharge.go create mode 100644 internal/service/gift/gift.go create mode 100644 internal/service/gift/provider.go create mode 100644 internal/service/live/catalog.go create mode 100644 internal/service/live/provider.go create mode 100644 internal/service/live/stream.go create mode 100644 internal/service/livechat/chat.go create mode 100644 internal/service/livechat/provider.go create mode 100644 internal/service/liveclient/account.go create mode 100644 internal/service/liveclient/provider.go create mode 100644 internal/service/platformsetting/provider.go create mode 100644 internal/service/platformsetting/setting.go create mode 100644 internal/service/recharge/provider.go create mode 100644 internal/service/recharge/recharge.go create mode 100644 web/Dockerfile.build create mode 100644 web/src/modules/live/api/live.js create mode 100644 web/src/modules/live/components/ImageUploader.vue create mode 100644 web/src/modules/live/components/LivePageHeader.vue create mode 100644 web/src/modules/live/components/VideoUploader.vue create mode 100644 web/src/modules/live/composables/useLiveRTC.js create mode 100644 web/src/modules/live/styles/live.scss create mode 100644 web/src/modules/live/view/categories.vue create mode 100644 web/src/modules/live/view/diagnostics.vue create mode 100644 web/src/modules/live/view/gifts.vue create mode 100644 web/src/modules/live/view/orders.vue create mode 100644 web/src/modules/live/view/room.vue create mode 100644 web/src/modules/live/view/rooms.vue create mode 100644 web/src/modules/live/view/sends.vue create mode 100644 web/src/modules/live/view/settings.vue create mode 100644 web/src/modules/live/view/studio.vue create mode 100644 web/src/modules/live/view/users.vue diff --git a/cmd/main.go b/cmd/main.go index ffaa4f3..7839cff 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,7 @@ import ( "strings" "kra/internal/config" + liveintegration "kra/internal/integration/live" "kra/internal/logging" "kra/internal/server/router" "kra/internal/service/dto" @@ -62,7 +63,7 @@ func buildRuntime(registry *platformtask.Registry, systemRoutes *router.Routes, return systemRoutes } -func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *systemservice.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App { +func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *systemservice.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client, _ *liveintegration.Manager) *kratos.App { if audit != nil && loggerControl != nil { loggerControl.SetErrorSink(logging.ErrorSinkFunc(func(ctx context.Context, entry logging.ErrorEntry) error { return audit.CreateErrorRequest(ctx, &dto.ErrorRecordRequest{Form: entry.Form, Info: entry.Info, Level: entry.Level, RequestID: entry.RequestID, TraceID: entry.TraceID}) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4de9642..0bc0331 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -8,20 +8,37 @@ package main import ( "github.com/go-kratos/kratos/v3" + analytics2 "kra/internal/biz/analytics" + asset2 "kra/internal/biz/asset" + gift2 "kra/internal/biz/gift" integration3 "kra/internal/biz/integration" + live2 "kra/internal/biz/live" + livechat2 "kra/internal/biz/livechat" + liveclient2 "kra/internal/biz/liveclient" payment3 "kra/internal/biz/payment" + platformsetting2 "kra/internal/biz/platformsetting" + recharge2 "kra/internal/biz/recharge" system2 "kra/internal/biz/system" task2 "kra/internal/biz/task" "kra/internal/config" "kra/internal/data" + "kra/internal/data/analytics" + "kra/internal/data/asset" + "kra/internal/data/gift" "kra/internal/data/integration" + "kra/internal/data/live" + "kra/internal/data/livechat" + "kra/internal/data/liveclient" payment2 "kra/internal/data/payment" + "kra/internal/data/platformsetting" + "kra/internal/data/recharge" "kra/internal/data/system" "kra/internal/data/task" "kra/internal/initialize" integration2 "kra/internal/integration" "kra/internal/integration/cache" "kra/internal/integration/email" + live4 "kra/internal/integration/live" "kra/internal/integration/mq" "kra/internal/integration/payment" "kra/internal/integration/storage" @@ -32,8 +49,16 @@ import ( "kra/internal/server" "kra/internal/server/handler" "kra/internal/server/router" + analytics3 "kra/internal/service/analytics" + asset3 "kra/internal/service/asset" + gift3 "kra/internal/service/gift" integration4 "kra/internal/service/integration" + live3 "kra/internal/service/live" + livechat3 "kra/internal/service/livechat" + liveclient3 "kra/internal/service/liveclient" payment4 "kra/internal/service/payment" + platformsetting3 "kra/internal/service/platformsetting" + recharge3 "kra/internal/service/recharge" system3 "kra/internal/service/system" task3 "kra/internal/service/task" "kra/internal/worker" @@ -170,6 +195,59 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigUsecase := integration3.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester) integrationConfigService := integration4.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) + streamRepo := live.NewStreamRepo() + streamUsecase := live2.NewStreamUsecase(streamRepo) + streamService := live3.NewStreamService(streamUsecase) + catalogRepo := live.NewCatalogRepo(dataData) + categoryUsecase := live2.NewCategoryUsecase(catalogRepo) + roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase) + catalogService := live3.NewCatalogService(categoryUsecase, roomUsecase) + handlerLive := handler.NewLive(streamService, catalogService) + repo := livechat.NewRepo(dataData) + usecase := livechat2.NewUsecase(repo) + service := livechat3.NewService(usecase) + liveChat := handler.NewLiveChat(service) + liveclientRepo := liveclient.NewRepo(dataData) + liveclientUsecase := liveclient2.NewUsecase(liveclientRepo, securityUsecase, authenticationUsecase, userUsecase) + liveclientService := liveclient3.NewService(liveclientUsecase) + liveClient := handler.NewLiveClient(liveclientService) + giftRepo := gift.NewRepo(dataData) + websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) + if err != nil { + cleanup() + return nil, nil, err + } + manager, cleanup3, err := live4.New(websocketServer, streamUsecase, roomUsecase, usecase, logger) + if err != nil { + cleanup2() + cleanup() + return nil, nil, err + } + giftUsecase := gift2.NewUsecase(giftRepo, manager) + giftService := gift3.NewService(giftUsecase) + handlerGift := handler.NewGift(giftService) + rechargeRepo := recharge.NewRepo(dataData) + rechargeUsecase, err := recharge2.NewUsecase(rechargeRepo, paymentUsecase) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + rechargeService := recharge3.NewService(rechargeUsecase) + handlerRecharge := handler.NewRecharge(rechargeService) + assetRepo := asset.NewRepo(dataData) + assetUsecase := asset2.NewUsecase(assetRepo) + assetService := asset3.NewService(assetUsecase) + handlerAsset := handler.NewAsset(assetService) + platformsettingRepo := platformsetting.NewRepo(dataData) + platformsettingUsecase := platformsetting2.NewUsecase(platformsettingRepo) + platformsettingService := platformsetting3.NewService(platformsettingUsecase) + platformSetting := handler.NewPlatformSetting(platformsettingService) + analyticsRepo := analytics.NewRepo(dataData) + analyticsUsecase := analytics2.NewUsecase(analyticsRepo, streamUsecase) + analyticsService := analytics3.NewService(analyticsUsecase) + handlerAnalytics := handler.NewAnalytics(analyticsService) set := &handler.Set{ Authority: authority, Menu: menu, @@ -193,27 +271,32 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg Navigation: navigation, Session: session, IntegrationConfig: integrationConfig, + Live: handlerLive, + LiveChat: liveChat, + LiveClient: liveClient, + Gift: handlerGift, + Recharge: handlerRecharge, + Asset: handlerAsset, + PlatformSetting: platformSetting, + Analytics: handlerAnalytics, } routes := router.NewRoutes(set) maintenanceRepo := system.NewMaintenanceRepo(dataData) maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo) taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store) routeRegistrar := buildRuntime(registry, routes, taskMethods) - websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) - if err != nil { - cleanup() - return nil, nil, err - } engine := server.NewGinEngineWithRuntime(store, accessControlService, authService, securityService, auditRecorder, logger, string2, routeRegistrar, websocketServer) httpServer := server.NewGinServer(configServer, engine) - mqReloadable, cleanup3, err := mq.New(runtimeconfigStore, logger) + mqReloadable, cleanup4, err := mq.New(runtimeconfigStore, logger) if err != nil { + cleanup3() cleanup2() cleanup() return nil, nil, err } - app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger, mqReloadable) + app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger, mqReloadable, manager) return app, func() { + cleanup4() cleanup3() cleanup2() cleanup() diff --git a/configs/config.yaml b/configs/config.yaml index c506ae8..0dd552d 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -6,13 +6,13 @@ server: data: database: driver: mysql - source: "" + source: root:Xu950329.@tcp(127.0.0.1:3306)/yzyzlive?charset=utf8mb4&parseTime=True&loc=Local host: 127.0.0.1 port: "3306" user: root - password: "" - name: "" - config: timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=True&loc=Local + password: Xu950329. + name: yzyzlive + config: charset=utf8mb4&parseTime=True&loc=Local path: "" alias_name: "" disable: false @@ -88,7 +88,7 @@ admin: router_prefix: "" jwt: # Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally. - signing_key: "" + signing_key: e8e37061-b137-4438-ad48-30f85bf8c68b expires_time: 604800s buffer_time: 86400s issuer: kra diff --git a/deploy/Jenkinsfile b/deploy/Jenkinsfile index 18658da..6571180 100644 --- a/deploy/Jenkinsfile +++ b/deploy/Jenkinsfile @@ -1,262 +1,160 @@ +// live.fuhua91.com 部署流水线(基于 Jenkinsfile2 模板改造) +// 构建前后端镜像推送 Harbor(harbor.fuhua91.com/live),并 SSH 到宝塔服务器(192.168.2.226) +// 执行 /root/deploy.sh 拉取镜像更新容器。 +// +// Jenkins 凭据要求: +// - harbor-live-push : Username with password,Harbor 项目 live 的 robot 账号 +// (robot$live+live / harbor.json 中的 secret) +// - deploy-server-ssh-key: SSH Username with private key,可登录 root@192.168.2.226 +// +// 服务器要求: /root/deploy.sh 已就位(见 deploy/deploy.sh),首次已手动跑通 pipeline { agent any options { + timestamps() disableConcurrentBuilds() } + parameters { + choice( + name: 'DEPLOY_MODE', + choices: ['BUILD_AND_DEPLOY', 'DEPLOY_ONLY'], + description: 'BUILD_AND_DEPLOY 构建新镜像并部署;DEPLOY_ONLY 部署 Harbor 中已有的镜像 tag(回滚用)' + ) + string( + name: 'IMAGE_TAG', + defaultValue: '', + description: '要部署的镜像 tag。DEPLOY_ONLY 必填;BUILD_AND_DEPLOY 留空则使用当前 commit' + ) + } + environment { - // ══════════════════════════════════════════════════════════════════ - // 项目级参数 —— 基于 kra 模板的新项目只需修改本块。 - // 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。 - // ══════════════════════════════════════════════════════════════════ - APP_NAME = 'kra' // 派生:服务名/Secret名/库名/存储声明/镜像后缀 - IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径 - REGISTRY = '192.168.200.132' - BIN_NAME = 'kratos-admin' // Makefile build 的产物名 - // 当前单节点部署固定使用单副本,避免首次初始化和 hostPath 数据共享产生竞争。 - KRA_REPLICAS = '1' - // 中间件按环境开关;关闭时对应工作负载缩容到 0,不创建运行 Pod。 - KRA_DEPLOY_MYSQL = 'true' - KRA_DEPLOY_REDIS = 'true' - KRA_FRONTEND_NODEPORT = '30080' - KRA_BACKEND_NODEPORT = '30081' - KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4' - KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine' - GO_IMAGE = 'docker.m.daocloud.io/golang:1.25' - NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim' - PNPM_VERSION = '10.15.1' - CACHE_DIR = '/var/lib/kra-ci-cache' // Jenkins 宿主机依赖缓存根目录 - - // Jenkins 凭据 ID(新项目若沿用同一套凭据可不改) - CRED_HARBOR_PUSH = 'harbor-library-push' - CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig' - CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig' - CRED_MYSQL_PASSWORD = 'kra-mysql-root-password' - CRED_JWT_SIGNING_KEY = 'kra-jwt-signing-key' - - // k8s 清单渲染白名单:只替换列出的变量,避免误伤清单内 shell 变量(如 probe 里的 $MYSQL_ROOT_PASSWORD) - K8S_RENDER_VARS = '$KRA_APP $KRA_NAMESPACE $KRA_ENV $KRA_BACKEND_IMAGE $KRA_FRONTEND_IMAGE $KRA_REPLICAS $KRA_DEPLOY_MYSQL $KRA_DEPLOY_REDIS $KRA_MYSQL_REPLICAS $KRA_REDIS_REPLICAS $KRA_MYSQL_WAIT_IMAGE $KRA_FRONTEND_NODEPORT $KRA_BACKEND_NODEPORT $KRA_MYSQL_IMAGE $KRA_REDIS_IMAGE $BIN_NAME' + APP_NAME = 'live' + REGISTRY_HOST = 'harbor.fuhua91.com' + REGISTRY_NAMESPACE = 'live' + BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend" + FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend" + DEPLOY_HOST = 'root@192.168.2.226' + BIN_NAME = 'kratos-admin' + APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀 + PNPM_VERSION = '10.15.1' + DOCKER_BUILDKIT = '1' } stages { - stage('Select environment') { + stage('Checkout') { steps { - script { - // 分支来源优先级: - // 1) env.gitlabBranch —— GitLab Jenkins 集成/webhook 触发时由 gitlab-plugin 注入(push payload 的 ref) - // 2) env.BRANCH_NAME —— 多分支流水线场景 - // 3) JOB_NAME —— 手动触发兜底:名字以 -dev 结尾按 dev 部署,否则默认 main - def branch = env.gitlabBranch ?: env.BRANCH_NAME - if (!branch) { - branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main' - } - branch = branch.trim().replaceFirst(/^refs\/heads\//, '') - // The backend NodePort service has the longest generated name: - // -backend-nodeport, which must stay within 63 characters. - if (!(env.APP_NAME ==~ /^[a-z0-9](?:[a-z0-9-]{0,44}[a-z0-9])?$/)) { - error("APP_NAME must be a lowercase Kubernetes name with at most 46 characters: ${env.APP_NAME}") - } - echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'" - env.KRA_BRANCH = branch - if (branch == 'dev') { - env.KRA_NAMESPACE = "${env.APP_NAME}-dev" - env.KRA_ENV = 'development' - env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV - } else if (branch == 'main') { - env.KRA_NAMESPACE = "${env.APP_NAME}-prod" - env.KRA_ENV = 'production' - env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD - } else { - error("Unsupported branch: ${branch}. Only dev and main deploy.") - } - } + checkout scm } } - stage('Checkout triggered commit') { + stage('Prepare') { steps { - // job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出, - // 保证单 job 同时服务 dev/main 两个环境。 script { - // gitlabAfter 为 push 事件的目标 commit;手动触发时为空。 - // 注意:不能把 null 赋给 env.*(会被转成字符串 "null"),这里用局部变量。 - def triggerCommit = env.gitlabAfter?.trim() - if (triggerCommit && !(triggerCommit ==~ /[0-9a-fA-F]{40}/)) { - error("Invalid GitLab commit SHA: ${triggerCommit}") + env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + env.VERSION = sh(script: 'git describe --tags --always --dirty', returnStdout: true).trim() + + if (params.DEPLOY_MODE == 'DEPLOY_ONLY') { + if (!params.IMAGE_TAG?.trim()) { + error('IMAGE_TAG is required when DEPLOY_MODE=DEPLOY_ONLY') + } + env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG.trim() + } else { + env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG?.trim() ? params.IMAGE_TAG.trim() : env.GIT_COMMIT_SHORT } - if (triggerCommit && triggerCommit.replace('0', '').isEmpty()) { - error('GitLab event points to a deleted branch; refusing to deploy') - } - if (triggerCommit) { - env.KRA_TRIGGER_COMMIT = triggerCommit - } - checkout([$class: 'GitSCM', - branches: [[name: "*/${env.KRA_BRANCH}"]], - userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]]) - if (env.KRA_TRIGGER_COMMIT) { - sh "git fetch --no-tags origin ${env.KRA_TRIGGER_COMMIT}" - sh "git checkout --detach ${env.KRA_TRIGGER_COMMIT}" - } - env.KRA_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() - if (env.KRA_TRIGGER_COMMIT && env.KRA_COMMIT != env.KRA_TRIGGER_COMMIT) { - error("Checked out ${env.KRA_COMMIT}, expected GitLab commit ${env.KRA_TRIGGER_COMMIT}") - } - echo "building commit ${env.KRA_COMMIT}" + + echo "Deploy mode: ${params.DEPLOY_MODE}" + echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}" + echo "Frontend: ${env.FRONTEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}" } } } stage('Test') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } steps { sh ''' - set -eu - HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#') - docker run --rm -e GOPROXY=https://goproxy.cn,direct \\ - -v "$HOST_WORKSPACE:/src" \\ - -v "$CACHE_DIR/go-mod:/go/pkg/mod" \\ - -v "$CACHE_DIR/go-build:/root/.cache/go-build" \\ - -w /src "$GO_IMAGE" sh -c "git config --global --add safe.directory /src && go test ./... && make build" - ''' - sh ''' - set -eu - HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#') - docker run --rm \\ - -e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \\ - -v "$HOST_WORKSPACE/web:/app" \\ - -v "$CACHE_DIR/pnpm-store:/root/.local/share/pnpm/store" \\ - -v "$CACHE_DIR/corepack:/root/.cache/node/corepack" \\ - -w /app "$NODE_IMAGE" bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build" - # 前端 Nginx 配置模板渲染(proxy_pass 指向 ${APP_NAME}-backend 服务)。 - # KRA_APP 必须在此显式导出:envsubst 会把未设置的变量替换为空串。 - export KRA_APP="$APP_NAME" - envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf + go env -w GOPROXY=https://goproxy.cn,direct + go test ./... ''' } } - stage('Build and push images') { + stage('Build Frontend') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } steps { - script { - env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.KRA_COMMIT.take(12)}" - } - withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) { + // 容器内构建 dist(vite base 由 web/.env.production 的 VITE_APP_BASE=/admin/ 控制), + // 并渲染前端 nginx 配置(/api 转发到 live-backend:8000) + sh ''' + docker run --rm \ + -v "$PWD/web:/app" -w /app \ + -e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \ + -e npm_config_registry=https://registry.npmmirror.com \ + -v kra-pnpm-store:/root/.local/share/pnpm/store \ + docker.m.daocloud.io/node:20-slim \ + bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build" + sed "s/\\${KRA_APP}/$APP_BASE/g" web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf + ''' + } + } + + stage('Build Images') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + sh ''' + CGO_ENABLED=0 go build -ldflags "-X main.Version=${VERSION}" -o bin/${BIN_NAME} ./cmd + docker build \ + --build-arg BIN_NAME=${BIN_NAME} \ + --tag ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} \ + --tag ${BACKEND_IMAGE}:latest \ + . + docker build \ + --tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \ + --tag ${FRONTEND_IMAGE}:latest \ + web + ''' + } + } + + stage('Push Images') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + withCredentials([usernamePassword(credentialsId: 'harbor-live-push', usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASS')]) { sh ''' - set -eu - umask 077 - DOCKER_CONFIG_DIR=$(mktemp -d) - trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT - echo "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null - docker --config "$DOCKER_CONFIG_DIR" build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" . - docker --config "$DOCKER_CONFIG_DIR" build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web - docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" - docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" - docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true + echo "${HARBOR_PASS}" | docker login "${REGISTRY_HOST}" -u "${HARBOR_USER}" --password-stdin + docker push ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} + docker push ${BACKEND_IMAGE}:latest + docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} + docker push ${FRONTEND_IMAGE}:latest + docker logout "${REGISTRY_HOST}" ''' } } } - stage('Deploy to Rancher cluster') { + stage('Deploy') { steps { - script { - if (!(env.KRA_DEPLOY_MYSQL in ['true', 'false'])) { - error("KRA_DEPLOY_MYSQL must be true or false: ${env.KRA_DEPLOY_MYSQL}") - } - if (!(env.KRA_DEPLOY_REDIS in ['true', 'false'])) { - error("KRA_DEPLOY_REDIS must be true or false: ${env.KRA_DEPLOY_REDIS}") - } - def credentialBindings = [ - file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'), - string(credentialsId: env.CRED_JWT_SIGNING_KEY, variable: 'KRA_JWT_SIGNING_KEY'), - usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD') - ] - if (env.KRA_DEPLOY_MYSQL == 'true') { - credentialBindings << string(credentialsId: env.CRED_MYSQL_PASSWORD, variable: 'KRA_MYSQL_ROOT_PASSWORD') - } - withCredentials(credentialBindings) { - sh ''' - set -eu - export KUBECONFIG="$KUBECONFIG_FILE" - umask 077 - TMP_DIR=$(mktemp -d) - trap 'rm -rf "$TMP_DIR"' EXIT - kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - - DOCKER_CONFIG_DIR="$TMP_DIR/docker" - mkdir -p "$DOCKER_CONFIG_DIR" - printf '%s' "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null - kubectl -n "$KRA_NAMESPACE" create secret generic harbor-registry \\ - --type=kubernetes.io/dockerconfigjson \\ - --from-file=.dockerconfigjson="$DOCKER_CONFIG_DIR/config.json" \\ - --dry-run=client -o yaml | kubectl apply -f - - docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true - if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then - test -n "$KRA_MYSQL_ROOT_PASSWORD" || { echo 'MySQL root password credential is empty' >&2; exit 1; } - printf '%s' "$KRA_MYSQL_ROOT_PASSWORD" > "$TMP_DIR/mysql-root-password" - kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\ - --from-file=mysql-root-password="$TMP_DIR/mysql-root-password" \\ - --dry-run=client -o yaml | kubectl apply -f - - fi - test -n "$KRA_JWT_SIGNING_KEY" || { echo 'JWT signing key credential is empty' >&2; exit 1; } - if [ "$(printf '%s' "$KRA_JWT_SIGNING_KEY" | wc -l)" -ne 0 ]; then - echo 'JWT signing key credential must be a single line' >&2 - exit 1 - fi - case "$KRA_JWT_SIGNING_KEY" in - *"'"*) echo "JWT signing key credential must not contain a single quote" >&2; exit 1 ;; - esac - CONFIG_TEMPLATE="$TMP_DIR/config.yaml" - envsubst '$APP_NAME $KRA_ENV $KRA_JWT_SIGNING_KEY' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE" - kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-config-template" \\ - --from-file=config.yaml="$CONFIG_TEMPLATE" \\ - --dry-run=client -o yaml | kubectl apply -f - - kubectl -n "$KRA_NAMESPACE" delete configmap "${APP_NAME}-config-template" --ignore-not-found - export KRA_APP="$APP_NAME" - export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" - export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" - case "$KRA_DEPLOY_MYSQL" in - true) export KRA_MYSQL_REPLICAS=1; export KRA_MYSQL_WAIT_IMAGE="$KRA_MYSQL_IMAGE" ;; - false) export KRA_MYSQL_REPLICAS=0; export KRA_MYSQL_WAIT_IMAGE="$KRA_BACKEND_IMAGE" ;; - *) echo 'KRA_DEPLOY_MYSQL must be true or false' >&2; exit 1 ;; - esac - case "$KRA_DEPLOY_REDIS" in - true) export KRA_REDIS_REPLICAS=1 ;; - false) export KRA_REDIS_REPLICAS=0 ;; - *) echo 'KRA_DEPLOY_REDIS must be true or false' >&2; exit 1 ;; - esac - RENDERED_MANIFEST="$TMP_DIR/manifest.yaml" - envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST" - # Groovy 三单引号字符串内反斜杠仍是转义前缀: - # 本行正则里的反斜杠序列必须双写,shell 才能收到 grep -E 需要的单反斜杠模式。 - if grep -nE '\\$\\{[A-Z_][A-Z0-9_]*\\}' "$RENDERED_MANIFEST"; then - echo 'Rendered Kubernetes manifest contains unresolved template variables' >&2 - exit 1 - fi - MYSQL_SERVICE_IP=$(kubectl -n "$KRA_NAMESPACE" get service "${APP_NAME}-mysql" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true) - if [ -n "$MYSQL_SERVICE_IP" ] && [ "$MYSQL_SERVICE_IP" != 'None' ]; then - echo "${APP_NAME}-mysql already exists as a ClusterIP Service; delete it during a maintenance window before switching to headless mode" >&2 - exit 1 - fi - kubectl apply --dry-run=server -f "$RENDERED_MANIFEST" - kubectl apply -f "$RENDERED_MANIFEST" - if [ "$KRA_DEPLOY_MYSQL" = 'true' ]; then - RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}') - PATCH_FILE="$TMP_DIR/mysql-patch.json" - printf '{"spec":{"template":{"metadata":{"annotations":{"kra.dev/runtime-secret-version":"%s"}}}}}\n' \\ - "$RUNTIME_SECRET_VERSION" > "$PATCH_FILE" - kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\ - --patch-file="$PATCH_FILE" - kubectl -n "$KRA_NAMESPACE" rollout status statefulset/${APP_NAME}-mysql --timeout=10m - fi - if [ "$KRA_DEPLOY_REDIS" = 'true' ]; then - kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-redis --timeout=10m - fi - kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m - kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m - ''' - } + sshagent(credentials: ['deploy-server-ssh-key']) { + sh ''' + ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "bash /root/deploy.sh ${DEPLOY_IMAGE_TAG}" + ''' } } } } + + post { + success { + echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}" + } + } } diff --git a/deploy/Jenkinsfile2 b/deploy/Jenkinsfile2 new file mode 100644 index 0000000..ebb7d0b --- /dev/null +++ b/deploy/Jenkinsfile2 @@ -0,0 +1,149 @@ +pipeline { + agent any + + options { + timestamps() + disableConcurrentBuilds() + } + + parameters { + choice( + name: 'DEPLOY_MODE', + choices: ['BUILD_AND_DEPLOY', 'DEPLOY_ONLY'], + description: 'BUILD_AND_DEPLOY builds and deploys a new image; DEPLOY_ONLY deploys an existing Harbor image tag' + ) + string( + name: 'IMAGE_TAG', + defaultValue: '', + description: 'Image tag to deploy. Required for DEPLOY_ONLY; empty BUILD_AND_DEPLOY uses current commit' + ) + } + + environment { + APP_NAME = 'big-party-rpc-script' + REGISTRY_HOST = '192.168.2.160:8088' + REGISTRY_NAMESPACE = 'zhoubao' + IMAGE_REPOSITORY = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${APP_NAME}" + DEPLOY_HOST = 'root@192.168.2.226' + DEPLOY_DIR = "/opt/${APP_NAME}" + GITLAB_PROXY_HOST = '192.168.2.160' + DOCKER_BUILDKIT = '1' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Prepare') { + steps { + script { + env.GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + env.VERSION = sh(script: 'git describe --tags --always --dirty', returnStdout: true).trim() + + if (params.DEPLOY_MODE == 'DEPLOY_ONLY') { + if (!params.IMAGE_TAG?.trim()) { + error('IMAGE_TAG is required when DEPLOY_MODE=DEPLOY_ONLY') + } + env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG.trim() + } else { + env.DEPLOY_IMAGE_TAG = params.IMAGE_TAG?.trim() ? params.IMAGE_TAG.trim() : env.GIT_COMMIT_SHORT + } + + echo "Deploy mode: ${params.DEPLOY_MODE}" + echo "Image: ${env.IMAGE_REPOSITORY}:${env.DEPLOY_IMAGE_TAG}" + } + } + } + + stage('Test') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + withCredentials([sshUserPrivateKey(credentialsId: 'gitlab-go-deps-ssh-key', keyFileVariable: 'GIT_SSH_KEY')]) { + sh ''' + git config --global url."git@gitlab.fuhua91.com:".insteadOf "https://gitlab.fuhua91.com/" + git config --global url."git@gitlab.fuhua91.com:".insteadOf "http://gitlab.fuhua91.com/" + export GIT_SSH_COMMAND="ssh -i ${GIT_SSH_KEY} -o StrictHostKeyChecking=no" + go env -w GOPROXY=https://goproxy.cn,direct + go env -w GOPRIVATE=gitlab.fuhua91.com + go env -w GOINSECURE=gitlab.fuhua91.com + go test ./... + ''' + } + } + } + + stage('Build Image') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + withCredentials([sshUserPrivateKey(credentialsId: 'gitlab-go-deps-ssh-key', keyFileVariable: 'GIT_SSH_KEY')]) { + sh ''' + docker build \ + --file deploy/Dockerfile \ + --add-host gitlab.fuhua91.com:${GITLAB_PROXY_HOST} \ + --secret id=git_ssh_key,src=${GIT_SSH_KEY} \ + --build-arg VERSION=${VERSION} \ + --tag ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG} \ + --tag ${IMAGE_REPOSITORY}:latest \ + . + ''' + } + } + } + + stage('Push Image') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + withCredentials([usernamePassword(credentialsId: 'docker-registry', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASS')]) { + sh ''' + echo "${REGISTRY_PASS}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin + docker push ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG} + docker push ${IMAGE_REPOSITORY}:latest + ''' + } + } + } + + stage('Deploy') { + steps { + withCredentials([usernamePassword(credentialsId: 'docker-registry', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASS')]) { + sshagent(credentials: ['deploy-server-ssh-key']) { + sh ''' + ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "mkdir -p ${DEPLOY_DIR}/deploy ${DEPLOY_DIR}/configs ${DEPLOY_DIR}/logs" + scp -O -o StrictHostKeyChecking=no deploy/docker-compose.yaml ${DEPLOY_HOST}:${DEPLOY_DIR}/deploy/docker-compose.yaml + scp -O -o StrictHostKeyChecking=no configs/config.yaml ${DEPLOY_HOST}:${DEPLOY_DIR}/configs/config.yaml + printf '%s' "${REGISTRY_PASS}" | ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} "docker login '${REGISTRY_HOST}' -u '${REGISTRY_USER}' --password-stdin" + ssh -o StrictHostKeyChecking=no ${DEPLOY_HOST} " + cd ${DEPLOY_DIR}/deploy && + printf '%s\\n' \ + 'IMAGE_REPOSITORY=${IMAGE_REPOSITORY}' \ + 'IMAGE_TAG=${DEPLOY_IMAGE_TAG}' \ + 'HTTP_PORT=20200' \ + 'GRPC_PORT=20201' \ + 'CONFIG_FILE=../configs/config.yaml' \ + 'LOG_DIR=../logs' > .env && + docker-compose pull && + docker-compose up -d && + docker image prune -f + " + ''' + } + } + } + } + } + + post { + success { + echo "Deployed ${IMAGE_REPOSITORY}:${DEPLOY_IMAGE_TAG}" + } + } +} diff --git a/deploy/bt-nginx-live.fuhua91.com.conf b/deploy/bt-nginx-live.fuhua91.com.conf new file mode 100644 index 0000000..ebd0960 --- /dev/null +++ b/deploy/bt-nginx-live.fuhua91.com.conf @@ -0,0 +1,115 @@ +# live.fuhua91.com 宝塔站点配置 +# 路径规划: +# / -> live 用户端前端(静态目录,或替换为反代) +# /admin/ -> 管理后台(frontend 容器 9501,nginx 去前缀转发) +# /api -> 后端 API/WebSocket(backend 容器 9502) +# 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。 + +map $http_upgrade $live_connection_upgrade { + default upgrade; + '' close; +} + +server +{ + listen 80; + listen 443 ssl http2 ; + server_name live.fuhua91.com; + root /www/wwwroot/live.fuhua91.com; + index index.html; + #CERT-APPLY-CHECK--START + # 用于SSL证书申请时的文件验证相关配置 -- 请勿删除 + include /www/server/panel/vhost/nginx/well-known/live.fuhua91.com.conf; + #CERT-APPLY-CHECK--END + include /www/server/panel/vhost/nginx/extension/live.fuhua91.com/*.conf; + + #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则 + #error_page 404/404.html; + ssl_certificate /www/server/panel/vhost/cert/live.fuhua91.com/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/live.fuhua91.com/privkey.pem; + ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_tickets on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + + #SSL-END + + #ERROR-PAGE-START 错误页配置,可以注释、删除或修改 + error_page 404 /404.html; + #error_page 502 /502.html; + #ERROR-PAGE-END + + # 大文件上传(视频分片走 /api) + client_max_body_size 1024m; + + # 一键申请SSL证书验证目录: 直接读本地磁盘,不进容器 + location ^~ /.well-known/ { + root /www/wwwroot/live.fuhua91.com; + allow all; + } + + # 禁止访问的敏感文件 + location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ + { + return 404; + } + + # 禁止访问的敏感目录 + location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ { + return 404; + } + + # 后端 API / WebSocket(容器内后端无 /api 前缀,proxy_pass 尾斜杠自动去掉) + location ^~ /api { + proxy_pass http://127.0.0.1:9502; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $live_connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # 管理后台(frontend 容器;rewrite 去掉 /admin 前缀,前端已按 /admin/ base 构建) + location = /admin { + return 301 /admin/; + } + location ^~ /admin/ { + rewrite ^/admin/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:9501; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # live 用户端前端(根路径) + # 方式一: 静态文件放 /www/wwwroot/live.fuhua91.com/live-web 后使用当前配置 + # 方式二: 若 live 前端也是 docker 容器,把下面两行换成: + # proxy_pass http://127.0.0.1:; + # proxy_set_header Host $http_host; 以及上面 /admin/ 块中的其余 proxy_set_header + location / { + root /www/wwwroot/live.fuhua91.com/live-web; + try_files $uri $uri/ /index.html; + } + + #禁止在证书验证目录放入敏感文件 + if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { + return 403; + } + + #REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效 + include /www/server/panel/vhost/rewrite/live.fuhua91.com.conf; + #REWRITE-END + + access_log /www/wwwlogs/live.fuhua91.com.log; + error_log /www/wwwlogs/live.fuhua91.com.error.log; +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..9aad11c --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# ============================================================ +# live.fuhua91.com 一键部署脚本 +# +# 用法: +# ./deploy.sh 拉取 latest 最新镜像并重启 +# ./deploy.sh v1.0.0 部署指定版本 +# +# 建议: 放到 /root/deploy.sh,执行 chmod 700 /root/deploy.sh +# 依赖: 服务器已安装 docker,且能访问 harbor.fuhua91.com +# ============================================================ +set -euo pipefail + +# ---------- 可按需调整的参数 ---------- +REGISTRY="harbor.fuhua91.com" +PROJECT="live" +# Harbor 机器人账号(也可通过环境变量 HARBOR_USER / HARBOR_PASSWORD 覆盖) +HARBOR_USER_DEFAULT='robot$live+live' +HARBOR_PASSWORD_DEFAULT='R4p6ieacIogXE779eL1HXhCpA4bx4SA7' + +BASE_DIR="/www/wwwroot/live.fuhua91.com" +CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf +LOG_DIR="$BASE_DIR/log" # 后端日志目录 -> 容器内 /app/logs +UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads + +FRONTEND_PORT=9501 # 前端 nginx 容器 +BACKEND_PORT=9502 # 后端 API/WebSocket 容器 +NETWORK="live-net" +BACKEND_NAME="live-backend" +FRONTEND_NAME="live-frontend" +# -------------------------------------- + +TAG="${1:-latest}" +BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG" +FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG" + +echo "==> 部署版本: $TAG" + +# 1. 准备挂载目录 +mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$UPLOADS_DIR" + +# 2. 首次运行检查配置文件 +if [ ! -f "$CONFIG_DIR/config.yaml" ]; then + echo "错误: 缺少 $CONFIG_DIR/config.yaml" + echo "请先将项目 configs/config.yaml 复制到该路径," + echo "并修改其中的 MySQL / Redis 等连接信息后重新执行本脚本。" + exit 1 +fi + +# 3. 登录 Harbor(优先使用环境变量 HARBOR_USER / HARBOR_PASSWORD) +HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}" +HARBOR_PASSWORD="${HARBOR_PASSWORD:-$HARBOR_PASSWORD_DEFAULT}" +echo "==> 登录 $REGISTRY" +echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null + +# 4. 拉取最新镜像 +echo "==> 拉取镜像" +docker pull "$BACKEND_IMAGE" +docker pull "$FRONTEND_IMAGE" + +# 5. 重建容器 +echo "==> 重启容器" +docker network create "$NETWORK" >/dev/null 2>&1 || true +docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" 2>/dev/null || true + +# 容器内进程以 uid 1000 (kra) 运行,日志/上传目录需要写权限 +chown -R 1000:1000 "$LOG_DIR" "$UPLOADS_DIR" 2>/dev/null || \ + chmod -R 777 "$LOG_DIR" "$UPLOADS_DIR" + +# 后端: 配置 /data/conf,日志 /app/logs,上传 /app/uploads +docker run -d --name "$BACKEND_NAME" \ + --restart always \ + --network "$NETWORK" \ + -p "$BACKEND_PORT:8000" \ + -v "$CONFIG_DIR:/data/conf" \ + -v "$LOG_DIR:/app/logs" \ + -v "$UPLOADS_DIR:/app/uploads" \ + "$BACKEND_IMAGE" + +# 前端: nginx 静态站点,容器内已配置 /api -> live-backend:8000 +docker run -d --name "$FRONTEND_NAME" \ + --restart always \ + --network "$NETWORK" \ + -p "$FRONTEND_PORT:80" \ + "$FRONTEND_IMAGE" + +docker logout "$REGISTRY" >/dev/null 2>&1 || true + +echo "==> 部署完成" +docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME" +echo "" +echo "前端容器: http://<服务器IP>:$FRONTEND_PORT" +echo "后端容器: http://<服务器IP>:$BACKEND_PORT" +echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。" diff --git a/docs/LIVE.md b/docs/LIVE.md new file mode 100644 index 0000000..57b9048 --- /dev/null +++ b/docs/LIVE.md @@ -0,0 +1,115 @@ +# Go WebRTC Live + +直播功能使用 Pion WebRTC,在现有 GVA WebSocket 和 JWT 用户体系上实现单主播、多观众的 SFU 转发。 + +## WebSocket + +连接现有 WebSocket 地址(默认 `/ws`),握手必须携带 GVA JWT: + +```text +wss://host/ws?token= +``` + +连接后发送: + +```json +{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}} +``` + +角色支持 `publisher`、`subscriber`、`observer`。主播和观众完成 Join 后,浏览器发送 SDP offer: + +```json +{"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}} +``` + +服务端返回 `Answer`。主播使用发送方向的 transceiver,观众使用接收方向的 transceiver。媒体轨道由 Pion 在服务端复制给同一房间的所有观众。 + +其他消息: + +- `Heartbeat` / `HeartbeatAck` +- `PublisherStatus` +- `NewProducer` +- `ProducerClosed` +- `NewSubscriber` +- `SubscriberLeft` +- `StreamListUpdate` +- `Kicked` +- `ChatHistory`:加入房间后返回最近 100 条聊天和礼物消息 +- `ChatMessage`:普通聊天广播 +- `GiftMessage`:送礼成功后的礼物广播 + +发送聊天消息: + +```json +{"type":"ChatSend","payload":{"content":"大家好"}} +``` + +聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。 + +主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。 + +## HTTP + +公开列表接口: + +```text +GET /live/streams +GET /live/streams/:streamId +GET /api/streams # 旧客户端兼容别名 +GET /live/categories +GET /live/rooms?page=1&pageSize=20&categoryId=1&status=live +GET /live/rooms/:id +GET /live/rooms/:id/messages?limit=100 +GET /live/gifts +``` + +PC 客户端账号接口: + +```text +POST /live/client/auth/register +POST /live/client/auth/login +GET /live/client/me +PUT /live/client/me +PUT /live/client/me/password +``` + +客户端账号使用系统的 `sys_users` 表,注册时创建普通直播角色并初始化 100 钻石。注册和修改密码都会返回新的 JWT 登录态。 + +登录后可调用: + +```text +POST /live/rooms/:id/gifts +GET /live/recharge/plans +POST /live/recharge/orders +``` + +创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识: + +```json +{ + "planId": 1, + "provider": "wechat-v3", + "notifyUrl": "https://example.com/api/payment/callback/wechat-v3", + "returnUrl": "https://example.com/recharge/result" +} +``` + +充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。 + +礼物数量必须使用礼物接口返回的 `tiers`,扣款、礼物流水和钱包余额在同一事务内完成;成功后通过 `GiftMessage` 广播给房间内在线用户。 + +全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。 + +直播状态和在线连接是进程内状态,行为与旧项目一致;服务重启后房间自动清空。 + +## 网络配置 + +默认使用 Pion 主机候选。生产环境可以配置: + +```text +KRA_LIVE_STUN_URL=stun:stun.example.com:3478 +KRA_LIVE_UDP_MIN_PORT=40000 +KRA_LIVE_UDP_MAX_PORT=49999 +``` + +配置 UDP 端口范围后,需要在防火墙或容器网络中放行对应端口。 diff --git a/go.mod b/go.mod index c5b7060..cb7dbb2 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/minio/minio-go/v7 v7.0.92 github.com/mojocn/base64Captcha v1.3.8 github.com/olahol/melody v1.4.0 + github.com/pion/webrtc/v4 v4.2.20 github.com/qiniu/go-sdk/v7 v7.25.2 github.com/rabbitmq/amqp091-go v1.14.0 github.com/redis/go-redis/v9 v9.7.0 @@ -145,6 +146,21 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/pion/datachannel v1.6.2 // indirect + github.com/pion/dtls/v3 v3.1.8 // indirect + github.com/pion/ice/v4 v4.4.2 // indirect + github.com/pion/interceptor v0.1.48 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.2.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.17 // indirect + github.com/pion/rtp v1.10.5 // indirect + github.com/pion/sctp v1.11.1 // indirect + github.com/pion/sdp/v3 v3.0.19 // indirect + github.com/pion/srtp/v3 v3.0.13 // indirect + github.com/pion/stun/v4 v4.0.0 // indirect + github.com/pion/transport/v4 v4.1.0 // indirect + github.com/pion/turn/v5 v5.1.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.4 // indirect @@ -163,6 +179,7 @@ require ( github.com/tklauser/numcpus v0.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -175,7 +192,7 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 5256b8c..5d72655 100644 --- a/go.sum +++ b/go.sum @@ -321,11 +321,42 @@ github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1Gsh github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= +github.com/pion/dtls/v3 v3.1.8 h1:aLcgjZqzrYn5AbjSds4LvK2WI5VzJc1PencExyDjYis= +github.com/pion/dtls/v3 v3.1.8/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/ice/v4 v4.4.2 h1:asS17nbHJrzlVQl8fiSJaipxrxSY3Dq6DWgmB+0VwpI= +github.com/pion/ice/v4 v4.4.2/go.mod h1:YZgNFOyJWXpLpLj0mb4ccqNAWRo9C2RtqKwuVTEz0Sc= +github.com/pion/interceptor v0.1.48 h1:FF4gZ6Yh+N75gKMYpC7rYR8DdkiMBFtA7V2OBUKo7XQ= +github.com/pion/interceptor v0.1.48/go.mod h1:5mg/N5xXMAa4codCUdrJYY9I1y4tVQhlpd7rfwAJpvI= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.2.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA= +github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= +github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= +github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= +github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q= +github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY= +github.com/pion/stun/v4 v4.0.0 h1:UuQy2q6iZR4EnMl/+G8kAtaWhf7jx/u8ZyN9oD4+YEQ= +github.com/pion/stun/v4 v4.0.0/go.mod h1:JAojPsPtDH4iPeNQb7kvxBdzypmkWAROxy0coApKH2E= +github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= +github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.1.0 h1:OSzLub7q4GssG1P4BEVrz39MnnJlxLy1LOvEkP9f46o= +github.com/pion/turn/v5 v5.1.0/go.mod h1:6HJQO7UAe7pEPMrTtBrmj+tTfp+Ai8KAV2+GXHFi1nQ= +github.com/pion/webrtc/v4 v4.2.20 h1:NYiNhBTFArA8aoP18a30y4LN0dyqSrF65HxU7KnhNOo= +github.com/pion/webrtc/v4 v4.2.20/go.mod h1:aLGXbekuN0tHOu7IPX1o9Y/uN29Ovfjaaz4bL2P9ND8= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= @@ -388,8 +419,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= @@ -414,6 +445,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -455,8 +488,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/internal/biz/analytics/dashboard.go b/internal/biz/analytics/dashboard.go new file mode 100644 index 0000000..7fc75ee --- /dev/null +++ b/internal/biz/analytics/dashboard.go @@ -0,0 +1,44 @@ +package analytics + +import ( + "context" + bizlive "kra/internal/biz/live" + "time" +) + +type TrendPoint struct { + Date string + AmountCents int64 +} +type GiftRank struct { + RoomID uint + RoomName, AnchorName string + Diamonds int64 +} +type Dashboard struct { + RoomTotal, RoomLive, RechargeCents, PaidOrderCount, GiftDiamonds, GiftSendCount, DiamondRateCents int64 + RechargeTrend []*TrendPoint + GiftRanking []*GiftRank +} +type Repo interface { + Dashboard(context.Context, time.Time, time.Time) (*Dashboard, error) +} +type Usecase struct { + repo Repo + streams *bizlive.StreamUsecase +} + +func NewUsecase(repo Repo, streams *bizlive.StreamUsecase) *Usecase { + return &Usecase{repo: repo, streams: streams} +} +func (uc *Usecase) Get(ctx context.Context, from, to time.Time) (*Dashboard, error) { + v, err := uc.repo.Dashboard(ctx, from, to) + if err != nil { + return nil, err + } + streams, err := uc.streams.List(ctx) + if err == nil { + v.RoomLive = int64(len(streams)) + } + return v, nil +} diff --git a/internal/biz/analytics/provider.go b/internal/biz/analytics/provider.go new file mode 100644 index 0000000..30ac064 --- /dev/null +++ b/internal/biz/analytics/provider.go @@ -0,0 +1,5 @@ +package analytics + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/asset/asset.go b/internal/biz/asset/asset.go new file mode 100644 index 0000000..95404eb --- /dev/null +++ b/internal/biz/asset/asset.go @@ -0,0 +1,90 @@ +package asset + +import ( + "context" + kratoserrors "github.com/go-kratos/kratos/v3/errors" + "strings" + "time" +) + +var ( + ErrNotFound = kratoserrors.NotFound("ASSET_USER_NOT_FOUND", "user asset not found") + ErrInsufficient = kratoserrors.BadRequest("ASSET_INSUFFICIENT", "insufficient diamond balance") + ErrInvalidAdjustment = kratoserrors.BadRequest("ASSET_ADJUSTMENT_INVALID", "invalid balance adjustment") + ErrSelfDisable = kratoserrors.BadRequest("ASSET_SELF_DISABLE", "current administrator cannot be disabled") +) + +type User struct { + UserID uint + Username, Nickname, Avatar string + Enabled bool + DiamondBalance, TotalRechargeCents, TotalSpentDiamonds int64 + CreatedAt time.Time + LastLoginAt *time.Time + RecentOrders []OrderRecord + RecentSends []SendRecord +} +type OrderRecord struct { + OrderNo string + Diamonds, AmountCents int64 + Status string + CreatedAt time.Time +} +type SendRecord struct { + SerialNo, RoomName, GiftName, GiftIcon string + DiamondTotal int64 + CreatedAt time.Time +} +type Filter struct { + Keyword string + UserID uint + Enabled *bool + CreatedAt []time.Time +} +type ListOptions struct { + Offset, Limit int + OrderBy string +} +type Adjustment struct { + UserID, OperatorUserID uint + Type string + Amount int64 + Reason string +} +type Repo interface { + List(context.Context, Filter, ListOptions) ([]*User, int64, error) + Get(context.Context, uint) (*User, error) + SetEnabled(context.Context, uint, bool) error + Adjust(context.Context, *Adjustment) (*User, error) +} +type Usecase struct{ repo Repo } + +func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} } +func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*User, int64, error) { + if o.Limit < 1 || o.Limit > 100 { + o.Limit = 10 + } + return uc.repo.List(ctx, f, o) +} +func (uc *Usecase) Get(ctx context.Context, id uint) (*User, error) { + if id == 0 { + return nil, ErrNotFound + } + return uc.repo.Get(ctx, id) +} +func (uc *Usecase) SetEnabled(ctx context.Context, actor, id uint, enabled bool) error { + if actor == id && !enabled { + return ErrSelfDisable + } + return uc.repo.SetEnabled(ctx, id, enabled) +} +func (uc *Usecase) Adjust(ctx context.Context, v *Adjustment) (*User, error) { + if v == nil || v.UserID == 0 || v.OperatorUserID == 0 || v.Amount < 1 || v.Amount > 99999 || (v.Type != "increase" && v.Type != "decrease") { + return nil, ErrInvalidAdjustment + } + v.Reason = strings.TrimSpace(v.Reason) + if n := len([]rune(v.Reason)); n < 5 || n > 50 { + return nil, ErrInvalidAdjustment + } + return uc.repo.Adjust(ctx, v) +} diff --git a/internal/biz/asset/provider.go b/internal/biz/asset/provider.go new file mode 100644 index 0000000..9006a05 --- /dev/null +++ b/internal/biz/asset/provider.go @@ -0,0 +1,5 @@ +package asset + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 00fa73d..35179f4 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -1,8 +1,16 @@ package biz import ( + "kra/internal/biz/analytics" + "kra/internal/biz/asset" + "kra/internal/biz/gift" "kra/internal/biz/integration" + "kra/internal/biz/live" + "kra/internal/biz/livechat" + "kra/internal/biz/liveclient" "kra/internal/biz/payment" + "kra/internal/biz/platformsetting" + "kra/internal/biz/recharge" "kra/internal/biz/system" "kra/internal/biz/task" @@ -10,4 +18,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet) +var ProviderSet = wire.NewSet(live.ProviderSet, livechat.ProviderSet, liveclient.ProviderSet, gift.ProviderSet, recharge.ProviderSet, asset.ProviderSet, platformsetting.ProviderSet, analytics.ProviderSet, system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet) diff --git a/internal/biz/gift/gift.go b/internal/biz/gift/gift.go new file mode 100644 index 0000000..e6159ed --- /dev/null +++ b/internal/biz/gift/gift.go @@ -0,0 +1,158 @@ +package gift + +import ( + "context" + kratoserrors "github.com/go-kratos/kratos/v3/errors" + "sort" + "strings" + "time" +) + +var ( + ErrNotFound = kratoserrors.NotFound("GIFT_NOT_FOUND", "gift not found") + ErrNameExists = kratoserrors.BadRequest("GIFT_NAME_EXISTS", "gift name already exists") + ErrInvalid = kratoserrors.BadRequest("GIFT_INVALID", "invalid gift data") + ErrLastEnabled = kratoserrors.BadRequest("GIFT_LAST_ENABLED", "at least one enabled gift is required") + ErrInsufficientBalance = kratoserrors.BadRequest("GIFT_INSUFFICIENT_BALANCE", "insufficient diamond balance") + ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent") +) + +type Gift struct { + ID uint + Name, Icon, Description string + Price int64 + Tiers []int + Sort int + Enabled bool + SendCount int64 + CreatedAt, UpdatedAt time.Time +} +type Send struct { + ID uint + SerialNo string + UserID uint + Username, Nickname string + RoomID *uint + RoomName string + RoomDeleted bool + AnchorUserID uint + AnchorName string + GiftID *uint + GiftName, GiftIcon string + GiftPrice int64 + Quantity int + DiamondTotal, RateCents, EquivalentCents int64 + CreatedAt time.Time +} +type Filter struct { + Name string + MinPrice, MaxPrice int64 + Enabled *bool +} +type SendFilter struct { + RoomName, AnchorName, UserKeyword string + GiftID uint + CreatedAt []time.Time +} +type ListOptions struct { + Offset, Limit int + OrderBy string +} +type Summary struct{ Count, Diamonds, AmountCents int64 } +type Repo interface { + List(context.Context, Filter, ListOptions) ([]*Gift, int64, error) + Get(context.Context, uint) (*Gift, error) + Save(context.Context, *Gift) (*Gift, error) + Delete(context.Context, uint) error + SetEnabled(context.Context, uint, bool) error + SaveSort(context.Context, map[uint]int) error + ListSends(context.Context, SendFilter, ListOptions) ([]*Send, int64, *Summary, error) + Send(context.Context, uint, uint, uint, int) (*Send, error) +} +type Notifier interface { + GiftSent(context.Context, *Send) +} +type Usecase struct { + repo Repo + notifier Notifier +} + +func NewUsecase(repo Repo, notifier Notifier) *Usecase { + return &Usecase{repo: repo, notifier: notifier} +} +func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) { + f.Name = strings.TrimSpace(f.Name) + return uc.repo.List(ctx, f, normalize(o)) +} +func (uc *Usecase) Get(ctx context.Context, id uint) (*Gift, error) { + if id == 0 { + return nil, ErrNotFound + } + return uc.repo.Get(ctx, id) +} +func (uc *Usecase) Save(ctx context.Context, item *Gift) (*Gift, error) { + if err := validate(item); err != nil { + return nil, err + } + return uc.repo.Save(ctx, item) +} +func (uc *Usecase) Delete(ctx context.Context, id uint) error { return uc.repo.Delete(ctx, id) } +func (uc *Usecase) SetEnabled(ctx context.Context, id uint, enabled bool) error { + return uc.repo.SetEnabled(ctx, id, enabled) +} +func (uc *Usecase) SaveSort(ctx context.Context, values map[uint]int) error { + for id, v := range values { + if id == 0 || v < 0 || v > 9999 { + return ErrInvalid + } + } + return uc.repo.SaveSort(ctx, values) +} +func (uc *Usecase) ListSends(ctx context.Context, f SendFilter, o ListOptions) ([]*Send, int64, *Summary, error) { + return uc.repo.ListSends(ctx, f, normalize(o)) +} +func (uc *Usecase) Send(ctx context.Context, userID, roomID, giftID uint, quantity int) (*Send, error) { + if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 { + return nil, ErrSendNotAllowed + } + result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity) + if err != nil { + return nil, err + } + if uc.notifier != nil { + uc.notifier.GiftSent(ctx, result) + } + return result, nil +} +func normalize(o ListOptions) ListOptions { + if o.Offset < 0 { + o.Offset = 0 + } + if o.Limit < 1 || o.Limit > 100 { + o.Limit = 10 + } + return o +} +func validate(item *Gift) error { + if item == nil { + return ErrInvalid + } + item.Name = strings.TrimSpace(item.Name) + item.Icon = strings.TrimSpace(item.Icon) + item.Description = strings.TrimSpace(item.Description) + if len([]rune(item.Name)) < 1 || len([]rune(item.Name)) > 12 || item.Icon == "" || item.Price < 1 || item.Price > 99999 || len([]rune(item.Description)) > 50 || item.Sort < 0 || item.Sort > 9999 || len(item.Tiers) < 1 || len(item.Tiers) > 6 { + return ErrInvalid + } + seen := map[int]struct{}{} + for _, v := range item.Tiers { + if v < 1 || v > 9999 { + return ErrInvalid + } + if _, ok := seen[v]; ok { + return ErrInvalid + } + seen[v] = struct{}{} + } + sort.Ints(item.Tiers) + return nil +} diff --git a/internal/biz/gift/provider.go b/internal/biz/gift/provider.go new file mode 100644 index 0000000..db43ab8 --- /dev/null +++ b/internal/biz/gift/provider.go @@ -0,0 +1,5 @@ +package gift + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/live/catalog.go b/internal/biz/live/catalog.go new file mode 100644 index 0000000..8f886c5 --- /dev/null +++ b/internal/biz/live/catalog.go @@ -0,0 +1,330 @@ +package live + +import ( + "context" + "strings" + "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" +) + +const ( + RoomStatusOffline = "offline" + RoomStatusLive = "live" + RoomStatusDisabled = "disabled" +) + +var ( + ErrRoomNotFound = kratoserrors.NotFound("LIVE_ROOM_NOT_FOUND", "live room not found") + ErrCategoryNotFound = kratoserrors.NotFound("LIVE_CATEGORY_NOT_FOUND", "live category not found") + ErrCategoryNameExists = kratoserrors.BadRequest("LIVE_CATEGORY_NAME_EXISTS", "category name already exists") + ErrCategoryInUse = kratoserrors.BadRequest("LIVE_CATEGORY_IN_USE", "category is still used by live rooms") + ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled") + ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting") + ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data") + ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found") +) + +type Category struct { + ID uint + Name string + Icon string + Sort int + Enabled bool + RoomCount int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type Room struct { + ID uint + Name string + CategoryID uint + CategoryName string + CoverURL string + Intro string + Sort int + AnchorUserID uint + AnchorName string + AnchorAvatar string + StreamURL string + ReplayURL string + Enabled bool + Status string + ViewerCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +type CategoryFilter struct { + Name string + Enabled *bool +} + +type RoomFilter struct { + Name string + AnchorName string + CategoryID uint + Status string + Enabled *bool + CreatedAt []time.Time +} + +type ListOptions struct { + Offset int + Limit int + OrderBy string +} + +type CatalogRepo interface { + ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) + GetCategory(context.Context, uint) (*Category, error) + CreateCategory(context.Context, *Category) (*Category, error) + UpdateCategory(context.Context, *Category) (*Category, error) + DeleteCategory(context.Context, uint) error + + ListRooms(context.Context, RoomFilter, ListOptions) ([]*Room, int64, error) + GetRoom(context.Context, uint) (*Room, error) + CreateRoom(context.Context, *Room) (*Room, error) + UpdateRoom(context.Context, *Room) (*Room, error) + DeleteRoom(context.Context, uint) error + UpdateRoomEnabled(context.Context, uint, bool) error + SaveRoomSort(context.Context, map[uint]int) error +} + +type CategoryUsecase struct{ repo CatalogRepo } + +func NewCategoryUsecase(repo CatalogRepo) *CategoryUsecase { return &CategoryUsecase{repo: repo} } + +func (uc *CategoryUsecase) List(ctx context.Context, filter CategoryFilter, options ListOptions) ([]*Category, int64, error) { + filter.Name = strings.TrimSpace(filter.Name) + return uc.repo.ListCategories(ctx, filter, normalizeListOptions(options)) +} + +func (uc *CategoryUsecase) Get(ctx context.Context, id uint) (*Category, error) { + if id == 0 { + return nil, ErrCategoryNotFound + } + return uc.repo.GetCategory(ctx, id) +} + +func (uc *CategoryUsecase) Create(ctx context.Context, item *Category) (*Category, error) { + if err := validateCategory(item); err != nil { + return nil, err + } + return uc.repo.CreateCategory(ctx, item) +} + +func (uc *CategoryUsecase) Update(ctx context.Context, item *Category) (*Category, error) { + if item == nil || item.ID == 0 { + return nil, ErrCategoryNotFound + } + if err := validateCategory(item); err != nil { + return nil, err + } + return uc.repo.UpdateCategory(ctx, item) +} + +func (uc *CategoryUsecase) Delete(ctx context.Context, id uint) error { + if id == 0 { + return ErrCategoryNotFound + } + return uc.repo.DeleteCategory(ctx, id) +} + +type RoomUsecase struct { + repo CatalogRepo + streams *StreamUsecase +} + +func NewRoomUsecase(repo CatalogRepo, streams *StreamUsecase) *RoomUsecase { + return &RoomUsecase{repo: repo, streams: streams} +} + +func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) { + filter.Name = strings.TrimSpace(filter.Name) + filter.AnchorName = strings.TrimSpace(filter.AnchorName) + options = normalizeListOptions(options) + if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline { + return uc.listByRuntimeStatus(ctx, filter, options) + } + rooms, total, err := uc.repo.ListRooms(ctx, filter, options) + if err != nil { + return nil, 0, err + } + return uc.attachRuntime(ctx, rooms), total, nil +} + +func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) { + all := make([]*Room, 0) + for offset := 0; ; offset += 100 { + batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy}) + if err != nil { + return nil, 0, err + } + all = append(all, batch...) + if len(all) >= int(total) || len(batch) == 0 { + break + } + } + all = uc.attachRuntime(ctx, all) + matched := make([]*Room, 0, len(all)) + for _, room := range all { + if room.Status == filter.Status { + matched = append(matched, room) + } + } + total := int64(len(matched)) + if options.Offset >= len(matched) { + return []*Room{}, total, nil + } + end := options.Offset + options.Limit + if end > len(matched) { + end = len(matched) + } + return matched[options.Offset:end], total, nil +} + +func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) { + if id == 0 { + return nil, ErrRoomNotFound + } + room, err := uc.repo.GetRoom(ctx, id) + if err != nil { + return nil, err + } + return uc.attachRuntime(ctx, []*Room{room})[0], nil +} + +func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) { + if err := validateRoom(room); err != nil { + return nil, err + } + return uc.repo.CreateRoom(ctx, room) +} + +func (uc *RoomUsecase) Update(ctx context.Context, room *Room) (*Room, error) { + if room == nil || room.ID == 0 { + return nil, ErrRoomNotFound + } + if err := validateRoom(room); err != nil { + return nil, err + } + current, err := uc.Get(ctx, room.ID) + if err != nil { + return nil, err + } + if current.Status == RoomStatusLive && current.CategoryID != room.CategoryID { + return nil, ErrRoomCategoryLocked + } + return uc.repo.UpdateRoom(ctx, room) +} + +func (uc *RoomUsecase) Delete(ctx context.Context, id uint) error { + if id == 0 { + return ErrRoomNotFound + } + return uc.repo.DeleteRoom(ctx, id) +} + +func (uc *RoomUsecase) SetEnabled(ctx context.Context, id uint, enabled bool) error { + if id == 0 { + return ErrRoomNotFound + } + return uc.repo.UpdateRoomEnabled(ctx, id, enabled) +} + +func (uc *RoomUsecase) SaveSort(ctx context.Context, values map[uint]int) error { + if len(values) == 0 { + return nil + } + for id, sortValue := range values { + if id == 0 || sortValue < 0 || sortValue > 9999 { + return ErrRoomInvalidArgument + } + } + return uc.repo.SaveRoomSort(ctx, values) +} + +func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room { + streams, err := uc.streams.List(ctx) + if err != nil { + return rooms + } + byID := make(map[string]*Stream, len(streams)) + for _, stream := range streams { + byID[stream.StreamID] = stream + } + for _, room := range rooms { + if !room.Enabled { + room.Status = RoomStatusDisabled + continue + } + room.Status = RoomStatusOffline + if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline { + room.Status = RoomStatusLive + room.ViewerCount = stream.SubscriberCount + } + } + return rooms +} + +func roomStreamID(id uint) string { return "room-" + formatUint(id) } + +func formatUint(value uint) string { + const digits = "0123456789" + if value == 0 { + return "0" + } + var buf [20]byte + index := len(buf) + for value > 0 { + index-- + buf[index] = digits[value%10] + value /= 10 + } + return string(buf[index:]) +} + +func normalizeListOptions(options ListOptions) ListOptions { + if options.Offset < 0 { + options.Offset = 0 + } + if options.Limit <= 0 || options.Limit > 100 { + options.Limit = 10 + } + return options +} + +func validateCategory(item *Category) error { + if item == nil { + return ErrRoomInvalidArgument + } + item.Name = strings.TrimSpace(item.Name) + item.Icon = strings.TrimSpace(item.Icon) + if length := len([]rune(item.Name)); length < 1 || length > 10 || item.Sort < 0 || item.Sort > 9999 { + return ErrRoomInvalidArgument + } + return nil +} + +func validateRoom(room *Room) error { + if room == nil { + return ErrRoomInvalidArgument + } + room.Name = strings.TrimSpace(room.Name) + room.Intro = strings.TrimSpace(room.Intro) + room.CoverURL = strings.TrimSpace(room.CoverURL) + room.StreamURL = strings.TrimSpace(room.StreamURL) + room.ReplayURL = strings.TrimSpace(room.ReplayURL) + if nameLength := len([]rune(room.Name)); nameLength < 1 || nameLength > 30 { + return ErrRoomInvalidArgument + } + if len([]rune(room.Intro)) > 60 || room.CategoryID == 0 || room.AnchorUserID == 0 || room.CoverURL == "" || room.Sort < 0 || room.Sort > 9999 { + return ErrRoomInvalidArgument + } + if room.StreamURL != "" && !strings.HasPrefix(room.StreamURL, "http://") && !strings.HasPrefix(room.StreamURL, "https://") { + return ErrRoomInvalidArgument + } + return nil +} diff --git a/internal/biz/live/provider.go b/internal/biz/live/provider.go new file mode 100644 index 0000000..aad5e62 --- /dev/null +++ b/internal/biz/live/provider.go @@ -0,0 +1,5 @@ +package live + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase) diff --git a/internal/biz/live/stream.go b/internal/biz/live/stream.go new file mode 100644 index 0000000..2fb9efe --- /dev/null +++ b/internal/biz/live/stream.go @@ -0,0 +1,84 @@ +package live + +import ( + "context" + "errors" + "strings" + "time" +) + +const ( + MaxStreams = 20 + StatusWaiting = "waiting" + StatusLive = "live" +) + +var ( + ErrInvalidStreamID = errors.New("invalid stream id") + ErrMaxStreams = errors.New("maximum live streams reached") + ErrPublisherExists = errors.New("publisher already exists") + ErrPublisherNotFound = errors.New("publisher not found") + ErrSubscriberExists = errors.New("subscriber already exists") + ErrSubscriberNotFound = errors.New("subscriber not found") +) + +type Stream struct { + StreamID string + PublisherID uint + PublisherOnline bool + SubscriberCount int + UpdatedAt time.Time +} + +type StreamRepo interface { + Get(context.Context, string) (*Stream, error) + List(context.Context) ([]*Stream, error) + RegisterPublisher(context.Context, string, uint) (*Stream, error) + UnregisterPublisher(context.Context, string, uint) error + RegisterSubscriber(context.Context, string, uint) (*Stream, error) + UnregisterSubscriber(context.Context, string, uint) error +} + +type StreamUsecase struct{ repo StreamRepo } + +func NewStreamUsecase(repo StreamRepo) *StreamUsecase { return &StreamUsecase{repo: repo} } + +func (uc *StreamUsecase) List(ctx context.Context) ([]*Stream, error) { + return uc.repo.List(ctx) +} + +func (uc *StreamUsecase) Get(ctx context.Context, streamID string) (*Stream, error) { + streamID = normalizeStreamID(streamID) + if streamID == "" { + return nil, ErrInvalidStreamID + } + return uc.repo.Get(ctx, streamID) +} + +func (uc *StreamUsecase) RegisterPublisher(ctx context.Context, streamID string, userID uint) (*Stream, error) { + streamID = normalizeStreamID(streamID) + if streamID == "" || userID == 0 { + return nil, ErrInvalidStreamID + } + return uc.repo.RegisterPublisher(ctx, streamID, userID) +} + +func (uc *StreamUsecase) UnregisterPublisher(ctx context.Context, streamID string, userID uint) error { + return uc.repo.UnregisterPublisher(ctx, normalizeStreamID(streamID), userID) +} + +func (uc *StreamUsecase) RegisterSubscriber(ctx context.Context, streamID string, userID uint) (*Stream, error) { + streamID = normalizeStreamID(streamID) + if streamID == "" || userID == 0 { + return nil, ErrInvalidStreamID + } + return uc.repo.RegisterSubscriber(ctx, streamID, userID) +} + +func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID string, userID uint) error { + return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID) +} + +func normalizeStreamID(value string) string { + return strings.TrimSpace(value) +} diff --git a/internal/biz/live/stream_test.go b/internal/biz/live/stream_test.go new file mode 100644 index 0000000..08a14fe --- /dev/null +++ b/internal/biz/live/stream_test.go @@ -0,0 +1,60 @@ +package live + +import ( + "context" + "testing" +) + +type testRepo struct { + streams map[string]*Stream +} + +func (r *testRepo) Get(_ context.Context, id string) (*Stream, error) { return r.streams[id], nil } +func (r *testRepo) List(context.Context) ([]*Stream, error) { + result := make([]*Stream, 0, len(r.streams)) + for _, item := range r.streams { + result = append(result, item) + } + return result, nil +} +func (r *testRepo) RegisterPublisher(_ context.Context, id string, user uint) (*Stream, error) { + item := r.streams[id] + if item == nil { + item = &Stream{StreamID: id} + r.streams[id] = item + } + if item.PublisherOnline { + return nil, ErrPublisherExists + } + item.PublisherOnline, item.PublisherID = true, user + return item, nil +} +func (r *testRepo) UnregisterPublisher(_ context.Context, id string, user uint) error { + item := r.streams[id] + if item == nil || !item.PublisherOnline || item.PublisherID != user { + return ErrPublisherNotFound + } + item.PublisherOnline, item.PublisherID = false, 0 + return nil +} +func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, error) { + return nil, nil +} +func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil } + +func TestStreamUsecaseValidatesAndDelegates(t *testing.T) { + repo := &testRepo{streams: make(map[string]*Stream)} + uc := NewStreamUsecase(repo) + if _, err := uc.RegisterPublisher(context.Background(), " ", 1); err != ErrInvalidStreamID { + t.Fatalf("invalid stream error = %v", err) + } + if _, err := uc.RegisterPublisher(context.Background(), "room-1", 1); err != nil { + t.Fatalf("register publisher error = %v", err) + } + if _, err := uc.RegisterPublisher(context.Background(), "room-1", 2); err != ErrPublisherExists { + t.Fatalf("duplicate publisher error = %v", err) + } + if err := uc.UnregisterPublisher(context.Background(), "room-1", 1); err != nil { + t.Fatalf("unregister publisher error = %v", err) + } +} diff --git a/internal/biz/livechat/chat.go b/internal/biz/livechat/chat.go new file mode 100644 index 0000000..abfb91a --- /dev/null +++ b/internal/biz/livechat/chat.go @@ -0,0 +1,85 @@ +package livechat + +import ( + "context" + "strings" + "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" +) + +const ( + TypeSystem = "system" + TypeChat = "chat" + TypeGift = "gift" +) + +var ( + ErrInvalidMessage = kratoserrors.BadRequest("LIVE_CHAT_INVALID", "invalid chat message") + ErrRoomNotFound = kratoserrors.NotFound("LIVE_CHAT_ROOM_NOT_FOUND", "live room not found") + ErrUserNotFound = kratoserrors.NotFound("LIVE_CHAT_USER_NOT_FOUND", "chat user not found") +) + +type Message struct { + ID uint + RoomID uint + Type string + UserID uint + Username string + Nickname string + Avatar string + Content string + GiftID uint + GiftName string + GiftIcon string + Quantity int + DiamondTotal int64 + CreatedAt time.Time +} + +type GiftSnapshot struct { + RoomID uint + UserID uint + Username string + Nickname string + GiftID uint + GiftName string + GiftIcon string + Quantity int + DiamondTotal int64 +} + +type Repo interface { + ListRecent(context.Context, uint, int) ([]*Message, error) + CreateChat(context.Context, uint, uint, string) (*Message, error) + CreateGift(context.Context, *GiftSnapshot) (*Message, error) +} + +type Usecase struct{ repo Repo } + +func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} } + +func (uc *Usecase) ListRecent(ctx context.Context, roomID uint, limit int) ([]*Message, error) { + if roomID == 0 { + return nil, ErrRoomNotFound + } + if limit < 1 || limit > 100 { + limit = 100 + } + return uc.repo.ListRecent(ctx, roomID, limit) +} + +func (uc *Usecase) Send(ctx context.Context, roomID, userID uint, content string) (*Message, error) { + content = strings.TrimSpace(content) + if roomID == 0 || userID == 0 || len([]rune(content)) < 1 || len([]rune(content)) > 50 { + return nil, ErrInvalidMessage + } + return uc.repo.CreateChat(ctx, roomID, userID, content) +} + +func (uc *Usecase) RecordGift(ctx context.Context, gift *GiftSnapshot) (*Message, error) { + if gift == nil || gift.RoomID == 0 || gift.UserID == 0 || gift.GiftID == 0 || gift.Quantity < 1 || gift.DiamondTotal < 1 { + return nil, ErrInvalidMessage + } + return uc.repo.CreateGift(ctx, gift) +} diff --git a/internal/biz/livechat/provider.go b/internal/biz/livechat/provider.go new file mode 100644 index 0000000..af4b4a8 --- /dev/null +++ b/internal/biz/livechat/provider.go @@ -0,0 +1,5 @@ +package livechat + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/liveclient/account.go b/internal/biz/liveclient/account.go new file mode 100644 index 0000000..48186fc --- /dev/null +++ b/internal/biz/liveclient/account.go @@ -0,0 +1,175 @@ +package liveclient + +import ( + "context" + "strings" + "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" + "golang.org/x/crypto/bcrypt" + + "kra/internal/biz/system" +) + +const ( + ClientAuthorityID uint = 1000 + InitialDiamonds int64 = 100 +) + +var ( + ErrInvalidAccount = kratoserrors.BadRequest("LIVE_CLIENT_INVALID", "invalid client account data") + ErrUsernameExists = kratoserrors.BadRequest("LIVE_CLIENT_USERNAME_EXISTS", "username already exists") + ErrAccountNotFound = kratoserrors.NotFound("LIVE_CLIENT_NOT_FOUND", "client account not found") +) + +type Account struct { + UserID uint + UUID string + Username string + Nickname string + Avatar string + AuthorityID uint + Enabled bool + DiamondBalance int64 + CreatedAt time.Time + PasswordUpdatedAt *time.Time +} + +type RegisterInput struct { + Username, Password, ConfirmPassword string +} + +type LoginInput struct { + Username, Password, IP, Agent string +} + +type ProfileInput struct { + UserID uint + Nickname, Avatar string +} + +type PasswordInput struct { + UserID uint + OldPassword, NewPassword, ConfirmPassword string + IP, Agent string +} + +type AuthResult struct { + Account *Account + Auth *system.AuthenticationResult +} + +type Repo interface { + Register(context.Context, *Account, string, int64) (*Account, error) + Get(context.Context, uint) (*Account, error) + UpdateProfile(context.Context, *ProfileInput) (*Account, error) +} + +type Usecase struct { + repo Repo + security *system.SecurityUsecase + auth *system.AuthenticationUsecase + users *system.UserUsecase +} + +func NewUsecase(repo Repo, security *system.SecurityUsecase, auth *system.AuthenticationUsecase, users *system.UserUsecase) *Usecase { + return &Usecase{repo: repo, security: security, auth: auth, users: users} +} + +func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent string) (*AuthResult, error) { + if in == nil { + return nil, ErrInvalidAccount + } + in.Username = strings.TrimSpace(in.Username) + if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword { + return nil, ErrInvalidAccount + } + config, err := uc.security.Current(ctx) + if err != nil { + return nil, err + } + if err = uc.security.ValidatePassword(config, in.Password); err != nil { + return nil, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Username, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds) + if err != nil { + return nil, err + } + auth, err := uc.auth.AuthenticateRegisteredUser(ctx, accountUser(account), ip, agent) + if err != nil { + return nil, err + } + return &AuthResult{Account: account, Auth: auth}, nil +} + +func (uc *Usecase) Login(ctx context.Context, in *LoginInput) (*AuthResult, error) { + if in == nil || strings.TrimSpace(in.Username) == "" || in.Password == "" { + return nil, ErrInvalidAccount + } + auth, err := uc.auth.LoginClient(ctx, &system.LoginAttempt{Username: strings.TrimSpace(in.Username), Password: in.Password, IP: in.IP, Agent: in.Agent}) + if err != nil { + return nil, err + } + account, err := uc.repo.Get(ctx, auth.User.ID) + if err != nil { + return nil, err + } + return &AuthResult{Account: account, Auth: auth}, nil +} + +func (uc *Usecase) Get(ctx context.Context, userID uint) (*Account, error) { + if userID == 0 { + return nil, ErrAccountNotFound + } + return uc.repo.Get(ctx, userID) +} + +func (uc *Usecase) UpdateProfile(ctx context.Context, in *ProfileInput) (*Account, error) { + if in == nil || in.UserID == 0 { + return nil, ErrInvalidAccount + } + in.Nickname = strings.TrimSpace(in.Nickname) + in.Avatar = strings.TrimSpace(in.Avatar) + if in.Nickname != "" { + if n := len([]rune(in.Nickname)); n < 2 || n > 16 { + return nil, ErrInvalidAccount + } + } + if len([]rune(in.Avatar)) > 1000 { + return nil, ErrInvalidAccount + } + return uc.repo.UpdateProfile(ctx, in) +} + +func (uc *Usecase) ChangePassword(ctx context.Context, in *PasswordInput) (*AuthResult, error) { + if in == nil || in.UserID == 0 || in.OldPassword == "" || in.NewPassword == "" || in.NewPassword != in.ConfirmPassword { + return nil, ErrInvalidAccount + } + config, err := uc.security.Current(ctx) + if err != nil { + return nil, err + } + if err = uc.security.ValidatePassword(config, in.NewPassword); err != nil { + return nil, err + } + if err = uc.users.ChangePassword(ctx, in.UserID, in.OldPassword, in.NewPassword); err != nil { + return nil, err + } + account, err := uc.repo.Get(ctx, in.UserID) + if err != nil { + return nil, err + } + auth, err := uc.auth.AuthenticateRegisteredUser(ctx, accountUser(account), in.IP, in.Agent) + if err != nil { + return nil, err + } + return &AuthResult{Account: account, Auth: auth}, nil +} + +func accountUser(account *Account) *system.User { + return &system.User{ID: account.UserID, UUID: account.UUID, Username: account.Username, NickName: account.Nickname, HeaderImg: account.Avatar, AuthorityID: account.AuthorityID, Enable: 1, PasswordUpdatedAt: account.PasswordUpdatedAt} +} diff --git a/internal/biz/liveclient/provider.go b/internal/biz/liveclient/provider.go new file mode 100644 index 0000000..6492ccd --- /dev/null +++ b/internal/biz/liveclient/provider.go @@ -0,0 +1,5 @@ +package liveclient + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/payment/payment.go b/internal/biz/payment/payment.go index bfe53e7..324e879 100644 --- a/internal/biz/payment/payment.go +++ b/internal/biz/payment/payment.go @@ -538,6 +538,10 @@ func supportedPaymentProvider(provider string) bool { return false } +func IsSupportedPaymentProvider(provider string) bool { + return supportedPaymentProvider(strings.TrimSpace(provider)) +} + func IsDemoPaymentOrder(order *PaymentOrder) bool { return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-") } diff --git a/internal/biz/platformsetting/provider.go b/internal/biz/platformsetting/provider.go new file mode 100644 index 0000000..459da44 --- /dev/null +++ b/internal/biz/platformsetting/provider.go @@ -0,0 +1,5 @@ +package platformsetting + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/platformsetting/setting.go b/internal/biz/platformsetting/setting.go new file mode 100644 index 0000000..9503cbb --- /dev/null +++ b/internal/biz/platformsetting/setting.go @@ -0,0 +1,24 @@ +package platformsetting + +import ( + "context" + kratoserrors "github.com/go-kratos/kratos/v3/errors" +) + +var ErrInvalid = kratoserrors.BadRequest("PLATFORM_SETTING_INVALID", "diamond rate must be between 0.01 and 1.00") + +type Setting struct{ DiamondRateCents int64 } +type Repo interface { + Get(context.Context) (*Setting, error) + Save(context.Context, *Setting) error +} +type Usecase struct{ repo Repo } + +func NewUsecase(repo Repo) *Usecase { return &Usecase{repo: repo} } +func (uc *Usecase) Get(ctx context.Context) (*Setting, error) { return uc.repo.Get(ctx) } +func (uc *Usecase) Save(ctx context.Context, v *Setting) error { + if v == nil || v.DiamondRateCents < 1 || v.DiamondRateCents > 100 { + return ErrInvalid + } + return uc.repo.Save(ctx, v) +} diff --git a/internal/biz/recharge/provider.go b/internal/biz/recharge/provider.go new file mode 100644 index 0000000..9cfef2b --- /dev/null +++ b/internal/biz/recharge/provider.go @@ -0,0 +1,5 @@ +package recharge + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewUsecase) diff --git a/internal/biz/recharge/recharge.go b/internal/biz/recharge/recharge.go new file mode 100644 index 0000000..47ad97c --- /dev/null +++ b/internal/biz/recharge/recharge.go @@ -0,0 +1,197 @@ +package recharge + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" + + bizpayment "kra/internal/biz/payment" +) + +const BusinessType = "live_recharge" + +var ( + ErrNotFound = kratoserrors.NotFound("RECHARGE_ORDER_NOT_FOUND", "recharge order not found") + ErrInvalid = kratoserrors.BadRequest("RECHARGE_INVALID", "invalid recharge data") + ErrRefundUnsupported = kratoserrors.BadRequest("RECHARGE_REFUND_UNSUPPORTED", "recharge refunds require manual balance reconciliation") +) + +type Plan struct { + ID uint + Sort int + Diamonds, AmountCents int64 + Enabled bool +} + +// Order owns recharge facts. Payment state comes from KRA's payment order. +type Order struct { + ID uint + OrderNo string + UserID uint + Username, Nickname string + Diamonds, AmountCents int64 + Provider, ProviderTradeNo string + PaymentStatus, FulfillmentStatus, RefundStatus string + CreatedAt time.Time + CreditedAt *time.Time +} + +type Checkout struct { + Provider string + NotifyURL string + ReturnURL string + ClientIP string + UserAgent string + DeviceID string + Extra map[string]any +} + +type Filter struct { + OrderNo, UserKeyword, PaymentStatus, Provider string + CreatedAt []time.Time +} + +type ListOptions struct { + Offset, Limit int + OrderBy string +} + +type Summary struct{ Count, PaidCount, AmountCents int64 } + +type Repo interface { + Plans(context.Context) ([]*Plan, error) + SavePlans(context.Context, []*Plan) error + List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error) + Get(context.Context, string) (*Order, error) + Create(context.Context, uint, uint) (*Order, error) + MarkPaid(context.Context, string) (*Order, error) +} + +type Usecase struct { + repo Repo + payments *bizpayment.PaymentUsecase +} + +func NewUsecase(repo Repo, payments *bizpayment.PaymentUsecase) (*Usecase, error) { + uc := &Usecase{repo: repo, payments: payments} + if payments == nil { + return nil, fmt.Errorf("充值支付服务未接入") + } + if err := payments.RegisterBusinessModule(uc); err != nil { + return nil, err + } + return uc, nil +} + +func (uc *Usecase) Plans(ctx context.Context) ([]*Plan, error) { return uc.repo.Plans(ctx) } + +func (uc *Usecase) SavePlans(ctx context.Context, plans []*Plan) error { + if len(plans) < 1 || len(plans) > 8 { + return ErrInvalid + } + for i, p := range plans { + if p == nil || p.Diamonds < 1 || p.Diamonds > 99999 || p.AmountCents < 1 || p.AmountCents > 999900 { + return ErrInvalid + } + if p.Sort == 0 { + p.Sort = i + 1 + } + p.Enabled = true + } + return uc.repo.SavePlans(ctx, plans) +} + +func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Order, int64, *Summary, error) { + if o.Limit < 1 || o.Limit > 100 { + o.Limit = 10 + } + return uc.repo.List(ctx, f, o) +} + +func (uc *Usecase) Get(ctx context.Context, no string) (*Order, error) { + if strings.TrimSpace(no) == "" { + return nil, ErrNotFound + } + return uc.repo.Get(ctx, no) +} + +func (uc *Usecase) Create(ctx context.Context, userID, planID uint, checkout Checkout) (*Order, *bizpayment.PaymentResult, error) { + checkout.Provider = strings.TrimSpace(checkout.Provider) + if userID == 0 || planID == 0 || checkout.Provider == "" || checkout.Provider == bizpayment.PaymentInternal || !bizpayment.IsSupportedPaymentProvider(checkout.Provider) { + return nil, nil, ErrInvalid + } + order, err := uc.repo.Create(ctx, userID, planID) + if err != nil { + return nil, nil, err + } + extra := make(map[string]any, len(checkout.Extra)+2) + for key, value := range checkout.Extra { + extra[key] = value + } + extra["userId"] = order.UserID + extra["diamonds"] = order.Diamonds + result, err := uc.payments.Create(ctx, &bizpayment.PaymentRequest{ + Provider: checkout.Provider, TradeNo: order.OrderNo, + BusinessType: BusinessType, BusinessID: order.OrderNo, + NotifyURL: checkout.NotifyURL, ReturnURL: checkout.ReturnURL, + ClientIP: checkout.ClientIP, UserAgent: checkout.UserAgent, DeviceID: checkout.DeviceID, + Extra: extra, + }) + if err != nil { + return order, nil, err + } + refreshed, err := uc.repo.Get(ctx, order.OrderNo) + if err != nil { + return nil, nil, err + } + return refreshed, result, nil +} + +func (uc *Usecase) Type() string { return BusinessType } + +func (uc *Usecase) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*bizpayment.PaymentIntent, error) { + if strings.TrimSpace(provider) == "" || tradeNo == "" || businessID != tradeNo { + return nil, bizpayment.ErrPaymentOrderConflict + } + order, err := uc.repo.Get(ctx, businessID) + if err != nil { + return nil, err + } + if order.OrderNo != tradeNo || order.CreditedAt != nil { + return nil, bizpayment.ErrPaymentOrderConflict + } + extra, err := json.Marshal(map[string]any{"userId": order.UserID, "diamonds": order.Diamonds}) + if err != nil { + return nil, err + } + return &bizpayment.PaymentIntent{ + Provider: provider, TradeNo: tradeNo, BusinessType: BusinessType, BusinessID: businessID, + Subject: fmt.Sprintf("充值 %d 钻石", order.Diamonds), Amount: order.AmountCents, + OriginalAmount: order.AmountCents, Currency: "CNY", PaymentMode: bizpayment.PaymentModeExternal, + Extra: extra, + }, nil +} + +func (uc *Usecase) Fulfill(ctx context.Context, confirmation *bizpayment.PaymentConfirmation) error { + if confirmation == nil || confirmation.BusinessType != BusinessType || confirmation.BusinessID == "" || confirmation.TradeNo != confirmation.BusinessID || !strings.EqualFold(confirmation.Currency, "CNY") { + return ErrInvalid + } + order, err := uc.repo.Get(ctx, confirmation.BusinessID) + if err != nil { + return err + } + if order.AmountCents != confirmation.Amount { + return bizpayment.ErrPaymentOrderConflict + } + _, err = uc.repo.MarkPaid(ctx, order.OrderNo) + return err +} + +// Refunds stay disabled until diamonds can be reserved across an async refund. +func (uc *Usecase) AuthorizeRefund(context.Context, *bizpayment.PaymentOrder, int64) error { + return ErrRefundUnsupported +} diff --git a/internal/biz/recharge/recharge_test.go b/internal/biz/recharge/recharge_test.go new file mode 100644 index 0000000..3565bfe --- /dev/null +++ b/internal/biz/recharge/recharge_test.go @@ -0,0 +1,70 @@ +package recharge + +import ( + "context" + "testing" + + bizpayment "kra/internal/biz/payment" +) + +type repoStub struct { + order *Order + paidCall int +} + +func (r *repoStub) Plans(context.Context) ([]*Plan, error) { return nil, nil } +func (r *repoStub) SavePlans(context.Context, []*Plan) error { return nil } +func (r *repoStub) List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error) { + return nil, 0, &Summary{}, nil +} +func (r *repoStub) Get(context.Context, string) (*Order, error) { return r.order, nil } +func (r *repoStub) Create(context.Context, uint, uint) (*Order, error) { return r.order, nil } +func (r *repoStub) MarkPaid(context.Context, string) (*Order, error) { + r.paidCall++ + return r.order, nil +} + +func newTestUsecase(t *testing.T, repo Repo) *Usecase { + t.Helper() + payments := bizpayment.NewPaymentUsecase(nil, nil, bizpayment.NewPaymentHooks(), bizpayment.NewPaymentOrderSourceRegistry(), bizpayment.NewPaymentFulfillmentRegistry(), nil) + uc, err := NewUsecase(repo, payments) + if err != nil { + t.Fatal(err) + } + return uc +} + +func TestPreparePaymentUsesRechargeOrderFacts(t *testing.T) { + repo := &repoStub{order: &Order{OrderNo: "CZ-1", UserID: 7, Diamonds: 300, AmountCents: 3000}} + uc := newTestUsecase(t, repo) + intent, err := uc.PreparePayment(context.Background(), bizpayment.PaymentWechatV3, "CZ-1", "CZ-1") + if err != nil { + t.Fatal(err) + } + if intent.Amount != 3000 || intent.OriginalAmount != 3000 || intent.Currency != "CNY" || intent.Subject != "充值 300 钻石" { + t.Fatalf("unexpected payment intent: %+v", intent) + } + if string(intent.Extra) != `{"diamonds":300,"userId":7}` { + t.Fatalf("unexpected payment extra: %s", intent.Extra) + } +} + +func TestFulfillCreditsMatchingRechargeOnly(t *testing.T) { + repo := &repoStub{order: &Order{OrderNo: "CZ-1", Diamonds: 60, AmountCents: 600}} + uc := newTestUsecase(t, repo) + err := uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{ + TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 600, Currency: "CNY", + }) + if err != nil { + t.Fatal(err) + } + if repo.paidCall != 1 { + t.Fatalf("paid calls = %d, want 1", repo.paidCall) + } + err = uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{ + TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 601, Currency: "CNY", + }) + if err == nil || repo.paidCall != 1 { + t.Fatalf("mismatched payment was credited: err=%v calls=%d", err, repo.paidCall) + } +} diff --git a/internal/biz/system/authentication.go b/internal/biz/system/authentication.go index abbf240..70cd3b0 100644 --- a/internal/biz/system/authentication.go +++ b/internal/biz/system/authentication.go @@ -129,6 +129,69 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp } uc.security.ClearLoginState(ctx, attempt.Username) + return uc.issueAuthenticatedUser(ctx, user, attempt) +} + +// LoginClient authenticates the public live client without the administration +// captcha challenge. Account locking, credential checks, audit logging and +// multipoint-session rotation still use the same security policy as the admin +// login flow. +func (uc *AuthenticationUsecase) LoginClient(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) { + config, err := uc.security.Current(ctx) + if err != nil || config == nil { + return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState) + } + if config.LockEnable { + locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username) + if lockErr != nil { + return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr) + } + if locked { + uc.recordLogin(ctx, attempt, false, "账号已锁定", 0) + return nil, &AccountLockedError{Minutes: config.LockDuration} + } + } + user, err := uc.users.Login(ctx, attempt.Username, attempt.Password) + if err != nil { + if config.LockEnable { + lockTTL := time.Duration(config.LockDuration) * time.Minute + failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL) + if stateErr != nil { + return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr) + } + if int(failures) >= config.LockThreshold { + if stateErr = uc.security.LockLogin(ctx, attempt.Username, lockTTL); stateErr != nil { + return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr) + } + } + } + uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0) + return nil, ErrInvalidCredentials + } + if user.Enable != 1 { + uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID) + return nil, &UserDisabledError{UserID: user.ID} + } + uc.security.ClearLoginState(ctx, attempt.Username) + return uc.issueAuthenticatedUser(ctx, user, attempt) +} + +// AuthenticateRegisteredUser issues the first session immediately after a +// client registration. Credential verification has already succeeded by +// creating the account, so this method only applies the common token/session +// rules. +func (uc *AuthenticationUsecase) AuthenticateRegisteredUser(ctx context.Context, user *User, ip, agent string) (*AuthenticationResult, error) { + if user == nil || user.ID == 0 || user.Enable != 1 { + return nil, ErrInvalidCredentials + } + return uc.issueAuthenticatedUser(ctx, user, &LoginAttempt{Username: user.Username, IP: ip, Agent: agent}) +} + +func (uc *AuthenticationUsecase) issueAuthenticatedUser(ctx context.Context, user *User, attempt *LoginAttempt) (*AuthenticationResult, error) { + config, err := uc.security.Current(ctx) + if err != nil || config == nil { + return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState) + } if passwordExpired(config, user) { user.MustChangePassword = true } diff --git a/internal/data/analytics/dashboard.go b/internal/data/analytics/dashboard.go new file mode 100644 index 0000000..6321e19 --- /dev/null +++ b/internal/data/analytics/dashboard.go @@ -0,0 +1,66 @@ +package analytics + +import ( + "context" + "gorm.io/gorm" + biz "kra/internal/biz/analytics" + "kra/internal/data/ledger" + "strconv" + "time" +) + +type Provider interface{ DB() *gorm.DB } +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } +func (r *repo) Dashboard(ctx context.Context, from, to time.Time) (*biz.Dashboard, error) { + db := r.data.DB().WithContext(ctx) + out := &biz.Dashboard{DiamondRateCents: 10} + if err := db.Table("live_rooms").Where("deleted_at IS NULL").Count(&out.RoomTotal).Error; err != nil { + return nil, err + } + var set ledger.SettingPO + if err := db.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil { + if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil { + out.DiamondRateCents = v + } + } + var f struct{ RechargeCents, PaidOrderCount int64 } + if err := db.Model(&ledger.RechargeOrderPO{}).Select("COALESCE(SUM(amount_cents),0) AS recharge_cents,COUNT(*) AS paid_order_count").Where("credited_at IS NOT NULL AND credited_at BETWEEN ? AND ?", from, to).Scan(&f).Error; err != nil { + return nil, err + } + out.RechargeCents, out.PaidOrderCount = f.RechargeCents, f.PaidOrderCount + var g struct{ Diamonds, Count int64 } + if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(SUM(diamond_total),0) AS diamonds,COUNT(*) AS count").Where("created_at BETWEEN ? AND ?", from, to).Scan(&g).Error; err != nil { + return nil, err + } + out.GiftDiamonds, out.GiftSendCount = g.Diamonds, g.Count + start := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()).AddDate(0, 0, -6) + var points []struct { + Date string + AmountCents int64 + } + if err := db.Model(&ledger.RechargeOrderPO{}).Select("DATE(credited_at) AS date,COALESCE(SUM(amount_cents),0) AS amount_cents").Where("credited_at IS NOT NULL AND credited_at >= ?", start).Group("DATE(credited_at)").Order("DATE(credited_at)").Scan(&points).Error; err != nil { + return nil, err + } + m := map[string]int64{} + for _, p := range points { + m[p.Date] = p.AmountCents + } + for i := 0; i < 7; i++ { + d := start.AddDate(0, 0, i).Format("2006-01-02") + out.RechargeTrend = append(out.RechargeTrend, &biz.TrendPoint{Date: d, AmountCents: m[d]}) + } + var ranks []struct { + RoomID uint + RoomName, AnchorName string + Diamonds int64 + } + if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(room_id,0) AS room_id,room_name,anchor_name,SUM(diamond_total) AS diamonds").Where("created_at BETWEEN ? AND ?", from, to).Group("room_id,room_name,anchor_name").Order("diamonds DESC").Limit(5).Scan(&ranks).Error; err != nil { + return nil, err + } + for _, x := range ranks { + out.GiftRanking = append(out.GiftRanking, &biz.GiftRank{RoomID: x.RoomID, RoomName: x.RoomName, AnchorName: x.AnchorName, Diamonds: x.Diamonds}) + } + return out, nil +} diff --git a/internal/data/analytics/provider.go b/internal/data/analytics/provider.go new file mode 100644 index 0000000..c14994f --- /dev/null +++ b/internal/data/analytics/provider.go @@ -0,0 +1,5 @@ +package analytics + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/asset/asset.go b/internal/data/asset/asset.go new file mode 100644 index 0000000..b95db73 --- /dev/null +++ b/internal/data/asset/asset.go @@ -0,0 +1,144 @@ +package asset + +import ( + "context" + "gorm.io/gorm" + "gorm.io/gorm/clause" + biz "kra/internal/biz/asset" + "kra/internal/data/ledger" + "time" +) + +type Provider interface{ DB() *gorm.DB } +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } +func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) } + +type row struct { + UserID uint `gorm:"column:user_id"` + Username string + Nickname string + Avatar string + Enable int + CreatedAt time.Time + DiamondBalance, TotalRechargeCents, TotalSpentDiamonds int64 + LastLoginAt *time.Time +} + +func query(db *gorm.DB, f biz.Filter) *gorm.DB { + q := db.Table("sys_users").Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id").Where("sys_users.deleted_at IS NULL") + if f.Keyword != "" { + v := "%" + f.Keyword + "%" + q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v) + } + if f.UserID > 0 { + q = q.Where("sys_users.id = ?", f.UserID) + } + if f.Enabled != nil { + if *f.Enabled { + q = q.Where("sys_users.enable = 1") + } else { + q = q.Where("sys_users.enable <> 1") + } + } + if len(f.CreatedAt) == 2 { + q = q.Where("sys_users.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1]) + } + return q +} +func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.User, int64, error) { + q := query(r.db(ctx), f) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []row + sel := "sys_users.id AS user_id,sys_users.username,sys_users.nick_name AS nickname,sys_users.header_img AS avatar,sys_users.enable,sys_users.created_at,COALESCE(live_wallets.diamond_balance,0) AS diamond_balance,COALESCE(live_wallets.total_recharge_cents,0) AS total_recharge_cents,COALESCE(live_wallets.total_spent_diamonds,0) AS total_spent_diamonds,(SELECT MAX(created_at) FROM sys_login_logs WHERE user_id=sys_users.id AND status=?) AS last_login_at" + if err := q.Select(sel, true).Order(userOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil { + return nil, 0, err + } + out := make([]*biz.User, 0, len(rows)) + for i := range rows { + out = append(out, toBiz(&rows[i])) + } + return out, total, nil +} +func (r *repo) Get(ctx context.Context, id uint) (*biz.User, error) { + items, _, err := r.List(ctx, biz.Filter{UserID: id}, biz.ListOptions{Limit: 1}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, biz.ErrNotFound + } + v := items[0] + var orders []ledger.RechargeOrderPO + if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&orders).Error; err != nil { + return nil, err + } + for _, x := range orders { + status := "pending" + if x.CreditedAt != nil { + status = "paid" + } + v.RecentOrders = append(v.RecentOrders, biz.OrderRecord{OrderNo: x.OrderNo, Diamonds: x.Diamonds, AmountCents: x.AmountCents, Status: status, CreatedAt: x.CreatedAt}) + } + var sends []ledger.GiftSendPO + if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&sends).Error; err != nil { + return nil, err + } + for _, x := range sends { + v.RecentSends = append(v.RecentSends, biz.SendRecord{SerialNo: x.SerialNo, RoomName: x.RoomName, GiftName: x.GiftName, GiftIcon: x.GiftIcon, DiamondTotal: x.DiamondTotal, CreatedAt: x.CreatedAt}) + } + return v, nil +} +func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error { + value := 2 + if enabled { + value = 1 + } + res := r.db(ctx).Table("sys_users").Where("id = ? AND deleted_at IS NULL", id).Update("enable", value) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return biz.ErrNotFound + } + return nil +} +func (r *repo) Adjust(ctx context.Context, v *biz.Adjustment) (*biz.User, error) { + err := r.db(ctx).Transaction(func(tx *gorm.DB) error { + wallet := ledger.WalletPO{UserID: v.UserID} + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil { + return err + } + balance := wallet.DiamondBalance + if v.Type == "decrease" { + if balance < v.Amount { + return biz.ErrInsufficient + } + balance -= v.Amount + } else { + balance += v.Amount + } + if err := tx.Model(&wallet).Update("diamond_balance", balance).Error; err != nil { + return err + } + return tx.Create(&ledger.BalanceAdjustmentPO{UserID: v.UserID, OperatorUserID: v.OperatorUserID, Type: v.Type, Amount: v.Amount, Reason: v.Reason, BalanceAfter: balance}).Error + }) + if err != nil { + return nil, err + } + return r.Get(ctx, v.UserID) +} +func toBiz(v *row) *biz.User { + return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt} +} +func userOrder(v string) string { + m := map[string]string{"id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"} + if x := m[v]; x != "" { + return x + } + return "sys_users.id ASC" +} diff --git a/internal/data/asset/asset_test.go b/internal/data/asset/asset_test.go new file mode 100644 index 0000000..5b56b91 --- /dev/null +++ b/internal/data/asset/asset_test.go @@ -0,0 +1,53 @@ +package asset + +import ( + "context" + "errors" + "testing" + + bizasset "kra/internal/biz/asset" + "kra/internal/data/ledger" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type testProvider struct{ db *gorm.DB } + +func (p testProvider) DB() *gorm.DB { return p.db } + +func TestAdjustmentRejectsInsufficientBalanceWithoutAudit(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{}, &ledger.RechargeOrderPO{}, &ledger.GiftSendPO{}); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, created_at datetime, deleted_at datetime)`, + `CREATE TABLE sys_login_logs (id integer primary key, user_id integer, status numeric, created_at datetime)`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name,enable,created_at) VALUES (4,'user','User',1,CURRENT_TIMESTAMP)`).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&ledger.WalletPO{UserID: 4, DiamondBalance: 20}).Error; err != nil { + t.Fatal(err) + } + repo := NewRepo(testProvider{db: db}) + _, err = repo.Adjust(context.Background(), &bizasset.Adjustment{UserID: 4, OperatorUserID: 1, Type: "decrease", Amount: 30, Reason: "test reason"}) + if !errors.Is(err, bizasset.ErrInsufficient) { + t.Fatalf("adjustment error = %v", err) + } + var wallet ledger.WalletPO + var audits int64 + db.Where("user_id = ?", 4).First(&wallet) + db.Model(&ledger.BalanceAdjustmentPO{}).Count(&audits) + if wallet.DiamondBalance != 20 || audits != 0 { + t.Fatalf("failed adjustment changed state: wallet=%+v audits=%d", wallet, audits) + } +} diff --git a/internal/data/asset/migrations.go b/internal/data/asset/migrations.go new file mode 100644 index 0000000..8029745 --- /dev/null +++ b/internal/data/asset/migrations.go @@ -0,0 +1,13 @@ +package asset + +import ( + "gorm.io/gorm" + "kra/internal/data/ledger" + "kra/pkg/database/migration" +) + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error { + return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{}) + }}} +} diff --git a/internal/data/asset/provider.go b/internal/data/asset/provider.go new file mode 100644 index 0000000..8534b51 --- /dev/null +++ b/internal/data/asset/provider.go @@ -0,0 +1,5 @@ +package asset + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/data.go b/internal/data/data.go index 5e081e6..b0d77c0 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -13,8 +13,16 @@ import ( "go.mongodb.org/mongo-driver/mongo" "gorm.io/gorm" "kra/internal/config" + dataanalytics "kra/internal/data/analytics" + dataasset "kra/internal/data/asset" + datgift "kra/internal/data/gift" dataintegration "kra/internal/data/integration" + datalive "kra/internal/data/live" + datalivechat "kra/internal/data/livechat" + dataliveclient "kra/internal/data/liveclient" datapayment "kra/internal/data/payment" + dataplatformsetting "kra/internal/data/platformsetting" + datarecharge "kra/internal/data/recharge" datasystem "kra/internal/data/system" datatask "kra/internal/data/task" "kra/internal/integration/runtimeconfig" @@ -30,7 +38,23 @@ var ProviderSet = wire.NewSet( wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(dataintegration.Provider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)), + wire.Bind(new(datalive.Provider), new(*Data)), + wire.Bind(new(datalivechat.Provider), new(*Data)), + wire.Bind(new(dataliveclient.Provider), new(*Data)), + wire.Bind(new(datgift.Provider), new(*Data)), + wire.Bind(new(datarecharge.Provider), new(*Data)), + wire.Bind(new(dataasset.Provider), new(*Data)), + wire.Bind(new(dataplatformsetting.Provider), new(*Data)), + wire.Bind(new(dataanalytics.Provider), new(*Data)), datasystem.ProviderSet, + datalive.ProviderSet, + datalivechat.ProviderSet, + dataliveclient.ProviderSet, + datgift.ProviderSet, + datarecharge.ProviderSet, + dataasset.ProviderSet, + dataplatformsetting.ProviderSet, + dataanalytics.ProviderSet, dataintegration.ProviderSet, datatask.ProviderSet, datapayment.ProviderSet, diff --git a/internal/data/database.go b/internal/data/database.go index 593517f..ea7cc69 100644 --- a/internal/data/database.go +++ b/internal/data/database.go @@ -205,7 +205,16 @@ func openDatabase(c *config.Database, create bool, template string, appLogger *s case "mssql": bootstrap = "master" } - dsn, err := databaseDSN(c, bootstrap) + bootstrapConfig := c + if driver == "mysql" { + // MySQL creates the target schema after connecting to the server. Do + // not include the not-yet-existing schema in the bootstrap DSN. + copy := *c + copy.Name = "" + copy.Source = "" + bootstrapConfig = © + } + dsn, err := databaseDSN(bootstrapConfig, bootstrap) if err != nil { return nil, err } diff --git a/internal/data/database_test.go b/internal/data/database_test.go index 09a4d8c..dffa147 100644 --- a/internal/data/database_test.go +++ b/internal/data/database_test.go @@ -7,20 +7,16 @@ import ( "kra/internal/config" ) -func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) { - dsn, err := databaseDSN(&config.Database{ - Driver: "mysql", - User: "root", - Password: "secret", - Host: "mysql", - Name: "kra", - }, "") +func TestMySQLBootstrapDSNDoesNotRequireTargetDatabase(t *testing.T) { + value := &config.Database{Driver: "mysql", Host: "127.0.0.1", Port: "3306", User: "root", Password: "secret", Name: "new_database", Config: "parseTime=True"} + copy := *value + copy.Name = "" + copy.Source = "" + dsn, err := databaseDSN(©, "") if err != nil { t.Fatal(err) } - for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} { - if !strings.Contains(dsn, option) { - t.Fatalf("DSN %q does not contain %q", dsn, option) - } + if strings.Contains(dsn, "/new_database?") || !strings.Contains(dsn, "/?parseTime=True") { + t.Fatalf("bootstrap DSN still selects target database: %q", dsn) } } diff --git a/internal/data/gift/gift.go b/internal/data/gift/gift.go new file mode 100644 index 0000000..3283c26 --- /dev/null +++ b/internal/data/gift/gift.go @@ -0,0 +1,310 @@ +package gift + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "gorm.io/gorm" + "gorm.io/gorm/clause" + bizgift "kra/internal/biz/gift" + "kra/internal/data/ledger" + "strconv" + "strings" + "time" +) + +type Provider interface{ DB() *gorm.DB } +type giftPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"size:60;uniqueIndex"` + Icon string `gorm:"size:1000"` + Price int64 `gorm:"index"` + Description string `gorm:"size:255"` + Tiers string `gorm:"type:text"` + Sort int `gorm:"default:0;index"` + Enabled bool `gorm:"default:true;index"` +} + +func (giftPO) TableName() string { return "live_gifts" } + +type repo struct{ data Provider } + +func NewRepo(data Provider) bizgift.Repo { return &repo{data: data} } +func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) } +func (r *repo) List(ctx context.Context, f bizgift.Filter, o bizgift.ListOptions) ([]*bizgift.Gift, int64, error) { + q := r.db(ctx).Model(&giftPO{}) + if f.Name != "" { + q = q.Where("name LIKE ?", "%"+f.Name+"%") + } + if f.MinPrice > 0 { + q = q.Where("price >= ?", f.MinPrice) + } + if f.MaxPrice > 0 { + q = q.Where("price <= ?", f.MaxPrice) + } + if f.Enabled != nil { + q = q.Where("enabled = ?", *f.Enabled) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []giftPO + if err := q.Order(giftOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Find(&rows).Error; err != nil { + return nil, 0, err + } + items := make([]*bizgift.Gift, 0, len(rows)) + for i := range rows { + item := toBiz(&rows[i]) + r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", item.ID).Count(&item.SendCount) + items = append(items, item) + } + return items, total, nil +} +func (r *repo) Get(ctx context.Context, id uint) (*bizgift.Gift, error) { + var row giftPO + if err := r.db(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, bizgift.ErrNotFound + } + return nil, err + } + item := toBiz(&row) + r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", id).Count(&item.SendCount) + return item, nil +} +func (r *repo) Save(ctx context.Context, item *bizgift.Gift) (*bizgift.Gift, error) { + row := newPO(item) + if item.ID == 0 { + if err := r.db(ctx).Create(row).Error; err != nil { + return nil, mapError(err) + } + item.ID = row.ID + } else { + if !item.Enabled { + if err := r.notLast(ctx, item.ID); err != nil { + return nil, err + } + } + res := r.db(ctx).Model(&giftPO{}).Where("id = ?", item.ID).Updates(map[string]any{"name": item.Name, "icon": item.Icon, "price": item.Price, "description": item.Description, "tiers": encode(item.Tiers), "sort": item.Sort, "enabled": item.Enabled}) + if res.Error != nil { + return nil, mapError(res.Error) + } + if res.RowsAffected == 0 { + return nil, bizgift.ErrNotFound + } + } + return r.Get(ctx, item.ID) +} +func (r *repo) Delete(ctx context.Context, id uint) error { + if err := r.notLast(ctx, id); err != nil { + return err + } + res := r.db(ctx).Delete(&giftPO{}, id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return bizgift.ErrNotFound + } + return nil +} +func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error { + if !enabled { + if err := r.notLast(ctx, id); err != nil { + return err + } + } + res := r.db(ctx).Model(&giftPO{}).Where("id = ?", id).Update("enabled", enabled) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return bizgift.ErrNotFound + } + return nil +} +func (r *repo) SaveSort(ctx context.Context, values map[uint]int) error { + return r.db(ctx).Transaction(func(tx *gorm.DB) error { + for id, v := range values { + if err := tx.Model(&giftPO{}).Where("id = ?", id).Update("sort", v).Error; err != nil { + return err + } + } + return nil + }) +} +func (r *repo) notLast(ctx context.Context, id uint) error { + var row giftPO + if err := r.db(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return bizgift.ErrNotFound + } + return err + } + if !row.Enabled { + return nil + } + var count int64 + if err := r.db(ctx).Model(&giftPO{}).Where("enabled = ?", true).Count(&count).Error; err != nil { + return err + } + if count <= 1 { + return bizgift.ErrLastEnabled + } + return nil +} + +type sendRow struct { + ledger.GiftSendPO + Username string `gorm:"column:username"` + Nickname string `gorm:"column:nickname"` + RoomDeleted bool `gorm:"column:room_deleted"` +} + +func (r *repo) ListSends(ctx context.Context, f bizgift.SendFilter, o bizgift.ListOptions) ([]*bizgift.Send, int64, *bizgift.Summary, error) { + q := sendQuery(r.db(ctx), f) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, nil, err + } + var sum struct { + Diamonds int64 + AmountCents int64 + } + if err := sendQuery(r.db(ctx), f).Select("COALESCE(SUM(live_gift_sends.diamond_total),0) AS diamonds,COALESCE(SUM(live_gift_sends.equivalent_cents),0) AS amount_cents").Scan(&sum).Error; err != nil { + return nil, 0, nil, err + } + var rows []sendRow + if err := q.Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,CASE WHEN live_rooms.id IS NULL THEN 1 ELSE 0 END AS room_deleted").Order(sendOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil { + return nil, 0, nil, err + } + items := make([]*bizgift.Send, 0, len(rows)) + for i := range rows { + items = append(items, sendToBiz(&rows[i])) + } + return items, total, &bizgift.Summary{Count: total, Diamonds: sum.Diamonds, AmountCents: sum.AmountCents}, nil +} +func sendQuery(db *gorm.DB, f bizgift.SendFilter) *gorm.DB { + q := db.Model(&ledger.GiftSendPO{}).Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Joins("LEFT JOIN live_rooms ON live_rooms.id = live_gift_sends.room_id AND live_rooms.deleted_at IS NULL") + if f.RoomName != "" { + q = q.Where("live_gift_sends.room_name LIKE ?", "%"+f.RoomName+"%") + } + if f.AnchorName != "" { + q = q.Where("live_gift_sends.anchor_name LIKE ?", "%"+f.AnchorName+"%") + } + if f.UserKeyword != "" { + v := "%" + f.UserKeyword + "%" + q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v) + } + if f.GiftID > 0 { + q = q.Where("live_gift_sends.gift_id = ?", f.GiftID) + } + if len(f.CreatedAt) == 2 { + q = q.Where("live_gift_sends.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1]) + } + return q +} +func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity int) (*bizgift.Send, error) { + var created uint + err := r.db(ctx).Transaction(func(tx *gorm.DB) error { + var room struct { + ID uint + Name string + AnchorUserID uint + AnchorName string + } + if err := tx.Table("live_rooms").Select("live_rooms.id,live_rooms.name,live_rooms.anchor_user_id,COALESCE(NULLIF(sys_users.nick_name,''),sys_users.username) AS anchor_name").Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id").Where("live_rooms.id = ? AND live_rooms.enabled = ? AND live_rooms.deleted_at IS NULL", roomID, true).Scan(&room).Error; err != nil { + return err + } + if room.ID == 0 { + return bizgift.ErrSendNotAllowed + } + var gift giftPO + if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil { + return bizgift.ErrSendNotAllowed + } + allowedQuantity := false + for _, tier := range decode(gift.Tiers) { + if tier == quantity { + allowedQuantity = true + break + } + } + if !allowedQuantity { + return bizgift.ErrSendNotAllowed + } + wallet := ledger.WalletPO{UserID: userID} + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil { + return err + } + total := gift.Price * int64(quantity) + if wallet.DiamondBalance < total { + return bizgift.ErrInsufficientBalance + } + rate := int64(10) + var setting ledger.SettingPO + if err := tx.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&setting).Error; err == nil { + if v, e := strconv.ParseInt(setting.Value, 10, 64); e == nil { + rate = v + } + } + roomIDCopy, giftIDCopy := room.ID, gift.ID + row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate} + if err := tx.Create(&row).Error; err != nil { + return err + } + if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance - ?", total), "total_spent_diamonds": gorm.Expr("total_spent_diamonds + ?", total)}).Error; err != nil { + return err + } + created = row.ID + return nil + }) + if err != nil { + return nil, err + } + var row sendRow + if err := r.db(ctx).Model(&ledger.GiftSendPO{}).Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,0 AS room_deleted").Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Where("live_gift_sends.id = ?", created).Scan(&row).Error; err != nil { + return nil, err + } + return sendToBiz(&row), nil +} +func newPO(v *bizgift.Gift) *giftPO { + return &giftPO{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: encode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled} +} +func toBiz(v *giftPO) *bizgift.Gift { + return &bizgift.Gift{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: decode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt} +} +func sendToBiz(v *sendRow) *bizgift.Send { + return &bizgift.Send{ID: v.ID, SerialNo: v.SerialNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, RoomID: v.RoomID, RoomName: v.RoomName, RoomDeleted: v.RoomDeleted, AnchorUserID: v.AnchorUserID, AnchorName: v.AnchorName, GiftID: v.GiftID, GiftName: v.GiftName, GiftIcon: v.GiftIcon, GiftPrice: v.GiftPrice, Quantity: v.Quantity, DiamondTotal: v.DiamondTotal, RateCents: v.RateCents, EquivalentCents: v.EquivalentCents, CreatedAt: v.CreatedAt} +} +func encode(v []int) string { b, _ := json.Marshal(v); return string(b) } +func decode(v string) []int { var out []int; _ = json.Unmarshal([]byte(v), &out); return out } +func mapError(err error) error { + s := strings.ToLower(err.Error()) + if strings.Contains(s, "unique") || strings.Contains(s, "duplicate") { + return bizgift.ErrNameExists + } + return err +} +func giftOrder(v string) string { + m := map[string]string{"sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC"} + if x := m[strings.ToLower(v)]; x != "" { + return x + } + return "sort ASC,created_at DESC" +} +func sendOrder(v string) string { + m := map[string]string{"created_at_asc": "created_at ASC", "price_asc": "gift_price ASC", "price_desc": "gift_price DESC", "quantity_asc": "quantity ASC", "quantity_desc": "quantity DESC", "total_asc": "diamond_total ASC", "total_desc": "diamond_total DESC"} + if x := m[strings.ToLower(v)]; x != "" { + return x + } + return "created_at DESC" +} +func newNumber(prefix string) string { + return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000) +} diff --git a/internal/data/gift/gift_test.go b/internal/data/gift/gift_test.go new file mode 100644 index 0000000..b150a87 --- /dev/null +++ b/internal/data/gift/gift_test.go @@ -0,0 +1,87 @@ +package gift + +import ( + "context" + "errors" + "testing" + + bizgift "kra/internal/biz/gift" + "kra/internal/data/ledger" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type testProvider struct{ db *gorm.DB } + +func (p testProvider) DB() *gorm.DB { return p.db } + +func openGiftTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&giftPO{}, &ledger.WalletPO{}, &ledger.GiftSendPO{}, &ledger.SettingPO{}); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`, + `CREATE TABLE live_rooms (id integer primary key, name text, anchor_user_id integer, enabled numeric, deleted_at datetime)`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +func TestGiftRepoProtectsLastEnabledGift(t *testing.T) { + db := openGiftTestDB(t) + repo := NewRepo(testProvider{db: db}) + item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Rose", Icon: "rose", Price: 1, Tiers: []int{1}, Enabled: true}) + if err != nil { + t.Fatal(err) + } + if err = repo.SetEnabled(context.Background(), item.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) { + t.Fatalf("disable last gift error = %v", err) + } + if err = repo.Delete(context.Background(), item.ID); !errors.Is(err, bizgift.ErrLastEnabled) { + t.Fatalf("delete last gift error = %v", err) + } +} + +func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) { + db := openGiftTestDB(t) + repo := NewRepo(testProvider{db: db}) + if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil { + t.Fatal(err) + } + item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 5, Tiers: []int{1, 10}, Enabled: true}) + if err != nil { + t.Fatal(err) + } + if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) { + t.Fatalf("unsupported quantity error = %v", err) + } + var before ledger.WalletPO + if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 { + t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err) + } + if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil { + t.Fatal(err) + } + var after ledger.WalletPO + var sends int64 + db.Where("user_id = ?", 1).First(&after) + db.Model(&ledger.GiftSendPO{}).Count(&sends) + if after.DiamondBalance != 50 || after.TotalSpentDiamonds != 50 || sends != 1 { + t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends) + } +} diff --git a/internal/data/gift/migrations.go b/internal/data/gift/migrations.go new file mode 100644 index 0000000..fcbe2f1 --- /dev/null +++ b/internal/data/gift/migrations.go @@ -0,0 +1,23 @@ +package gift + +import ( + "gorm.io/gorm" + "kra/internal/data/ledger" + "kra/pkg/database/migration" +) + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190002_gift_schema", Migrate: func(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &giftPO{}, &ledger.GiftSendPO{}); err != nil { + return err + } + var n int64 + if err := db.Model(&giftPO{}).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return nil + } + return db.Create(&[]giftPO{{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true}, {Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true}, {Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true}, {Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true}}).Error + }}} +} diff --git a/internal/data/gift/provider.go b/internal/data/gift/provider.go new file mode 100644 index 0000000..1cf5c88 --- /dev/null +++ b/internal/data/gift/provider.go @@ -0,0 +1,5 @@ +package gift + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/ledger/models.go b/internal/data/ledger/models.go new file mode 100644 index 0000000..5fda549 --- /dev/null +++ b/internal/data/ledger/models.go @@ -0,0 +1,81 @@ +package ledger + +import "time" + +type WalletPO struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"uniqueIndex"` + DiamondBalance int64 + TotalRechargeCents int64 + TotalSpentDiamonds int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (WalletPO) TableName() string { return "live_wallets" } + +type RechargePlanPO struct { + ID uint `gorm:"primaryKey"` + Sort int `gorm:"index"` + Diamonds int64 + AmountCents int64 + Enabled bool `gorm:"default:true"` +} + +func (RechargePlanPO) TableName() string { return "live_recharge_plans" } + +type RechargeOrderPO struct { + ID uint `gorm:"primaryKey"` + OrderNo string `gorm:"size:80;uniqueIndex"` + UserID uint `gorm:"index"` + Diamonds int64 + AmountCents int64 + RateCents int64 + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time + CreditedAt *time.Time `gorm:"index"` +} + +func (RechargeOrderPO) TableName() string { return "live_recharge_orders" } + +type GiftSendPO struct { + ID uint `gorm:"primaryKey"` + SerialNo string `gorm:"size:80;uniqueIndex"` + UserID uint `gorm:"index"` + RoomID *uint `gorm:"index"` + RoomName string `gorm:"size:120"` + AnchorUserID uint `gorm:"index"` + AnchorName string `gorm:"size:120"` + GiftID *uint `gorm:"index"` + GiftName string `gorm:"size:60"` + GiftIcon string `gorm:"size:1000"` + GiftPrice int64 + Quantity int + DiamondTotal int64 + RateCents int64 + EquivalentCents int64 + CreatedAt time.Time `gorm:"index"` +} + +func (GiftSendPO) TableName() string { return "live_gift_sends" } + +type BalanceAdjustmentPO struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"index"` + OperatorUserID uint `gorm:"index"` + Type string `gorm:"size:20"` + Amount int64 + Reason string `gorm:"size:255"` + BalanceAfter int64 + CreatedAt time.Time `gorm:"index"` +} + +func (BalanceAdjustmentPO) TableName() string { return "live_balance_adjustments" } + +type SettingPO struct { + Key string `gorm:"primaryKey;size:100"` + Value string `gorm:"type:text"` + UpdatedAt time.Time +} + +func (SettingPO) TableName() string { return "live_settings" } diff --git a/internal/data/live/catalog.go b/internal/data/live/catalog.go new file mode 100644 index 0000000..b7e3065 --- /dev/null +++ b/internal/data/live/catalog.go @@ -0,0 +1,364 @@ +package live + +import ( + "context" + "errors" + "strings" + "time" + + bizlive "kra/internal/biz/live" + + "gorm.io/gorm" +) + +type Provider interface { + DB() *gorm.DB +} + +type categoryPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"size:40;uniqueIndex"` + Icon string `gorm:"size:500"` + Sort int `gorm:"default:0;index"` + Enabled bool `gorm:"default:true;index"` +} + +func (categoryPO) TableName() string { return "live_categories" } + +type roomPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `gorm:"size:120;index"` + CategoryID uint `gorm:"index"` + CoverURL string `gorm:"size:1000"` + Intro string `gorm:"size:500"` + Sort int `gorm:"default:0;index"` + AnchorUserID uint `gorm:"index"` + StreamURL string `gorm:"size:1000"` + ReplayURL string `gorm:"size:1000"` + Enabled bool `gorm:"default:true;index"` +} + +func (roomPO) TableName() string { return "live_rooms" } + +type roomRow struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt + Name string + CategoryID uint + CoverURL string + Intro string + Sort int + AnchorUserID uint + StreamURL string + ReplayURL string + Enabled bool + CategoryName string `gorm:"column:category_name"` + AnchorName string `gorm:"column:anchor_name"` + AnchorAvatar string `gorm:"column:anchor_avatar"` +} + +type categoryRow struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt + Name string + Icon string + Sort int + Enabled bool + RoomCount int64 `gorm:"column:room_count"` +} + +type catalogRepo struct{ data Provider } + +func NewCatalogRepo(data Provider) *catalogRepo { return &catalogRepo{data: data} } + +func (r *catalogRepo) ListCategories(ctx context.Context, filter bizlive.CategoryFilter, options bizlive.ListOptions) ([]*bizlive.Category, int64, error) { + db := r.db(ctx) + if db == nil { + return nil, 0, gorm.ErrInvalidDB + } + query := db.Model(&categoryPO{}) + if filter.Name != "" { + query = query.Where("live_categories.name LIKE ?", "%"+filter.Name+"%") + } + if filter.Enabled != nil { + query = query.Where("live_categories.enabled = ?", *filter.Enabled) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + order := categoryOrder(options.OrderBy) + var rows []categoryRow + err := query. + Select("live_categories.*, COUNT(live_rooms.id) AS room_count"). + Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true). + Group("live_categories.id").Order(order).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error + if err != nil { + return nil, 0, err + } + items := make([]*bizlive.Category, 0, len(rows)) + for index := range rows { + items = append(items, categoryToBiz(&rows[index])) + } + return items, total, nil +} + +func (r *catalogRepo) GetCategory(ctx context.Context, id uint) (*bizlive.Category, error) { + var row categoryRow + err := r.db(ctx).Model(&categoryPO{}). + Select("live_categories.*, COUNT(live_rooms.id) AS room_count"). + Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true). + Where("live_categories.id = ?", id).Group("live_categories.id").Scan(&row).Error + if err != nil { + return nil, err + } + if row.ID == 0 { + return nil, bizlive.ErrCategoryNotFound + } + return categoryToBiz(&row), nil +} + +func (r *catalogRepo) CreateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) { + po := newCategory(item) + if err := r.db(ctx).Create(po).Error; err != nil { + return nil, mapCategoryError(err) + } + return r.GetCategory(ctx, po.ID) +} + +func (r *catalogRepo) UpdateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) { + result := r.db(ctx).Model(&categoryPO{}).Where("id = ?", item.ID).Updates(map[string]any{ + "name": item.Name, "icon": item.Icon, "sort": item.Sort, "enabled": item.Enabled, + }) + if result.Error != nil { + return nil, mapCategoryError(result.Error) + } + if result.RowsAffected == 0 { + return nil, bizlive.ErrCategoryNotFound + } + return r.GetCategory(ctx, item.ID) +} + +func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error { + db := r.db(ctx) + var count int64 + if err := db.Model(&roomPO{}).Where("category_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return bizlive.ErrCategoryInUse + } + result := db.Delete(&categoryPO{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return bizlive.ErrCategoryNotFound + } + return nil +} + +func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) { + db := r.db(ctx) + query := db.Model(&roomPO{}). + Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id"). + Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL") + if filter.Name != "" { + query = query.Where("live_rooms.name LIKE ?", "%"+filter.Name+"%") + } + if filter.AnchorName != "" { + value := "%" + filter.AnchorName + "%" + query = query.Where("sys_users.nick_name LIKE ? OR sys_users.username LIKE ?", value, value) + } + if filter.CategoryID > 0 { + query = query.Where("live_rooms.category_id = ?", filter.CategoryID) + } + if filter.Enabled != nil { + query = query.Where("live_rooms.enabled = ?", *filter.Enabled) + } + if filter.Status == bizlive.RoomStatusDisabled { + query = query.Where("live_rooms.enabled = ?", false) + } else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive { + query = query.Where("live_rooms.enabled = ?", true) + } + if len(filter.CreatedAt) == 2 { + query = query.Where("live_rooms.created_at BETWEEN ? AND ?", filter.CreatedAt[0], filter.CreatedAt[1]) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []roomRow + err := query.Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar"). + Order(roomOrder(options.OrderBy)).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error + if err != nil { + return nil, 0, err + } + items := make([]*bizlive.Room, 0, len(rows)) + for index := range rows { + items = append(items, roomToBiz(&rows[index])) + } + return items, total, nil +} + +func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, error) { + var row roomRow + err := r.db(ctx).Model(&roomPO{}). + Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar"). + Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id"). + Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL"). + Where("live_rooms.id = ?", id).Scan(&row).Error + if err != nil { + return nil, err + } + if row.ID == 0 { + return nil, bizlive.ErrRoomNotFound + } + return roomToBiz(&row), nil +} + +func (r *catalogRepo) CreateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) { + if err := r.validateRoomRelations(ctx, item); err != nil { + return nil, err + } + po := newRoom(item) + if err := r.db(ctx).Create(po).Error; err != nil { + return nil, err + } + return r.GetRoom(ctx, po.ID) +} + +func (r *catalogRepo) UpdateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) { + if err := r.validateRoomRelations(ctx, item); err != nil { + return nil, err + } + result := r.db(ctx).Model(&roomPO{}).Where("id = ?", item.ID).Updates(map[string]any{ + "name": item.Name, "category_id": item.CategoryID, "cover_url": item.CoverURL, "intro": item.Intro, + "sort": item.Sort, "anchor_user_id": item.AnchorUserID, "stream_url": item.StreamURL, "replay_url": item.ReplayURL, "enabled": item.Enabled, + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, bizlive.ErrRoomNotFound + } + return r.GetRoom(ctx, item.ID) +} + +func (r *catalogRepo) DeleteRoom(ctx context.Context, id uint) error { + result := r.db(ctx).Delete(&roomPO{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return bizlive.ErrRoomNotFound + } + return nil +} + +func (r *catalogRepo) UpdateRoomEnabled(ctx context.Context, id uint, enabled bool) error { + result := r.db(ctx).Model(&roomPO{}).Where("id = ?", id).Update("enabled", enabled) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return bizlive.ErrRoomNotFound + } + return nil +} + +func (r *catalogRepo) SaveRoomSort(ctx context.Context, values map[uint]int) error { + return r.db(ctx).Transaction(func(tx *gorm.DB) error { + for id, sortValue := range values { + if err := tx.Model(&roomPO{}).Where("id = ?", id).Update("sort", sortValue).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *catalogRepo) validateRoomRelations(ctx context.Context, item *bizlive.Room) error { + db := r.db(ctx) + var category categoryPO + if err := db.First(&category, item.CategoryID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return bizlive.ErrCategoryNotFound + } + return err + } + if !category.Enabled { + return bizlive.ErrCategoryDisabled + } + var count int64 + if err := db.Table("sys_users").Where("id = ? AND deleted_at IS NULL", item.AnchorUserID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return bizlive.ErrAnchorNotFound + } + return nil +} + +func (r *catalogRepo) db(ctx context.Context) *gorm.DB { + if r == nil || r.data == nil || r.data.DB() == nil { + return nil + } + return r.data.DB().WithContext(ctx) +} + +func newCategory(item *bizlive.Category) *categoryPO { + return &categoryPO{Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled} +} + +func categoryToBiz(row *categoryRow) *bizlive.Category { + return &bizlive.Category{ID: row.ID, Name: row.Name, Icon: row.Icon, Sort: row.Sort, Enabled: row.Enabled, RoomCount: row.RoomCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +func newRoom(item *bizlive.Room) *roomPO { + return &roomPO{Name: item.Name, CategoryID: item.CategoryID, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled} +} + +func roomToBiz(row *roomRow) *bizlive.Room { + return &bizlive.Room{ID: row.ID, Name: row.Name, CategoryID: row.CategoryID, CategoryName: row.CategoryName, CoverURL: row.CoverURL, Intro: row.Intro, Sort: row.Sort, AnchorUserID: row.AnchorUserID, AnchorName: row.AnchorName, AnchorAvatar: row.AnchorAvatar, StreamURL: row.StreamURL, ReplayURL: row.ReplayURL, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +func categoryOrder(value string) string { + allowed := map[string]string{"sort_asc": "live_categories.sort ASC", "sort_desc": "live_categories.sort DESC", "created_at_asc": "live_categories.created_at ASC", "created_at_desc": "live_categories.created_at DESC"} + if order := allowed[strings.ToLower(value)]; order != "" { + return order + } + return "live_categories.sort ASC, live_categories.created_at DESC" +} + +func roomOrder(value string) string { + allowed := map[string]string{ + "sort_asc": "live_rooms.sort ASC", "sort_desc": "live_rooms.sort DESC", "id_asc": "live_rooms.id ASC", "id_desc": "live_rooms.id DESC", + "name_asc": "live_rooms.name ASC", "name_desc": "live_rooms.name DESC", "created_at_asc": "live_rooms.created_at ASC", "created_at_desc": "live_rooms.created_at DESC", + } + if order := allowed[strings.ToLower(value)]; order != "" { + return order + } + return "live_rooms.sort ASC, live_rooms.created_at DESC" +} + +func mapCategoryError(err error) error { + if err == nil { + return nil + } + message := strings.ToLower(err.Error()) + if strings.Contains(message, "unique") || strings.Contains(message, "duplicate") { + return bizlive.ErrCategoryNameExists + } + return err +} diff --git a/internal/data/live/catalog_test.go b/internal/data/live/catalog_test.go new file mode 100644 index 0000000..e00e0e0 --- /dev/null +++ b/internal/data/live/catalog_test.go @@ -0,0 +1,76 @@ +package live + +import ( + "context" + "testing" + + bizlive "kra/internal/biz/live" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type catalogTestProvider struct{ db *gorm.DB } + +func (p catalogTestProvider) DB() *gorm.DB { return p.db } + +func openCatalogTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&categoryPO{}, &roomPO{}); err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TABLE sys_users ( + id integer primary key, + username text, + nick_name text, + header_img text, + deleted_at datetime + )`).Error; err != nil { + t.Fatal(err) + } + return db +} + +func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) { + db := openCatalogTestDB(t) + if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil { + t.Fatal(err) + } + category := categoryPO{Name: "Games", Icon: "game.png", Sort: 1, Enabled: true} + if err := db.Create(&category).Error; err != nil { + t.Fatal(err) + } + room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true} + if err := db.Create(&room).Error; err != nil { + t.Fatal(err) + } + + repo := NewCatalogRepo(catalogTestProvider{db: db}) + categories, total, err := repo.ListCategories(context.Background(), bizlive.CategoryFilter{}, bizlive.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(categories) != 1 || categories[0].ID != category.ID || categories[0].Name != category.Name || !categories[0].Enabled || categories[0].RoomCount != 1 { + t.Fatalf("category scan result total=%d categories=%+v", total, categories) + } + + rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{}, bizlive.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(rooms) != 1 || rooms[0].ID != room.ID || rooms[0].Name != room.Name || rooms[0].CategoryName != category.Name || rooms[0].AnchorName != "Anchor" || !rooms[0].Enabled { + t.Fatalf("room list scan result total=%d rooms=%+v", total, rooms) + } + + got, err := repo.GetRoom(context.Background(), room.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled { + t.Fatalf("room get scan result=%+v", got) + } +} diff --git a/internal/data/live/migrations.go b/internal/data/live/migrations.go new file mode 100644 index 0000000..974b28a --- /dev/null +++ b/internal/data/live/migrations.go @@ -0,0 +1,22 @@ +package live + +import ( + "gorm.io/gorm" + "kra/pkg/database/migration" +) + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190001_live_schema", Migrate: func(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &categoryPO{}, &roomPO{}); err != nil { + return err + } + var count int64 + if err := db.Model(&categoryPO{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error + }}} +} diff --git a/internal/data/live/provider.go b/internal/data/live/provider.go new file mode 100644 index 0000000..048eb7d --- /dev/null +++ b/internal/data/live/provider.go @@ -0,0 +1,13 @@ +package live + +import ( + bizlive "kra/internal/biz/live" + + "github.com/google/wire" +) + +var ProviderSet = wire.NewSet( + NewStreamRepo, + NewCatalogRepo, + wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)), +) diff --git a/internal/data/live/stream.go b/internal/data/live/stream.go new file mode 100644 index 0000000..30da988 --- /dev/null +++ b/internal/data/live/stream.go @@ -0,0 +1,130 @@ +package live + +import ( + "context" + "sort" + "sync" + "time" + + bizlive "kra/internal/biz/live" +) + +type streamState struct { + stream *bizlive.Stream + users map[uint]struct{} +} + +type streamRepo struct { + mu sync.RWMutex + streams map[string]*streamState +} + +func NewStreamRepo() bizlive.StreamRepo { + return &streamRepo{streams: make(map[string]*streamState)} +} + +func (r *streamRepo) Get(_ context.Context, streamID string) (*bizlive.Stream, error) { + r.mu.RLock() + defer r.mu.RUnlock() + state := r.streams[streamID] + if state == nil { + return &bizlive.Stream{StreamID: streamID, UpdatedAt: time.Now()}, nil + } + return clone(state.stream), nil +} + +func (r *streamRepo) List(_ context.Context) ([]*bizlive.Stream, error) { + r.mu.RLock() + defer r.mu.RUnlock() + items := make([]*bizlive.Stream, 0, len(r.streams)) + for _, state := range r.streams { + if state.stream.PublisherOnline { + items = append(items, clone(state.stream)) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].StreamID < items[j].StreamID }) + return items, nil +} + +func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) { + r.mu.Lock() + defer r.mu.Unlock() + state := r.streams[streamID] + if state == nil { + if len(r.streams) >= bizlive.MaxStreams { + return nil, bizlive.ErrMaxStreams + } + state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})} + r.streams[streamID] = state + } + if state.stream.PublisherOnline { + return nil, bizlive.ErrPublisherExists + } + state.stream.PublisherID = userID + state.stream.PublisherOnline = true + state.stream.UpdatedAt = time.Now() + return clone(state.stream), nil +} + +func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, userID uint) error { + r.mu.Lock() + defer r.mu.Unlock() + state := r.streams[streamID] + if state == nil || !state.stream.PublisherOnline || state.stream.PublisherID != userID { + return bizlive.ErrPublisherNotFound + } + state.stream.PublisherID = 0 + state.stream.PublisherOnline = false + state.stream.UpdatedAt = time.Now() + if len(state.users) == 0 { + delete(r.streams, streamID) + } + return nil +} + +func (r *streamRepo) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) { + r.mu.Lock() + defer r.mu.Unlock() + state := r.streams[streamID] + if state == nil { + if len(r.streams) >= bizlive.MaxStreams { + return nil, bizlive.ErrMaxStreams + } + state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})} + r.streams[streamID] = state + } + if _, exists := state.users[userID]; exists { + return nil, bizlive.ErrSubscriberExists + } + state.users[userID] = struct{}{} + state.stream.SubscriberCount = len(state.users) + state.stream.UpdatedAt = time.Now() + return clone(state.stream), nil +} + +func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error { + r.mu.Lock() + defer r.mu.Unlock() + state := r.streams[streamID] + if state == nil { + return bizlive.ErrSubscriberNotFound + } + if _, exists := state.users[userID]; !exists { + return bizlive.ErrSubscriberNotFound + } + delete(state.users, userID) + state.stream.SubscriberCount = len(state.users) + state.stream.UpdatedAt = time.Now() + if !state.stream.PublisherOnline && len(state.users) == 0 { + delete(r.streams, streamID) + } + return nil +} + +func clone(stream *bizlive.Stream) *bizlive.Stream { + if stream == nil { + return nil + } + value := *stream + return &value +} diff --git a/internal/data/livechat/chat.go b/internal/data/livechat/chat.go new file mode 100644 index 0000000..7a1c90e --- /dev/null +++ b/internal/data/livechat/chat.go @@ -0,0 +1,138 @@ +package livechat + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + biz "kra/internal/biz/livechat" +) + +type Provider interface{ DB() *gorm.DB } + +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } + +type messagePO struct { + ID uint `gorm:"primaryKey"` + RoomID uint `gorm:"index"` + Type string `gorm:"size:20;index"` + UserID uint `gorm:"index"` + Username string `gorm:"size:120"` + Nickname string `gorm:"size:120"` + Avatar string `gorm:"size:1000"` + Content string `gorm:"size:255"` + GiftID uint + GiftName string `gorm:"size:60"` + GiftIcon string `gorm:"size:1000"` + Quantity int + DiamondTotal int64 + CreatedAt time.Time `gorm:"index"` +} + +func (messagePO) TableName() string { return "live_chat_messages" } + +type userSnapshot struct { + ID uint + Username string + Nickname string + Avatar string +} + +func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.Message, error) { + if err := r.ensureRoom(ctx, roomID); err != nil { + return nil, err + } + var rows []messagePO + if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + out := make([]*biz.Message, 0, len(rows)) + for i := len(rows) - 1; i >= 0; i-- { + out = append(out, toBiz(&rows[i])) + } + return out, nil +} + +func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content string) (*biz.Message, error) { + if err := r.ensureRoom(ctx, roomID); err != nil { + return nil, err + } + user, err := r.user(ctx, userID) + if err != nil { + return nil, err + } + row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content} + if err = r.createAndPrune(ctx, row); err != nil { + return nil, err + } + return toBiz(row), nil +} + +func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) { + if err := r.ensureRoom(ctx, gift.RoomID); err != nil { + return nil, err + } + user, err := r.user(ctx, gift.UserID) + if err != nil { + return nil, err + } + username, nickname := gift.Username, gift.Nickname + if username == "" { + username = user.Username + } + if nickname == "" { + nickname = user.Nickname + } + row := &messagePO{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal} + if err = r.createAndPrune(ctx, row); err != nil { + return nil, err + } + return toBiz(row), nil +} + +func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(row).Error; err != nil { + return err + } + var boundary messagePO + err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error + }) +} + +func (r *repo) ensureRoom(ctx context.Context, roomID uint) error { + var count int64 + if err := r.data.DB().WithContext(ctx).Table("live_rooms").Where("id = ? AND deleted_at IS NULL AND enabled = ?", roomID, true).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return biz.ErrRoomNotFound + } + return nil +} + +func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) { + var row userSnapshot + if err := r.data.DB().WithContext(ctx).Table("sys_users").Select("id, username, nick_name AS nickname, header_img AS avatar").Where("id = ? AND deleted_at IS NULL AND enable = ?", userID, 1).Scan(&row).Error; err != nil { + return nil, err + } + if row.ID == 0 { + return nil, biz.ErrUserNotFound + } + return &row, nil +} + +func toBiz(row *messagePO) *biz.Message { + return &biz.Message{ID: row.ID, RoomID: row.RoomID, Type: row.Type, UserID: row.UserID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, Content: row.Content, GiftID: row.GiftID, GiftName: row.GiftName, GiftIcon: row.GiftIcon, Quantity: row.Quantity, DiamondTotal: row.DiamondTotal, CreatedAt: row.CreatedAt} +} diff --git a/internal/data/livechat/chat_test.go b/internal/data/livechat/chat_test.go new file mode 100644 index 0000000..107e7ab --- /dev/null +++ b/internal/data/livechat/chat_test.go @@ -0,0 +1,67 @@ +package livechat + +import ( + "context" + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + biz "kra/internal/biz/livechat" +) + +type testProvider struct{ db *gorm.DB } + +func (p testProvider) DB() *gorm.DB { return p.db } + +func openChatTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&messagePO{}); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`, + `CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, deleted_at datetime)`, + `INSERT INTO live_rooms(id,enabled) VALUES (7,1)`, + `INSERT INTO sys_users(id,username,nick_name,header_img,enable) VALUES (3,'viewer','Viewer','avatar.png',1)`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +func TestChatKeepsLatestHundredMessages(t *testing.T) { + db := openChatTestDB(t) + repo := NewRepo(testProvider{db: db}) + for index := 1; index <= 105; index++ { + if _, err := repo.CreateChat(context.Background(), 7, 3, fmt.Sprintf("message-%03d", index)); err != nil { + t.Fatal(err) + } + } + items, err := repo.ListRecent(context.Background(), 7, 100) + if err != nil { + t.Fatal(err) + } + if len(items) != 100 || items[0].Content != "message-006" || items[99].Content != "message-105" { + t.Fatalf("history length=%d first=%q last=%q", len(items), items[0].Content, items[len(items)-1].Content) + } +} + +func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) { + db := openChatTestDB(t) + repo := NewRepo(testProvider{db: db}) + item, err := repo.CreateGift(context.Background(), &biz.GiftSnapshot{RoomID: 7, UserID: 3, Username: "viewer", Nickname: "Viewer", GiftID: 8, GiftName: "Star", GiftIcon: "star.png", Quantity: 10, DiamondTotal: 50}) + if err != nil { + t.Fatal(err) + } + if item.Type != biz.TypeGift || item.Avatar != "avatar.png" || item.GiftName != "Star" || item.Quantity != 10 || item.DiamondTotal != 50 { + t.Fatalf("gift message = %+v", item) + } +} diff --git a/internal/data/livechat/migrations.go b/internal/data/livechat/migrations.go new file mode 100644 index 0000000..21bf009 --- /dev/null +++ b/internal/data/livechat/migrations.go @@ -0,0 +1,12 @@ +package livechat + +import ( + "gorm.io/gorm" + "kra/pkg/database/migration" +) + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190007_live_chat_schema", Migrate: func(db *gorm.DB) error { + return migration.CreateMissingTables(db, &messagePO{}) + }}} +} diff --git a/internal/data/livechat/provider.go b/internal/data/livechat/provider.go new file mode 100644 index 0000000..068dbbc --- /dev/null +++ b/internal/data/livechat/provider.go @@ -0,0 +1,5 @@ +package livechat + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/liveclient/account.go b/internal/data/liveclient/account.go new file mode 100644 index 0000000..c16d256 --- /dev/null +++ b/internal/data/liveclient/account.go @@ -0,0 +1,165 @@ +package liveclient + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + biz "kra/internal/biz/liveclient" + "kra/internal/data/ledger" +) + +type Provider interface{ DB() *gorm.DB } + +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } + +type userPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + UUID string + Username string + Password string + NickName string `gorm:"column:nick_name"` + HeaderImg string `gorm:"column:header_img"` + AuthorityID uint `gorm:"column:authority_id"` + Enable int + PasswordUpdatedAt *time.Time + MustChangePassword bool +} + +func (userPO) TableName() string { return "sys_users" } + +type authorityPO struct { + AuthorityID uint `gorm:"primaryKey;column:authority_id"` + AuthorityName string + ParentID *uint + DataScope int + DefaultRouter string +} + +func (authorityPO) TableName() string { return "sys_authorities" } + +type userAuthorityPO struct { + UserID uint `gorm:"column:sys_user_id"` + AuthorityID uint `gorm:"column:sys_authority_authority_id"` +} + +func (userAuthorityPO) TableName() string { return "sys_user_authority" } + +type policyPO struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Ptype string + V0, V1, V2 string + V3, V4, V5 string +} + +func (policyPO) TableName() string { return "casbin_rule" } + +func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash string, initialDiamonds int64) (*biz.Account, error) { + now := time.Now() + user := userPO{UUID: uuid.NewString(), Username: account.Username, Password: passwordHash, NickName: account.Nickname, HeaderImg: account.Avatar, AuthorityID: biz.ClientAuthorityID, Enable: 1, PasswordUpdatedAt: &now} + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&userPO{}).Where("username = ?", account.Username).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return biz.ErrUsernameExists + } + root := uint(0) + authority := authorityPO{AuthorityID: biz.ClientAuthorityID, AuthorityName: "直播用户", ParentID: &root, DataScope: 4, DefaultRouter: "404"} + if err := tx.Where("authority_id = ?", authority.AuthorityID).FirstOrCreate(&authority).Error; err != nil { + return err + } + for _, route := range clientRoutes() { + policy := policyPO{Ptype: "p", V0: strconv.FormatUint(uint64(biz.ClientAuthorityID), 10), V1: route.path, V2: route.method} + if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).FirstOrCreate(&policy).Error; err != nil { + return err + } + } + if err := tx.Create(&user).Error; err != nil { + if duplicate(err) { + return biz.ErrUsernameExists + } + return err + } + if err := tx.Create(&userAuthorityPO{UserID: user.ID, AuthorityID: biz.ClientAuthorityID}).Error; err != nil { + return err + } + return tx.Create(&ledger.WalletPO{UserID: user.ID, DiamondBalance: initialDiamonds}).Error + }) + if err != nil { + return nil, err + } + return r.Get(ctx, user.ID) +} + +func clientRoutes() []struct{ method, path string } { + return []struct{ method, path string }{ + {method: "GET", path: "/live/client/me"}, + {method: "PUT", path: "/live/client/me"}, + {method: "PUT", path: "/live/client/me/password"}, + {method: "POST", path: "/live/rooms/:id/gifts"}, + {method: "POST", path: "/live/recharge/orders"}, + } +} + +func (r *repo) Get(ctx context.Context, userID uint) (*biz.Account, error) { + var row struct { + ID uint + CreatedAt time.Time + UUID string + Username string + Nickname string + Avatar string + AuthorityID uint + Enable int + DiamondBalance int64 + PasswordUpdatedAt *time.Time + } + err := r.data.DB().WithContext(ctx).Table("sys_users"). + Select("sys_users.id, sys_users.created_at, sys_users.uuid, sys_users.username, sys_users.nick_name AS nickname, sys_users.header_img AS avatar, sys_users.authority_id, sys_users.enable, COALESCE(live_wallets.diamond_balance, 0) AS diamond_balance, sys_users.password_updated_at"). + Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id"). + Where("sys_users.id = ? AND sys_users.deleted_at IS NULL", userID).Scan(&row).Error + if err != nil { + return nil, err + } + if row.ID == 0 { + return nil, biz.ErrAccountNotFound + } + return &biz.Account{UserID: row.ID, UUID: row.UUID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, AuthorityID: row.AuthorityID, Enabled: row.Enable == 1, DiamondBalance: row.DiamondBalance, CreatedAt: row.CreatedAt, PasswordUpdatedAt: row.PasswordUpdatedAt}, nil +} + +func (r *repo) UpdateProfile(ctx context.Context, in *biz.ProfileInput) (*biz.Account, error) { + result := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Updates(map[string]any{"nick_name": in.Nickname, "header_img": in.Avatar}) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + var count int64 + if err := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, biz.ErrAccountNotFound + } + } + return r.Get(ctx, in.UserID) +} + +func duplicate(err error) bool { + if err == nil || errors.Is(err, biz.ErrUsernameExists) { + return false + } + text := strings.ToLower(err.Error()) + return strings.Contains(text, "unique") || strings.Contains(text, "duplicate") +} diff --git a/internal/data/liveclient/account_test.go b/internal/data/liveclient/account_test.go new file mode 100644 index 0000000..e4bdb97 --- /dev/null +++ b/internal/data/liveclient/account_test.go @@ -0,0 +1,72 @@ +package liveclient + +import ( + "context" + "errors" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + biz "kra/internal/biz/liveclient" + "kra/internal/data/ledger" +) + +type testProvider struct{ db *gorm.DB } + +func (p testProvider) DB() *gorm.DB { return p.db } + +func openAccountTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&userPO{}, &authorityPO{}, &userAuthorityPO{}, &policyPO{}, &ledger.WalletPO{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestRegisterCreatesCanonicalUserRoleWalletAndPolicies(t *testing.T) { + db := openAccountTestDB(t) + repo := NewRepo(testProvider{db: db}) + account, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "password-hash", biz.InitialDiamonds) + if err != nil { + t.Fatal(err) + } + if account.Username != "viewer" || account.AuthorityID != biz.ClientAuthorityID || account.DiamondBalance != biz.InitialDiamonds { + t.Fatalf("account = %+v", account) + } + var user userPO + var wallet ledger.WalletPO + var links, policies int64 + if err = db.First(&user, account.UserID).Error; err != nil { + t.Fatal(err) + } + if err = db.Where("user_id = ?", account.UserID).First(&wallet).Error; err != nil { + t.Fatal(err) + } + db.Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", account.UserID, biz.ClientAuthorityID).Count(&links) + db.Model(&policyPO{}).Where("ptype = ? AND v0 = ?", "p", "1000").Count(&policies) + if user.AuthorityID != biz.ClientAuthorityID || wallet.DiamondBalance != biz.InitialDiamonds || links != 1 || policies != int64(len(clientRoutes())) { + t.Fatalf("registration state user=%+v wallet=%+v links=%d policies=%d", user, wallet, links, policies) + } +} + +func TestRegisterDuplicateUsernameDoesNotCreateAnotherWallet(t *testing.T) { + db := openAccountTestDB(t) + repo := NewRepo(testProvider{db: db}) + if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "hash", 100); err != nil { + t.Fatal(err) + } + if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Other"}, "hash", 100); !errors.Is(err, biz.ErrUsernameExists) { + t.Fatalf("duplicate error = %v", err) + } + var users, wallets int64 + db.Model(&userPO{}).Count(&users) + db.Model(&ledger.WalletPO{}).Count(&wallets) + if users != 1 || wallets != 1 { + t.Fatalf("duplicate registration users=%d wallets=%d", users, wallets) + } +} diff --git a/internal/data/liveclient/provider.go b/internal/data/liveclient/provider.go new file mode 100644 index 0000000..4c4507a --- /dev/null +++ b/internal/data/liveclient/provider.go @@ -0,0 +1,5 @@ +package liveclient + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 7d33548..46a5278 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = migrateAll(db, testCatalog()); err != nil { t.Fatalf("second migration run: %v", err) } - for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} { + for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events", "live_chat_messages"} { if !db.Migrator().HasTable(table) { t.Fatalf("migration did not create %s", table) } @@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 13 { - t.Fatalf("migration versions = %d, want 13", versions) + if versions != 22 { + t.Fatalf("migration versions = %d, want 22", versions) } var communicationRows []dataintegration.ConfigPO if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { diff --git a/internal/data/platformsetting/migrations.go b/internal/data/platformsetting/migrations.go new file mode 100644 index 0000000..6ce75aa --- /dev/null +++ b/internal/data/platformsetting/migrations.go @@ -0,0 +1,18 @@ +package platformsetting + +import ( + "gorm.io/gorm" + "kra/internal/data/ledger" + "kra/pkg/database/migration" + "time" +) + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190005_platform_setting_schema", Migrate: func(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &ledger.SettingPO{}); err != nil { + return err + } + row := ledger.SettingPO{Key: key, Value: "10", UpdatedAt: time.Now()} + return db.Where(&ledger.SettingPO{Key: key}).FirstOrCreate(&row).Error + }}} +} diff --git a/internal/data/platformsetting/provider.go b/internal/data/platformsetting/provider.go new file mode 100644 index 0000000..3a37e9f --- /dev/null +++ b/internal/data/platformsetting/provider.go @@ -0,0 +1,5 @@ +package platformsetting + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/platformsetting/setting.go b/internal/data/platformsetting/setting.go new file mode 100644 index 0000000..c77ae84 --- /dev/null +++ b/internal/data/platformsetting/setting.go @@ -0,0 +1,38 @@ +package platformsetting + +import ( + "context" + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + biz "kra/internal/biz/platformsetting" + "kra/internal/data/ledger" + "strconv" + "time" +) + +const key = "diamond_rate_cents" + +type Provider interface{ DB() *gorm.DB } +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } +func (r *repo) Get(ctx context.Context) (*biz.Setting, error) { + var row ledger.SettingPO + err := r.data.DB().WithContext(ctx).Where(&ledger.SettingPO{Key: key}).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &biz.Setting{DiamondRateCents: 10}, nil + } + if err != nil { + return nil, err + } + v, e := strconv.ParseInt(row.Value, 10, 64) + if e != nil { + v = 10 + } + return &biz.Setting{DiamondRateCents: v}, nil +} +func (r *repo) Save(ctx context.Context, v *biz.Setting) error { + row := ledger.SettingPO{Key: key, Value: strconv.FormatInt(v.DiamondRateCents, 10), UpdatedAt: time.Now()} + return r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"})}).Create(&row).Error +} diff --git a/internal/data/recharge/migrations.go b/internal/data/recharge/migrations.go new file mode 100644 index 0000000..bff7403 --- /dev/null +++ b/internal/data/recharge/migrations.go @@ -0,0 +1,57 @@ +package recharge + +import ( + "time" + + "gorm.io/gorm" + "kra/internal/data/ledger" + "kra/pkg/database/migration" +) + +// rechargeOrderV1 freezes the original schema owned by the first migration. +type rechargeOrderV1 struct { + ID uint `gorm:"primaryKey"` + OrderNo string `gorm:"size:80;uniqueIndex"` + UserID uint `gorm:"index"` + Diamonds int64 + AmountCents int64 + RateCents int64 + PaymentMethod string `gorm:"size:40;index"` + Status string `gorm:"size:30;index"` + CreatedAt time.Time `gorm:"index"` + UpdatedAt time.Time + CompletedAt *time.Time +} + +func (rechargeOrderV1) TableName() string { return "live_recharge_orders" } + +func Migrations() []migration.Step { + return []migration.Step{{ID: "202609190003_recharge_schema", Migrate: func(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &ledger.RechargePlanPO{}, &rechargeOrderV1{}); err != nil { + return err + } + var n int64 + if err := db.Model(&ledger.RechargePlanPO{}).Count(&n).Error; err != nil { + return err + } + if n > 0 { + return nil + } + return db.Create(&[]ledger.RechargePlanPO{{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}, {Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true}, {Sort: 3, Diamonds: 980, AmountCents: 9800, Enabled: true}, {Sort: 4, Diamonds: 1980, AmountCents: 19800, Enabled: true}, {Sort: 5, Diamonds: 3280, AmountCents: 32800, Enabled: true}, {Sort: 6, Diamonds: 6980, AmountCents: 69800, Enabled: true}}).Error + }}, {ID: "202609190007_recharge_payment_unification", Migrate: func(db *gorm.DB) error { + migrator := db.Migrator() + if !migrator.HasColumn(&ledger.RechargeOrderPO{}, "CreditedAt") { + if err := migrator.AddColumn(&ledger.RechargeOrderPO{}, "CreditedAt"); err != nil { + return err + } + } + for _, column := range []string{"payment_method", "status", "completed_at"} { + if migrator.HasColumn(&rechargeOrderV1{}, column) { + if err := migrator.DropColumn(&rechargeOrderV1{}, column); err != nil { + return err + } + } + } + return nil + }}} +} diff --git a/internal/data/recharge/provider.go b/internal/data/recharge/provider.go new file mode 100644 index 0000000..dcdedba --- /dev/null +++ b/internal/data/recharge/provider.go @@ -0,0 +1,5 @@ +package recharge + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/data/recharge/recharge.go b/internal/data/recharge/recharge.go new file mode 100644 index 0000000..81e8c69 --- /dev/null +++ b/internal/data/recharge/recharge.go @@ -0,0 +1,191 @@ +package recharge + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + biz "kra/internal/biz/recharge" + "kra/internal/data/ledger" +) + +type Provider interface{ DB() *gorm.DB } +type repo struct{ data Provider } + +func NewRepo(data Provider) biz.Repo { return &repo{data: data} } +func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) } + +func (r *repo) Plans(ctx context.Context) ([]*biz.Plan, error) { + var rows []ledger.RechargePlanPO + if err := r.db(ctx).Where("enabled = ?", true).Order("sort ASC,id ASC").Find(&rows).Error; err != nil { + return nil, err + } + out := make([]*biz.Plan, 0, len(rows)) + for _, v := range rows { + out = append(out, &biz.Plan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled}) + } + return out, nil +} + +func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error { + return r.db(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ledger.RechargePlanPO{}).Error; err != nil { + return err + } + for _, p := range plans { + if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil { + return err + } + } + return nil + }) +} + +type row struct { + ledger.RechargeOrderPO + Username string `gorm:"column:username"` + Nickname string `gorm:"column:nickname"` + Provider string `gorm:"column:provider"` + ProviderTradeNo string `gorm:"column:provider_trade_no"` + PaymentStatus string `gorm:"column:payment_status"` + FulfillmentStatus string `gorm:"column:fulfillment_status"` + RefundStatus string `gorm:"column:refund_status"` +} + +func query(db *gorm.DB, f biz.Filter) *gorm.DB { + q := db.Model(&ledger.RechargeOrderPO{}). + Joins("LEFT JOIN sys_users ON sys_users.id = live_recharge_orders.user_id"). + Joins("LEFT JOIN pay_orders ON pay_orders.business_type = ? AND pay_orders.business_id = live_recharge_orders.order_no AND pay_orders.trade_no = live_recharge_orders.order_no", biz.BusinessType) + if f.OrderNo != "" { + q = q.Where("live_recharge_orders.order_no = ?", f.OrderNo) + } + if f.UserKeyword != "" { + v := "%" + f.UserKeyword + "%" + q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v) + } + if f.PaymentStatus != "" { + q = q.Where("pay_orders.payment_status = ?", f.PaymentStatus) + } + if f.Provider != "" { + q = q.Where("pay_orders.provider = ?", f.Provider) + } + if len(f.CreatedAt) == 2 { + q = q.Where("live_recharge_orders.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1]) + } + return q +} + +const orderSelect = "live_recharge_orders.*,sys_users.username,sys_users.nick_name AS nickname,pay_orders.provider,pay_orders.provider_trade_no,pay_orders.payment_status,pay_orders.fulfillment_status,pay_orders.refund_status" + +func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.Order, int64, *biz.Summary, error) { + q := query(r.db(ctx), f) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, nil, err + } + var s struct{ PaidCount, AmountCents int64 } + if err := query(r.db(ctx), f).Select("COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN 1 ELSE 0 END),0) AS paid_count,COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN live_recharge_orders.amount_cents ELSE 0 END),0) AS amount_cents").Scan(&s).Error; err != nil { + return nil, 0, nil, err + } + var rows []row + if err := q.Select(orderSelect).Order(orderBy(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil { + return nil, 0, nil, err + } + out := make([]*biz.Order, 0, len(rows)) + for i := range rows { + out = append(out, toBiz(&rows[i])) + } + return out, total, &biz.Summary{Count: total, PaidCount: s.PaidCount, AmountCents: s.AmountCents}, nil +} + +func (r *repo) Get(ctx context.Context, no string) (*biz.Order, error) { + var v row + if err := query(r.db(ctx), biz.Filter{OrderNo: no}).Select(orderSelect).Scan(&v).Error; err != nil { + return nil, err + } + if v.ID == 0 { + return nil, biz.ErrNotFound + } + return toBiz(&v), nil +} + +func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, error) { + var plan ledger.RechargePlanPO + if err := r.db(ctx).Where("id = ? AND enabled = ?", planID, true).First(&plan).Error; err != nil { + return nil, biz.ErrInvalid + } + rate := int64(10) + var set ledger.SettingPO + if err := r.db(ctx).Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil { + if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil { + rate = v + } + } + v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate} + if err := r.db(ctx).Create(&v).Error; err != nil { + return nil, err + } + return r.Get(ctx, v.OrderNo) +} + +func (r *repo) MarkPaid(ctx context.Context, no string) (*biz.Order, error) { + var credited ledger.RechargeOrderPO + err := r.db(ctx).Transaction(func(tx *gorm.DB) error { + var v ledger.RechargeOrderPO + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", no).First(&v).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return biz.ErrNotFound + } + return err + } + if v.CreditedAt != nil { + credited = v + return nil + } + now := time.Now() + v.CreditedAt = &now + if err := tx.Save(&v).Error; err != nil { + return err + } + wallet := ledger.WalletPO{UserID: v.UserID} + if err := tx.Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil { + return err + } + if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance + ?", v.Diamonds), "total_recharge_cents": gorm.Expr("total_recharge_cents + ?", v.AmountCents)}).Error; err != nil { + return err + } + credited = v + return nil + }) + if err != nil { + return nil, err + } + return toBiz(&row{RechargeOrderPO: credited}), nil +} + +func toBiz(v *row) *biz.Order { + return &biz.Order{ + ID: v.ID, OrderNo: v.OrderNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, + Diamonds: v.Diamonds, AmountCents: v.AmountCents, Provider: v.Provider, ProviderTradeNo: v.ProviderTradeNo, + PaymentStatus: v.PaymentStatus, FulfillmentStatus: v.FulfillmentStatus, RefundStatus: v.RefundStatus, + CreatedAt: v.CreatedAt, CreditedAt: v.CreditedAt, + } +} + +func orderBy(v string) string { + m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"} + if x := m[strings.ToLower(v)]; x != "" { + return x + } + return "live_recharge_orders.created_at DESC" +} + +func number(p string) string { + return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000) +} diff --git a/internal/data/recharge/recharge_test.go b/internal/data/recharge/recharge_test.go new file mode 100644 index 0000000..1dc74f1 --- /dev/null +++ b/internal/data/recharge/recharge_test.go @@ -0,0 +1,97 @@ +package recharge + +import ( + "context" + "testing" + "time" + + biz "kra/internal/biz/recharge" + "kra/internal/data/ledger" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type testProvider struct{ db *gorm.DB } + +func (p testProvider) DB() *gorm.DB { return p.db } + +func TestMarkPaidCreditsWalletOnlyOnce(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil { + t.Fatal(err) + } + order := ledger.RechargeOrderPO{OrderNo: "CZ-1", UserID: 3, Diamonds: 60, AmountCents: 600} + if err = db.Create(&order).Error; err != nil { + t.Fatal(err) + } + repo := NewRepo(testProvider{db: db}) + if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil { + t.Fatal(err) + } + if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil { + t.Fatal(err) + } + var wallet ledger.WalletPO + if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil { + t.Fatal(err) + } + if wallet.DiamondBalance != 60 || wallet.TotalRechargeCents != 600 { + t.Fatalf("wallet credited more than once: %+v", wallet) + } +} + +func TestListReadsPaymentStateFromKRAOrders(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&ledger.RechargeOrderPO{}); err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil { + t.Fatal(err) + } + creditedAt := time.Now() + order := ledger.RechargeOrderPO{OrderNo: "CZ-2", UserID: 3, Diamonds: 300, AmountCents: 3000, CreditedAt: &creditedAt} + if err = db.Create(&order).Error; err != nil { + t.Fatal(err) + } + repo := NewRepo(testProvider{db: db}) + pending, err := repo.Get(context.Background(), order.OrderNo) + if err != nil { + t.Fatal(err) + } + if pending.Provider != "" || pending.PaymentStatus != "" { + t.Fatalf("new business order unexpectedly has payment state: %+v", pending) + } + if err = db.Exec(`INSERT INTO pay_orders(trade_no,business_type,business_id,provider,provider_trade_no,payment_status,fulfillment_status,refund_status) VALUES (?,?,?,?,?,?,?,?)`, order.OrderNo, biz.BusinessType, order.OrderNo, "wechat-v3", "WX-1", "paid", "succeeded", "none").Error; err != nil { + t.Fatal(err) + } + items, total, summary, err := repo.List(context.Background(), biz.Filter{Provider: "wechat-v3", PaymentStatus: "paid"}, biz.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 1 || summary.PaidCount != 1 || summary.AmountCents != 3000 { + t.Fatalf("unexpected list result: total=%d items=%d summary=%+v", total, len(items), summary) + } + item := items[0] + if item.Provider != "wechat-v3" || item.ProviderTradeNo != "WX-1" || item.PaymentStatus != "paid" || item.FulfillmentStatus != "succeeded" || item.RefundStatus != "none" { + t.Fatalf("payment state was not joined: %+v", item) + } +} diff --git a/internal/data/system/surface_upgrade.go b/internal/data/system/surface_upgrade.go index 530f272..624e2e3 100644 --- a/internal/data/system/surface_upgrade.go +++ b/internal/data/system/surface_upgrade.go @@ -127,6 +127,50 @@ func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthori }) } +// RemoveAdminMenus removes obsolete menu metadata without touching business +// data. It is used by module migrations when a menu is intentionally deleted +// from the current admin surface. +func RemoveAdminMenus(db *gorm.DB, names ...string) error { + if db == nil || len(names) == 0 || !db.Migrator().HasTable(&menuPO{}) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + var menus []menuPO + if err := tx.Where("name IN ?", names).Find(&menus).Error; err != nil { + return err + } + if len(menus) == 0 { + return nil + } + + ids := make([]uint, 0, len(menus)) + for _, menu := range menus { + ids = append(ids, menu.ID) + } + if tx.Migrator().HasTable(&authorityMenuPO{}) { + if err := tx.Where("sys_base_menu_id IN ?", ids).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + } + if tx.Migrator().HasTable(&authorityButtonPO{}) { + if err := tx.Where("sys_menu_id IN ?", ids).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + } + if tx.Migrator().HasTable(&menuButtonPO{}) { + if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuButtonPO{}).Error; err != nil { + return err + } + } + if tx.Migrator().HasTable(&menuParameterPO{}) { + if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuParameterPO{}).Error; err != nil { + return err + } + } + return tx.Unscoped().Where("id IN ?", ids).Delete(&menuPO{}).Error + }) +} + // InheritAPIPolicy grants a new route only to authorities that already own // the supplied predecessor route. This preserves existing read-access intent // without broadening unrelated write permissions. diff --git a/internal/integration/live/provider.go b/internal/integration/live/provider.go new file mode 100644 index 0000000..35b319b --- /dev/null +++ b/internal/integration/live/provider.go @@ -0,0 +1,5 @@ +package live + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(New) diff --git a/internal/integration/live/server.go b/internal/integration/live/server.go new file mode 100644 index 0000000..e787337 --- /dev/null +++ b/internal/integration/live/server.go @@ -0,0 +1,714 @@ +package live + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + bizgift "kra/internal/biz/gift" + bizlive "kra/internal/biz/live" + bizchat "kra/internal/biz/livechat" + platformws "kra/pkg/websocket" + + "github.com/olahol/melody" + "github.com/pion/webrtc/v4" +) + +type Manager struct { + hub platformws.Hub + streams *bizlive.StreamUsecase + roomCatalog *bizlive.RoomUsecase + chat *bizchat.Usecase + logger *slog.Logger + api *webrtc.API + mu sync.RWMutex + rooms map[string]*room + peers map[*melody.Session]*peer + observers map[*melody.Session]struct{} + nextSub atomic.Uint64 + stop chan struct{} + stopOnce sync.Once +} + +type room struct { + mu sync.RWMutex + streamID string + publisher *peer + viewers map[*peer]struct{} + tracks map[webrtc.RTPCodecType]*publishedTrack +} + +type publishedTrack struct { + id string + kind webrtc.RTPCodecType + local *webrtc.TrackLocalStaticRTP +} + +type peer struct { + session *melody.Session + manager *Manager + room *room + streamID string + userID uint + role string + subscriberID string + pc *webrtc.PeerConnection + lastBeat atomic.Int64 + cleanup sync.Once +} + +type message struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type joinPayload struct { + Role string `json:"role"` + StreamID string `json:"streamId"` +} + +type sessionDescriptionPayload struct { + Type string `json:"type"` + SDP string `json:"sdp"` +} + +type chatSendPayload struct { + Content string `json:"content"` +} + +func newPionAPI() (*webrtc.API, error) { + settings := webrtc.SettingEngine{} + minPort := envPort("KRA_LIVE_UDP_MIN_PORT") + maxPort := envPort("KRA_LIVE_UDP_MAX_PORT") + if minPort > 0 || maxPort > 0 { + if minPort == 0 || maxPort == 0 || minPort > maxPort { + return nil, fmt.Errorf("invalid KRA_LIVE_UDP_MIN_PORT/KRA_LIVE_UDP_MAX_PORT") + } + if err := settings.SetEphemeralUDPPortRange(minPort, maxPort); err != nil { + return nil, fmt.Errorf("configure live udp port range: %w", err) + } + } + return webrtc.NewAPI(webrtc.WithSettingEngine(settings)), nil +} + +func envPort(key string) uint16 { + value, err := strconv.ParseUint(strings.TrimSpace(os.Getenv(key)), 10, 16) + if err != nil { + return 0 + } + return uint16(value) +} + +func liveICEServers() []webrtc.ICEServer { + value := strings.TrimSpace(os.Getenv("KRA_LIVE_STUN_URL")) + if value == "" { + return nil + } + return []webrtc.ICEServer{{URLs: []string{value}}} +} + +func New(hub platformws.Hub, streams *bizlive.StreamUsecase, rooms *bizlive.RoomUsecase, chat *bizchat.Usecase, logger *slog.Logger) (*Manager, func(), error) { + if hub == nil { + return nil, nil, errors.New("live websocket hub is unavailable") + } + if streams == nil { + return nil, nil, errors.New("live stream usecase is unavailable") + } + if rooms == nil { + return nil, nil, errors.New("live room usecase is unavailable") + } + if chat == nil { + return nil, nil, errors.New("live chat usecase is unavailable") + } + api, err := newPionAPI() + if err != nil { + return nil, nil, err + } + m := &Manager{hub: hub, streams: streams, roomCatalog: rooms, chat: chat, logger: logger, api: api, rooms: make(map[string]*room), peers: make(map[*melody.Session]*peer), observers: make(map[*melody.Session]struct{}), stop: make(chan struct{})} + hub.OnMessage(m.onMessage) + hub.OnDisconnect(m.onDisconnect) + go m.heartbeatLoop() + return m, m.Close, nil +} + +func (m *Manager) Close() { + m.stopOnce.Do(func() { close(m.stop) }) + m.mu.Lock() + peers := make([]*peer, 0, len(m.peers)) + for _, p := range m.peers { + peers = append(peers, p) + } + m.mu.Unlock() + for _, p := range peers { + m.closePeer(p, "server_shutdown") + } +} + +func (m *Manager) onMessage(session *melody.Session, raw []byte) { + var msg message + if err := json.Unmarshal(raw, &msg); err != nil { + m.send(session, "Error", map[string]any{"code": "INVALID_JSON", "message": "invalid json"}) + return + } + switch msg.Type { + case "Join": + var payload joinPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "invalid join payload"}) + return + } + m.join(session, payload) + case "Offer": + var payload sessionDescriptionPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + m.send(session, "Error", map[string]any{"code": "INVALID_OFFER", "message": "invalid offer payload"}) + return + } + m.handleOffer(session, payload) + case "Heartbeat": + if p := m.peer(session); p != nil { + p.lastBeat.Store(time.Now().UnixMilli()) + } + m.send(session, "HeartbeatAck", map[string]any{"streamId": peerStreamID(m, session)}) + case "Leave": + if p := m.peer(session); p != nil { + m.closePeer(p, "left") + } + case "ChatSend": + var payload chatSendPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": "invalid chat payload"}) + return + } + m.handleChat(session, payload) + default: + m.send(session, "Error", map[string]any{"code": "UNKNOWN_MESSAGE", "message": "unknown message type"}) + } +} + +func (m *Manager) join(session *melody.Session, payload joinPayload) { + role := strings.ToLower(strings.TrimSpace(payload.Role)) + streamID := strings.TrimSpace(payload.StreamID) + if role == "observer" { + m.mu.Lock() + m.observers[session] = struct{}{} + m.mu.Unlock() + m.send(session, "Joined", map[string]any{"role": "observer"}) + m.sendStreamList(session) + return + } + if streamID == "" || (role != "publisher" && role != "subscriber") { + m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"}) + return + } + userID, ok := sessionUserID(session) + if !ok { + m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"}) + return + } + if existing := m.peer(session); existing != nil { + m.closePeer(existing, "rejoin") + } + r := m.getOrCreateRoom(streamID) + if role == "publisher" { + if err := m.authorizeRoomPublisher(streamID, userID); err != nil { + m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()}) + return + } + r.mu.RLock() + occupied := r.publisher != nil + r.mu.RUnlock() + if occupied { + m.send(session, "JoinRejected", map[string]any{"code": "DUPLICATE_PUBLISHER", "message": "stream already has a publisher"}) + return + } + if _, err := m.streams.RegisterPublisher(context.Background(), streamID, userID); err != nil { + m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()}) + return + } + p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role} + p.lastBeat.Store(time.Now().UnixMilli()) + r.mu.Lock() + r.publisher = p + r.mu.Unlock() + m.mu.Lock() + m.peers[session] = p + m.mu.Unlock() + m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "mode": "pion-sfu"}) + m.sendChatHistory(session, streamID) + m.sendPublisherStatus(streamID, false, "publisher_waiting_for_offer") + m.broadcastStreamList() + return + } + + // Match the old behavior: a user can only watch a room once. The old + // connection is cleaned up before registering the new one. + var old *peer + r.mu.RLock() + for viewer := range r.viewers { + if viewer.userID == userID { + old = viewer + break + } + } + r.mu.RUnlock() + if old != nil { + m.send(old.session, "Kicked", map[string]any{"reason": "logged_in_elsewhere", "streamId": streamID}) + m.closePeer(old, "logged_in_elsewhere") + } + if _, err := m.streams.RegisterSubscriber(context.Background(), streamID, userID); err != nil { + m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()}) + return + } + p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)} + p.lastBeat.Store(time.Now().UnixMilli()) + r.mu.Lock() + r.viewers[p] = struct{}{} + ready := r.publisher != nil && len(r.tracks) > 0 + r.mu.Unlock() + m.mu.Lock() + m.peers[session] = p + m.mu.Unlock() + m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": userID, "mode": "pion-sfu"}) + m.sendChatHistory(session, streamID) + m.sendPublisherStatusTo(session, streamID, ready, "publisher_offline") + if ready { + m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true}) + } + if publisher := m.publisher(r); publisher != nil { + m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": userID}) + } + m.broadcastStreamList() +} + +func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) { + p := m.peer(session) + if p == nil || (p.role != "publisher" && p.role != "subscriber") { + m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"}) + return + } + roomID, ok := roomIDFromStream(p.streamID) + if !ok { + m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"}) + return + } + item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content) + if err != nil { + m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()}) + return + } + m.broadcastRoom(p.streamID, "ChatMessage", chatMessagePayload(item)) +} + +func (m *Manager) sendChatHistory(session *melody.Session, streamID string) { + roomID, ok := roomIDFromStream(streamID) + if !ok { + return + } + items, err := m.chat.ListRecent(context.Background(), roomID, 100) + if err != nil { + m.log("load live chat history failed", "room_id", roomID, "error", err) + return + } + messages := make([]map[string]any, 0, len(items)) + for _, item := range items { + messages = append(messages, chatMessagePayload(item)) + } + m.send(session, "ChatHistory", map[string]any{"roomId": roomID, "messages": messages}) +} + +// GiftSent implements gift.Notifier. Gift persistence and wallet debit have +// already committed; chat persistence is therefore best-effort and must never +// turn a successful debit into a failed gift response. +func (m *Manager) GiftSent(ctx context.Context, sent *bizgift.Send) { + if sent == nil || sent.RoomID == nil || sent.GiftID == nil { + return + } + item, err := m.chat.RecordGift(ctx, &bizchat.GiftSnapshot{RoomID: *sent.RoomID, UserID: sent.UserID, Username: sent.Username, Nickname: sent.Nickname, GiftID: *sent.GiftID, GiftName: sent.GiftName, GiftIcon: sent.GiftIcon, Quantity: sent.Quantity, DiamondTotal: sent.DiamondTotal}) + if err != nil { + m.log("record live gift chat failed", "room_id", *sent.RoomID, "gift_send_id", sent.ID, "error", err) + return + } + m.broadcastRoom("room-"+strconv.FormatUint(uint64(*sent.RoomID), 10), "GiftMessage", chatMessagePayload(item)) +} + +func (m *Manager) broadcastRoom(streamID, msgType string, payload any) { + m.mu.RLock() + peers := make([]*peer, 0) + for _, p := range m.peers { + if p.streamID == streamID && (p.role == "publisher" || p.role == "subscriber") { + peers = append(peers, p) + } + } + m.mu.RUnlock() + for _, p := range peers { + m.send(p.session, msgType, payload) + } +} + +func chatMessagePayload(item *bizchat.Message) map[string]any { + return map[string]any{"id": item.ID, "roomId": item.RoomID, "type": item.Type, "userId": item.UserID, "username": item.Username, "nickname": item.Nickname, "avatar": item.Avatar, "content": item.Content, "giftId": item.GiftID, "giftName": item.GiftName, "giftIcon": item.GiftIcon, "quantity": item.Quantity, "diamondTotal": item.DiamondTotal, "createdAt": item.CreatedAt} +} + +func roomIDFromStream(streamID string) (uint, bool) { + if !strings.HasPrefix(streamID, "room-") { + return 0, false + } + value, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64) + return uint(value), err == nil && value > 0 +} + +func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error { + if !strings.HasPrefix(streamID, "room-") { + return nil + } + id, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64) + if err != nil || id == 0 { + return errors.New("invalid live room stream id") + } + item, err := m.roomCatalog.Get(context.Background(), uint(id)) + if err != nil { + return errors.New("live room is unavailable") + } + if !item.Enabled { + return errors.New("live room is disabled") + } + if item.AnchorUserID != userID { + return errors.New("current user is not the room anchor") + } + return nil +} + +func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) { + p := m.peer(session) + if p == nil || (p.role != "publisher" && p.role != "subscriber") { + m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a stream first"}) + return + } + if strings.TrimSpace(payload.SDP) == "" { + m.send(session, "Error", map[string]any{"code": "INVALID_SDP", "message": "sdp is required"}) + return + } + if p.pc != nil { + _ = p.pc.Close() + } + pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()}) + if err != nil { + m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()}) + return + } + p.pc = pc + pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { + m.closePeer(p, "ice_"+state.String()) + } + }) + if p.role == "publisher" { + pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + m.onPublisherTrack(p, track) + }) + } else { + for _, track := range m.trackSnapshot(p.room) { + if sender, addErr := pc.AddTrack(track.local); addErr == nil { + go drainRTCP(sender) + } + } + } + if err = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: payload.SDP}); err != nil { + m.send(session, "Error", map[string]any{"code": "REMOTE_DESCRIPTION", "message": err.Error()}) + return + } + answer, err := pc.CreateAnswer(nil) + if err != nil { + m.send(session, "Error", map[string]any{"code": "CREATE_ANSWER", "message": err.Error()}) + return + } + if err = pc.SetLocalDescription(answer); err != nil { + m.send(session, "Error", map[string]any{"code": "LOCAL_DESCRIPTION", "message": err.Error()}) + return + } + <-webrtc.GatheringCompletePromise(pc) + local := pc.LocalDescription() + if local == nil { + m.send(session, "Error", map[string]any{"code": "NO_LOCAL_DESCRIPTION", "message": "local description unavailable"}) + return + } + m.send(session, "Answer", map[string]any{"type": local.Type.String(), "sdp": local.SDP}) + if p.role == "publisher" { + m.sendPublisherStatus(p.streamID, false, "waiting_for_tracks") + } +} + +func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) { + codec := remote.Codec().RTPCodecCapability + local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID) + if err != nil { + m.log("live track creation failed", "error", err) + return + } + entry := &publishedTrack{id: remote.ID(), kind: remote.Kind(), local: local} + p.room.mu.Lock() + p.room.tracks[remote.Kind()] = entry + viewers := make([]*peer, 0, len(p.room.viewers)) + for viewer := range p.room.viewers { + viewers = append(viewers, viewer) + } + p.room.mu.Unlock() + m.sendPublisherStatus(p.streamID, true, "") + for _, viewer := range viewers { + m.send(viewer.session, "NewProducer", map[string]any{"streamId": p.streamID, "producerId": remote.ID(), "kind": strings.ToLower(remote.Kind().String())}) + m.send(viewer.session, "Renegotiate", map[string]any{"streamId": p.streamID}) + } + go func() { + for { + packet, _, readErr := remote.ReadRTP() + if readErr != nil { + return + } + _ = local.WriteRTP(packet) + } + }() +} + +func (m *Manager) onDisconnect(session *melody.Session) { + if p := m.peer(session); p != nil { + m.closePeer(p, "disconnected") + return + } + m.mu.Lock() + delete(m.observers, session) + m.mu.Unlock() +} + +func (m *Manager) heartbeatLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now().UnixMilli() + m.mu.RLock() + peers := make([]*peer, 0, len(m.peers)) + for _, p := range m.peers { + peers = append(peers, p) + } + m.mu.RUnlock() + for _, p := range peers { + if now-p.lastBeat.Load() >= (45 * time.Second).Milliseconds() { + m.closePeer(p, "heartbeat_timeout") + } + } + case <-m.stop: + return + } + } +} + +func (m *Manager) closePeer(p *peer, reason string) { + if p == nil { + return + } + p.cleanup.Do(func() { + if p.pc != nil { + _ = p.pc.Close() + } + if p.role == "publisher" { + _ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID) + p.room.mu.Lock() + tracks := make([]*publishedTrack, 0, len(p.room.tracks)) + for _, track := range p.room.tracks { + tracks = append(tracks, track) + } + if p.room.publisher == p { + p.room.publisher = nil + } + p.room.tracks = make(map[webrtc.RTPCodecType]*publishedTrack) + viewers := make([]*peer, 0, len(p.room.viewers)) + for viewer := range p.room.viewers { + viewers = append(viewers, viewer) + } + p.room.mu.Unlock() + for _, viewer := range viewers { + for _, track := range tracks { + m.send(viewer.session, "ProducerClosed", map[string]any{"streamId": p.streamID, "producerId": track.id, "kind": strings.ToLower(track.kind.String()), "reason": reason}) + } + m.send(viewer.session, "PublisherStatus", map[string]any{"streamId": p.streamID, "ready": false, "reason": reason}) + } + } else if p.role == "subscriber" { + _ = m.streams.UnregisterSubscriber(context.Background(), p.streamID, p.userID) + p.room.mu.Lock() + delete(p.room.viewers, p) + publisher := p.room.publisher + p.room.mu.Unlock() + if publisher != nil { + m.send(publisher.session, "SubscriberLeft", map[string]any{"streamId": p.streamID, "subscriberId": p.subscriberID}) + } + } + m.mu.Lock() + delete(m.peers, p.session) + p.room.mu.RLock() + emptyRoom := p.room.publisher == nil && len(p.room.viewers) == 0 + p.room.mu.RUnlock() + if emptyRoom { + delete(m.rooms, p.streamID) + } + m.mu.Unlock() + m.broadcastStreamList() + }) +} + +func (m *Manager) getOrCreateRoom(streamID string) *room { + m.mu.Lock() + defer m.mu.Unlock() + if r := m.rooms[streamID]; r != nil { + return r + } + r := &room{streamID: streamID, viewers: make(map[*peer]struct{}), tracks: make(map[webrtc.RTPCodecType]*publishedTrack)} + m.rooms[streamID] = r + return r +} + +func (m *Manager) peer(session *melody.Session) *peer { + m.mu.RLock() + defer m.mu.RUnlock() + return m.peers[session] +} + +func (m *Manager) publisher(r *room) *peer { + r.mu.RLock() + defer r.mu.RUnlock() + return r.publisher +} + +func (m *Manager) trackSnapshot(r *room) []*publishedTrack { + r.mu.RLock() + defer r.mu.RUnlock() + tracks := make([]*publishedTrack, 0, len(r.tracks)) + for _, track := range r.tracks { + tracks = append(tracks, track) + } + return tracks +} + +func (m *Manager) sendStreamList(session *melody.Session) { + streams, err := m.streams.List(context.Background()) + if err != nil { + return + } + items := make([]map[string]any, 0, len(streams)) + for _, stream := range streams { + items = append(items, map[string]any{"streamId": stream.StreamID, "publisherOnline": stream.PublisherOnline, "subscriberCount": stream.SubscriberCount}) + } + m.send(session, "StreamListUpdate", map[string]any{"streams": items}) +} + +func (m *Manager) broadcastStreamList() { + m.mu.RLock() + observers := make([]*melody.Session, 0, len(m.observers)) + for session := range m.observers { + observers = append(observers, session) + } + m.mu.RUnlock() + for _, session := range observers { + m.sendStreamList(session) + } +} + +func (m *Manager) sendPublisherStatus(streamID string, ready bool, reason string) { + m.mu.RLock() + peers := make([]*peer, 0, len(m.peers)) + for _, p := range m.peers { + if p.role == "subscriber" && p.streamID == streamID { + peers = append(peers, p) + } + } + m.mu.RUnlock() + for _, p := range peers { + m.sendPublisherStatusTo(p.session, streamID, ready, reason) + } +} + +func (m *Manager) sendPublisherStatusTo(session *melody.Session, streamID string, ready bool, reason string) { + payload := map[string]any{"streamId": streamID, "ready": ready} + if reason != "" { + payload["reason"] = reason + } + m.send(session, "PublisherStatus", payload) +} + +func (m *Manager) send(session *melody.Session, msgType string, payload any) { + encoded, err := json.Marshal(map[string]any{"type": msgType, "payload": payload}) + if err != nil { + return + } + _ = m.hub.Send(session, encoded) +} + +func (m *Manager) log(message string, args ...any) { + if m.logger != nil { + m.logger.Warn(message, args...) + } +} + +func sessionUserID(session *melody.Session) (uint, bool) { + if session == nil { + return 0, false + } + value, ok := session.Keys["user_id"] + if !ok || value == nil { + return 0, false + } + switch typed := value.(type) { + case uint: + return typed, typed > 0 + case uint64: + return uint(typed), typed > 0 + case int: + return uint(typed), typed > 0 + case int64: + return uint(typed), typed > 0 + case string: + parsed, err := strconv.ParseUint(typed, 10, 64) + return uint(parsed), err == nil && parsed > 0 + default: + parsed, err := strconv.ParseUint(fmt.Sprint(value), 10, 64) + return uint(parsed), err == nil && parsed > 0 + } +} + +func peerStreamID(m *Manager, session *melody.Session) string { + if p := m.peer(session); p != nil { + return p.streamID + } + return "" +} + +func errorCode(err error) string { + switch { + case errors.Is(err, bizlive.ErrMaxStreams): + return "MAX_STREAMS" + case errors.Is(err, bizlive.ErrPublisherExists): + return "DUPLICATE_PUBLISHER" + case errors.Is(err, bizlive.ErrSubscriberExists): + return "DUPLICATE_SUBSCRIBER" + default: + return "INVALID_JOIN" + } +} + +func drainRTCP(sender *webrtc.RTPSender) { + for { + if _, _, err := sender.ReadRTCP(); err != nil { + return + } + } +} diff --git a/internal/integration/provider.go b/internal/integration/provider.go index 89e5b63..b74391c 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -4,11 +4,13 @@ package integration import ( + bizgift "kra/internal/biz/gift" integrationbiz "kra/internal/biz/integration" bizpayment "kra/internal/biz/payment" "kra/internal/biz/system" "kra/internal/integration/cache" "kra/internal/integration/email" + liveintegration "kra/internal/integration/live" mqintegration "kra/internal/integration/mq" paymentintegration "kra/internal/integration/payment" "kra/internal/integration/storage" @@ -36,5 +38,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(mq.SubscriptionRegistrar), new(*mqintegration.Reloadable)), websocketintegration.New, wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)), + liveintegration.ProviderSet, + wire.Bind(new(bizgift.Notifier), new(*liveintegration.Manager)), wire.Bind(new(system.FileStorage), new(*storage.Reloadable)), ) diff --git a/internal/modules/catalog.go b/internal/modules/catalog.go index 73681da..8142b7b 100644 --- a/internal/modules/catalog.go +++ b/internal/modules/catalog.go @@ -5,6 +5,7 @@ package modules import ( integrationmodule "kra/internal/modules/integration" + livemodule "kra/internal/modules/live" paymentmodule "kra/internal/modules/payment" systemmodule "kra/internal/modules/system" taskmodule "kra/internal/modules/task" @@ -20,5 +21,6 @@ func Catalog() module.Catalog { integrationmodule.Definition(), taskmodule.Definition(), paymentmodule.Definition(), + livemodule.Definition(), }} } diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 913aabc..f8be8d2 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -16,25 +16,25 @@ func TestCatalogMigrationIDsAreUnique(t *testing.T) { func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { catalog := Catalog() - if len(catalog.Definitions) != 4 { - t.Fatalf("definitions = %d, want 4", len(catalog.Definitions)) + if len(catalog.Definitions) != 5 { + t.Fatalf("definitions = %d, want 5", len(catalog.Definitions)) } - want := []string{"system", "integration", "task", "payment"} + want := []string{"system", "integration", "task", "payment", "live"} for index, name := range want { if got := catalog.Definitions[index].Name; got != name { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 13 { - t.Fatalf("module migrations = %d, want 13", len(got)) + if got := catalog.MigrationSteps(); len(got) != 22 { + t.Fatalf("module migrations = %d, want 22", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 3 || apis != 17 { - t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis) + if menus != 11 || apis != 50 { + t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/modules/live/definition.go b/internal/modules/live/definition.go new file mode 100644 index 0000000..33e3665 --- /dev/null +++ b/internal/modules/live/definition.go @@ -0,0 +1,51 @@ +package live + +import ( + "gorm.io/gorm" + dataasset "kra/internal/data/asset" + datagift "kra/internal/data/gift" + datalive "kra/internal/data/live" + datalivechat "kra/internal/data/livechat" + datasetting "kra/internal/data/platformsetting" + datarecharge "kra/internal/data/recharge" + datasystem "kra/internal/data/system" + "kra/pkg/database/migration" + "kra/pkg/module" +) + +func Definition() module.Definition { + root := module.Menu{Name: "liveOperations", Path: "liveOperations", Component: "view/routerHolder.vue", Title: "宇宙直播", Icon: "video-camera", Sort: 2} + menus := []module.Menu{root, {Name: "liveRooms", Path: "liveRooms", ParentName: root.Name, Component: "modules/live/view/rooms.vue", Title: "直播间", Icon: "video-camera", Sort: 1}, {Name: "liveGifts", Path: "liveGifts", ParentName: root.Name, Component: "modules/live/view/gifts.vue", Title: "礼物管理", Icon: "present", Sort: 2}, {Name: "liveCategories", Path: "liveCategories", ParentName: root.Name, Component: "modules/live/view/categories.vue", Title: "分类管理", Icon: "price-tag", Sort: 3}, {Name: "liveOrders", Path: "liveOrders", ParentName: root.Name, Component: "modules/live/view/orders.vue", Title: "订单明细", Icon: "document", Sort: 4}, {Name: "liveSends", Path: "liveSends", ParentName: root.Name, Component: "modules/live/view/sends.vue", Title: "刷礼物明细", Icon: "promotion", Sort: 5}, {Name: "liveUsers", Path: "liveUsers", ParentName: root.Name, Component: "modules/live/view/users.vue", Title: "用户资产", Icon: "user", Sort: 6}, {Name: "liveSettings", Path: "liveSettings", ParentName: root.Name, Component: "modules/live/view/settings.vue", Title: "全站设置", Icon: "setting", Sort: 7}} + apis := APIs() + steps := append([]migration.Step{}, datalive.Migrations()...) + steps = append(steps, datalivechat.Migrations()...) + steps = append(steps, datagift.Migrations()...) + steps = append(steps, datarecharge.Migrations()...) + steps = append(steps, dataasset.Migrations()...) + steps = append(steps, datasetting.Migrations()...) + steps = append(steps, migration.Step{ID: "202609190006_live_admin_surface", Migrate: func(db *gorm.DB) error { + var menuCount int64 + if err := db.Table("sys_base_menus").Count(&menuCount).Error; err != nil { + return err + } + if menuCount == 0 { + return nil + } + if err := datasystem.EnsureAdminSurface(db, module.Surface{Menus: []module.Menu{root}}, 888); err != nil { + return err + } + return datasystem.EnsureAdminSurface(db, module.Surface{Menus: menus[1:], APIs: apis}, 888) + }}) + steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error { + return datasystem.RemoveAdminMenus(db, "liveDashboard") + }}) + return module.Definition{Name: "live", Migrations: steps, Surface: module.Surface{Menus: menus, APIs: apis}} +} +func APIs() []module.API { + defs := []struct{ m, p, d string }{{"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}} + out := make([]module.API, 0, len(defs)) + for _, v := range defs { + out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d}) + } + return out +} diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 2f8215e..9c290d7 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -84,6 +84,51 @@ var routes = map[string]routeValue{ "GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"}, "GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"}, "GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"}, + "GET /api/streams": {group: "直播", description: "获取在线直播列表", public: true}, + "GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true}, + "GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true}, + "GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true}, + "GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true}, + "GET /live/rooms/:id": {group: "宇宙直播", description: "获取直播间详情", public: true}, + "GET /live/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true}, + "GET /live/gifts": {group: "宇宙直播", description: "获取礼物", public: true}, + "GET /live/recharge/plans": {group: "宇宙直播", description: "获取充值档位", public: true}, + "GET /live/settings": {group: "宇宙直播", description: "获取直播设置", public: true}, + "GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"}, + "GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"}, + "GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"}, + "GET /live/admin/rooms": {group: "宇宙直播", description: "查询直播间"}, + "GET /live/admin/rooms/:id": {group: "宇宙直播", description: "查询直播间详情"}, + "GET /live/admin/gifts": {group: "宇宙直播", description: "查询礼物"}, + "GET /live/admin/gifts/:id": {group: "宇宙直播", description: "查询礼物详情"}, + "GET /live/admin/sends": {group: "宇宙直播", description: "查询打赏流水"}, + "GET /live/admin/orders": {group: "宇宙直播", description: "查询充值订单"}, + "GET /live/admin/orders/:orderNo": {group: "宇宙直播", description: "查询充值订单详情"}, + "GET /live/admin/users": {group: "宇宙直播", description: "查询用户资产"}, + "GET /live/admin/users/:id": {group: "宇宙直播", description: "查询用户详情"}, + "POST /live/admin/categories": {group: "宇宙直播", description: "新建直播分类", audit: true}, + "POST /live/admin/rooms": {group: "宇宙直播", description: "新建直播间", audit: true}, + "POST /live/admin/gifts": {group: "宇宙直播", description: "新建礼物", audit: true}, + "POST /live/admin/users/:id/balance": {group: "宇宙直播", description: "调整钻石余额", audit: true}, + "POST /live/rooms/:id/gifts": {group: "宇宙直播", description: "赠送礼物", audit: true}, + "POST /live/recharge/orders": {group: "宇宙直播", description: "创建充值订单", audit: true}, + "POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true}, + "POST /live/client/auth/register": {group: "宇宙直播", description: "直播用户注册", public: true}, + "PUT /live/admin/categories/:id": {group: "宇宙直播", description: "更新直播分类", audit: true}, + "PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true}, + "PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true}, + "PUT /live/admin/gifts/:id": {group: "宇宙直播", description: "更新礼物", audit: true}, + "PUT /live/admin/gifts/sort": {group: "宇宙直播", description: "保存礼物排序", audit: true}, + "PUT /live/admin/recharge/plans": {group: "宇宙直播", description: "保存充值档位", audit: true}, + "PUT /live/admin/settings": {group: "宇宙直播", description: "保存直播设置", audit: true}, + "PUT /live/client/me": {group: "宇宙直播", description: "更新直播用户资料", audit: true}, + "PUT /live/client/me/password": {group: "宇宙直播", description: "修改直播用户密码", audit: true}, + "PATCH /live/admin/rooms/:id/status": {group: "宇宙直播", description: "启停直播间", audit: true}, + "PATCH /live/admin/gifts/:id/status": {group: "宇宙直播", description: "上下架礼物", audit: true}, + "PATCH /live/admin/users/:id/status": {group: "宇宙直播", description: "启停直播用户", audit: true}, + "DELETE /live/admin/categories/:id": {group: "宇宙直播", description: "删除直播分类", audit: true}, + "DELETE /live/admin/rooms/:id": {group: "宇宙直播", description: "删除直播间", audit: true}, + "DELETE /live/admin/gifts/:id": {group: "宇宙直播", description: "删除礼物", audit: true}, "GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"}, "GET /payment/orders": {group: "支付", description: "分页查询支付订单"}, "GET /payment/orders/summary": {group: "支付", description: "汇总支付订单运营指标"}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 385b411..47535fc 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -97,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) } - if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, fmt.Sprintf(`"route_count":%d`, len(engine.Routes()))) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/analytics.go b/internal/server/handler/analytics.go new file mode 100644 index 0000000..82b483d --- /dev/null +++ b/internal/server/handler/analytics.go @@ -0,0 +1,20 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + service "kra/internal/service/analytics" + "strconv" +) + +type Analytics struct{ s *service.Service } + +func NewAnalytics(s *service.Service) *Analytics { return &Analytics{s: s} } +func (h *Analytics) Dashboard(c *gin.Context) { + days, _ := strconv.Atoi(c.DefaultQuery("days", "1")) + v, e := h.s.Get(c.Request.Context(), days) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} diff --git a/internal/server/handler/asset.go b/internal/server/handler/asset.go new file mode 100644 index 0000000..eaa8e19 --- /dev/null +++ b/internal/server/handler/asset.go @@ -0,0 +1,76 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + service "kra/internal/service/asset" + "kra/internal/service/dto" +) + +type Asset struct{ s *service.Service } + +func NewAsset(s *service.Service) *Asset { return &Asset{s: s} } +func (h *Asset) List(c *gin.Context) { + in, ok := bindQuery[dto.AssetUserListRequest](c) + if !ok { + return + } + v, total, e := h.s.List(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v, "total": total}) +} +func (h *Asset) Get(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + v, e := h.s.Get(c.Request.Context(), id) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Asset) Status(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.LiveStatusRequest](c) + if !ok { + return + } + claims := Claims(c) + actor := uint(0) + if claims != nil { + actor = claims.ID + } + if e := h.s.SetEnabled(c.Request.Context(), actor, id, in.Enabled); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Asset) Adjust(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.AssetAdjustmentRequest](c) + if !ok { + return + } + claims := Claims(c) + actor := uint(0) + if claims != nil { + actor = claims.ID + } + v, e := h.s.Adjust(c.Request.Context(), actor, id, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} diff --git a/internal/server/handler/business_helpers.go b/internal/server/handler/business_helpers.go new file mode 100644 index 0000000..c23cd31 --- /dev/null +++ b/internal/server/handler/business_helpers.go @@ -0,0 +1,35 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "net/http" + "strconv" +) + +func businessID(c *gin.Context) (uint, bool) { + v, e := strconv.ParseUint(c.Param("id"), 10, 64) + if e != nil || v == 0 { + c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "参数无效"}) + return 0, false + } + return uint(v), true +} +func bindQuery[T any](c *gin.Context) (*T, bool) { + var v T + if e := c.ShouldBindQuery(&v); e != nil { + c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "查询条件无效"}) + return nil, false + } + return &v, true +} +func bindJSON[T any](c *gin.Context) (*T, bool) { + var v T + if e := c.ShouldBindJSON(&v); e != nil { + c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: "提交内容无效"}) + return nil, false + } + return &v, true +} +func businessFail(c *gin.Context, e error) { + c.JSON(http.StatusBadRequest, Response{Code: CodeError, Msg: e.Error()}) +} diff --git a/internal/server/handler/gift.go b/internal/server/handler/gift.go new file mode 100644 index 0000000..dcfe5d9 --- /dev/null +++ b/internal/server/handler/gift.go @@ -0,0 +1,143 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "kra/internal/service/dto" + service "kra/internal/service/gift" +) + +type Gift struct{ s *service.Service } + +func NewGift(s *service.Service) *Gift { return &Gift{s: s} } +func (h *Gift) List(c *gin.Context) { + in, ok := bindQuery[dto.GiftListRequest](c) + if !ok { + return + } + v, total, e := h.s.List(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v, "total": total}) +} +func (h *Gift) PublicList(c *gin.Context) { + enabled := true + in := &dto.GiftListRequest{LiveListRequest: dto.LiveListRequest{Page: 1, PageSize: 100, OrderBy: "sort_asc"}, Enabled: &enabled} + v, _, e := h.s.List(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v}) +} +func (h *Gift) Get(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + v, e := h.s.Get(c.Request.Context(), id) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Gift) Create(c *gin.Context) { + in, ok := bindJSON[dto.GiftRequest](c) + if !ok { + return + } + v, e := h.s.Save(c.Request.Context(), 0, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Gift) Update(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.GiftRequest](c) + if !ok { + return + } + v, e := h.s.Save(c.Request.Context(), id, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Gift) Delete(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + if e := h.s.Delete(c.Request.Context(), id); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Gift) Status(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.LiveStatusRequest](c) + if !ok { + return + } + if e := h.s.SetEnabled(c.Request.Context(), id, in.Enabled); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Gift) Sort(c *gin.Context) { + in, ok := bindJSON[dto.LiveSortRequest](c) + if !ok { + return + } + if e := h.s.SaveSort(c.Request.Context(), in.Values); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Gift) Sends(c *gin.Context) { + in, ok := bindQuery[dto.GiftSendListRequest](c) + if !ok { + return + } + v, total, sum, e := h.s.ListSends(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v, "total": total, "summary": sum}) +} +func (h *Gift) Send(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.GiftSendRequest](c) + if !ok { + return + } + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + v, e := h.s.Send(c.Request.Context(), claims.ID, id, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} diff --git a/internal/server/handler/live.go b/internal/server/handler/live.go new file mode 100644 index 0000000..301411e --- /dev/null +++ b/internal/server/handler/live.go @@ -0,0 +1,226 @@ +package handler + +import ( + "errors" + "net/http" + + bizlive "kra/internal/biz/live" + "kra/internal/service/dto" + liveservice "kra/internal/service/live" + + "github.com/gin-gonic/gin" +) + +type Live struct { + service *liveservice.StreamService + catalog *liveservice.CatalogService +} + +func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live { + return &Live{service: service, catalog: catalog} +} + +func (h *Live) List(c *gin.Context) { + items, err := h.service.List(c.Request.Context()) + if err != nil { + Fail(c, "直播列表获取失败") + return + } + Write(c, CodeSuccess, gin.H{"streams": items}, "获取成功") +} + +func (h *Live) ListCategories(c *gin.Context) { + in, ok := bindQuery[dto.LiveCategoryListRequest](c) + if !ok { + return + } + items, total, e := h.catalog.ListCategories(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": items, "total": total}) +} +func (h *Live) PublicCategories(c *gin.Context) { + enabled := true + in := &dto.LiveCategoryListRequest{LiveListRequest: dto.LiveListRequest{Page: 1, PageSize: 100, OrderBy: "sort_asc"}, Enabled: &enabled} + items, _, e := h.catalog.ListCategories(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": items}) +} +func (h *Live) CreateCategory(c *gin.Context) { + in, ok := bindJSON[dto.LiveCategoryRequest](c) + if !ok { + return + } + v, e := h.catalog.CreateCategory(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Live) UpdateCategory(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.LiveCategoryRequest](c) + if !ok { + return + } + v, e := h.catalog.UpdateCategory(c.Request.Context(), id, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Live) DeleteCategory(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + if e := h.catalog.DeleteCategory(c.Request.Context(), id); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Live) ListRooms(c *gin.Context) { + in, ok := bindQuery[dto.LiveRoomListRequest](c) + if !ok { + return + } + items, total, e := h.catalog.ListRooms(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": items, "total": total}) +} +func (h *Live) PublicRooms(c *gin.Context) { + in, ok := bindQuery[dto.LiveRoomListRequest](c) + if !ok { + return + } + if in.Page < 1 { + in.Page = 1 + } + if in.PageSize < 1 { + in.PageSize = 20 + } + if in.OrderBy == "" { + in.OrderBy = "sort_asc" + } + enabled := true + in.Enabled = &enabled + items, total, e := h.catalog.ListRooms(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize}) +} +func (h *Live) GetRoom(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + v, e := h.catalog.GetRoom(c.Request.Context(), id) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Live) CreateRoom(c *gin.Context) { + in, ok := bindJSON[dto.LiveRoomRequest](c) + if !ok { + return + } + v, e := h.catalog.CreateRoom(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Live) UpdateRoom(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.LiveRoomRequest](c) + if !ok { + return + } + v, e := h.catalog.UpdateRoom(c.Request.Context(), id, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Live) DeleteRoom(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + if e := h.catalog.DeleteRoom(c.Request.Context(), id); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Live) SetRoomStatus(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + in, ok := bindJSON[dto.LiveStatusRequest](c) + if !ok { + return + } + if e := h.catalog.SetRoomEnabled(c.Request.Context(), id, in.Enabled); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Live) SaveRoomSort(c *gin.Context) { + in, ok := bindJSON[dto.LiveSortRequest](c) + if !ok { + return + } + if e := h.catalog.SaveRoomSort(c.Request.Context(), in.Values); e != nil { + businessFail(c, e) + return + } + OK(c) +} + +func (h *Live) LegacyList(c *gin.Context) { + items, err := h.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "直播列表获取失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "streams": items}) +} + +func (h *Live) Find(c *gin.Context) { + item, err := h.service.Get(c.Request.Context(), c.Param("streamId")) + if err != nil { + if errors.Is(err, bizlive.ErrInvalidStreamID) { + c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "data": nil, "msg": "streamId 无效"}) + return + } + Fail(c, "直播间获取失败") + return + } + Write(c, CodeSuccess, gin.H{"stream": item}, "获取成功") +} diff --git a/internal/server/handler/live_chat.go b/internal/server/handler/live_chat.go new file mode 100644 index 0000000..5a5a7e8 --- /dev/null +++ b/internal/server/handler/live_chat.go @@ -0,0 +1,28 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "kra/internal/service/dto" + service "kra/internal/service/livechat" +) + +type LiveChat struct{ service *service.Service } + +func NewLiveChat(service *service.Service) *LiveChat { return &LiveChat{service: service} } + +func (h *LiveChat) History(c *gin.Context) { + roomID, ok := businessID(c) + if !ok { + return + } + in, ok := bindQuery[dto.LiveChatHistoryRequest](c) + if !ok { + return + } + items, err := h.service.History(c.Request.Context(), roomID, in.Limit) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, gin.H{"list": items}) +} diff --git a/internal/server/handler/live_client.go b/internal/server/handler/live_client.go new file mode 100644 index 0000000..49b8554 --- /dev/null +++ b/internal/server/handler/live_client.go @@ -0,0 +1,120 @@ +package handler + +import ( + "errors" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "kra/internal/biz/system" + "kra/internal/service/dto" + service "kra/internal/service/liveclient" +) + +type LiveClient struct{ service *service.Service } + +func NewLiveClient(service *service.Service) *LiveClient { return &LiveClient{service: service} } + +func (h *LiveClient) Register(c *gin.Context) { + in, ok := bindJSON[dto.LiveClientRegisterRequest](c) + if !ok { + return + } + result, err := h.service.Register(c.Request.Context(), in, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + clientAuthFail(c, err) + return + } + setClientToken(c, result) + Write(c, CodeSuccess, result, "注册成功") +} + +func (h *LiveClient) Login(c *gin.Context) { + in, ok := bindJSON[dto.LiveClientLoginRequest](c) + if !ok { + return + } + result, err := h.service.Login(c.Request.Context(), in, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + clientAuthFail(c, err) + return + } + setClientToken(c, result) + Write(c, CodeSuccess, result, "登录成功") +} + +func (h *LiveClient) Me(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + result, err := h.service.Me(c.Request.Context(), claims.ID) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, result) +} + +func (h *LiveClient) UpdateProfile(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + in, ok := bindJSON[dto.LiveClientProfileRequest](c) + if !ok { + return + } + result, err := h.service.UpdateProfile(c.Request.Context(), claims.ID, in) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, result) +} + +func (h *LiveClient) ChangePassword(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + in, ok := bindJSON[dto.LiveClientPasswordRequest](c) + if !ok { + return + } + result, err := h.service.ChangePassword(c.Request.Context(), claims.ID, in, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + clientAuthFail(c, err) + return + } + setClientToken(c, result) + Write(c, CodeSuccess, result, "密码修改成功") +} + +func setClientToken(c *gin.Context, result *dto.LiveClientAuthResponse) { + maxAge := int(time.UnixMilli(result.ExpiresAt).Unix() - time.Now().Unix()) + SetTokenCookie(c, result.Token, maxAge) +} + +func clientAuthFail(c *gin.Context, err error) { + switch { + case errors.Is(err, system.ErrAccountLocked): + var locked *system.AccountLockedError + errors.As(err, &locked) + minutes := 0 + if locked != nil { + minutes = locked.Minutes + } + Fail(c, "账号已锁定,请 "+strconv.Itoa(minutes)+" 分钟后再试") + case errors.Is(err, system.ErrInvalidCredentials): + Fail(c, "用户名不存在或者密码错误") + case errors.Is(err, system.ErrUserDisabled): + Fail(c, "用户被禁止登录") + default: + businessFail(c, err) + } +} diff --git a/internal/server/handler/platform_setting.go b/internal/server/handler/platform_setting.go new file mode 100644 index 0000000..ae220c1 --- /dev/null +++ b/internal/server/handler/platform_setting.go @@ -0,0 +1,30 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "kra/internal/service/dto" + service "kra/internal/service/platformsetting" +) + +type PlatformSetting struct{ s *service.Service } + +func NewPlatformSetting(s *service.Service) *PlatformSetting { return &PlatformSetting{s: s} } +func (h *PlatformSetting) Get(c *gin.Context) { + v, e := h.s.Get(c.Request.Context()) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *PlatformSetting) Save(c *gin.Context) { + in, ok := bindJSON[dto.PlatformSetting](c) + if !ok { + return + } + if e := h.s.Save(c.Request.Context(), in); e != nil { + businessFail(c, e) + return + } + OK(c) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index c00712d..c4c975a 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -9,6 +9,7 @@ var ProviderSet = wire.NewSet( NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit, NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken, NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession, - NewIntegrationConfig, + NewIntegrationConfig, NewLive, NewLiveChat, NewLiveClient, + NewGift, NewRecharge, NewAsset, NewPlatformSetting, NewAnalytics, wire.Struct(new(Set), "*"), ) diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 05d6937..ea7f2ac 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -138,6 +138,7 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc { Write(c, CodeSuccess, gin.H{}, "自动创建数据库成功") return } + slog.ErrorContext(c.Request.Context(), "数据库初始化失败", "mod", "system", "driver", input.DBType, "host", input.Host, "port", input.Port, "database", input.DBName, "error", err) Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化") return } diff --git a/internal/server/handler/recharge.go b/internal/server/handler/recharge.go new file mode 100644 index 0000000..d90d267 --- /dev/null +++ b/internal/server/handler/recharge.go @@ -0,0 +1,70 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "kra/internal/service/dto" + service "kra/internal/service/recharge" +) + +type Recharge struct{ s *service.Service } + +func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} } +func (h *Recharge) Plans(c *gin.Context) { + v, e := h.s.Plans(c.Request.Context()) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v}) +} +func (h *Recharge) SavePlans(c *gin.Context) { + in, ok := bindJSON[dto.RechargePlansRequest](c) + if !ok { + return + } + if e := h.s.SavePlans(c.Request.Context(), in); e != nil { + businessFail(c, e) + return + } + OK(c) +} +func (h *Recharge) List(c *gin.Context) { + in, ok := bindQuery[dto.RechargeOrderListRequest](c) + if !ok { + return + } + v, total, sum, e := h.s.List(c.Request.Context(), in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v, "total": total, "summary": sum}) +} +func (h *Recharge) Get(c *gin.Context) { + v, e := h.s.Get(c.Request.Context(), c.Param("orderNo")) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} +func (h *Recharge) Create(c *gin.Context) { + in, ok := bindJSON[dto.RechargeOrderRequest](c) + if !ok { + return + } + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + in.ClientIP = c.ClientIP() + in.UserAgent = c.Request.UserAgent() + in.DeviceID = c.GetHeader("X-Device-Id") + v, e := h.s.Create(c.Request.Context(), claims.ID, in) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index d892c7b..bb8317a 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -23,4 +23,12 @@ type Set struct { Navigation *Navigation Session *Session IntegrationConfig *IntegrationConfig + Live *Live + LiveChat *LiveChat + LiveClient *LiveClient + Gift *Gift + Recharge *Recharge + Asset *Asset + PlatformSetting *PlatformSetting + Analytics *Analytics } diff --git a/internal/server/router/analytics.go b/internal/server/router/analytics.go new file mode 100644 index 0000000..49432ff --- /dev/null +++ b/internal/server/router/analytics.go @@ -0,0 +1,10 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterAnalytics(private *gin.RouterGroup, h *handler.Analytics) { + private.GET("/live/admin/dashboard", h.Dashboard) +} diff --git a/internal/server/router/asset.go b/internal/server/router/asset.go new file mode 100644 index 0000000..f137581 --- /dev/null +++ b/internal/server/router/asset.go @@ -0,0 +1,13 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterAsset(private *gin.RouterGroup, h *handler.Asset) { + private.GET("/live/admin/users", h.List) + private.GET("/live/admin/users/:id", h.Get) + private.PATCH("/live/admin/users/:id/status", h.Status) + private.POST("/live/admin/users/:id/balance", h.Adjust) +} diff --git a/internal/server/router/gift.go b/internal/server/router/gift.go new file mode 100644 index 0000000..56b51bb --- /dev/null +++ b/internal/server/router/gift.go @@ -0,0 +1,19 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterGift(public, private *gin.RouterGroup, h *handler.Gift) { + public.GET("/live/gifts", h.PublicList) + private.GET("/live/admin/gifts", h.List) + private.GET("/live/admin/gifts/:id", h.Get) + private.POST("/live/admin/gifts", h.Create) + private.PUT("/live/admin/gifts/:id", h.Update) + private.DELETE("/live/admin/gifts/:id", h.Delete) + private.PATCH("/live/admin/gifts/:id/status", h.Status) + private.PUT("/live/admin/gifts/sort", h.Sort) + private.GET("/live/admin/sends", h.Sends) + private.POST("/live/rooms/:id/gifts", h.Send) +} diff --git a/internal/server/router/live.go b/internal/server/router/live.go new file mode 100644 index 0000000..61a4bd0 --- /dev/null +++ b/internal/server/router/live.go @@ -0,0 +1,27 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) { + public.GET("/live/streams", h.List) + public.GET("/live/streams/:streamId", h.Find) + // Keep the old list endpoint as a compatibility alias for existing clients. + public.GET("/api/streams", h.LegacyList) + public.GET("/live/categories", h.PublicCategories) + public.GET("/live/rooms", h.PublicRooms) + public.GET("/live/rooms/:id", h.GetRoom) + private.GET("/live/admin/categories", h.ListCategories) + private.POST("/live/admin/categories", h.CreateCategory) + private.PUT("/live/admin/categories/:id", h.UpdateCategory) + private.DELETE("/live/admin/categories/:id", h.DeleteCategory) + private.GET("/live/admin/rooms", h.ListRooms) + private.GET("/live/admin/rooms/:id", h.GetRoom) + private.POST("/live/admin/rooms", h.CreateRoom) + private.PUT("/live/admin/rooms/:id", h.UpdateRoom) + private.DELETE("/live/admin/rooms/:id", h.DeleteRoom) + private.PATCH("/live/admin/rooms/:id/status", h.SetRoomStatus) + private.PUT("/live/admin/rooms/sort", h.SaveRoomSort) +} diff --git a/internal/server/router/live_chat.go b/internal/server/router/live_chat.go new file mode 100644 index 0000000..517a86d --- /dev/null +++ b/internal/server/router/live_chat.go @@ -0,0 +1,10 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterLiveChat(public *gin.RouterGroup, h *handler.LiveChat) { + public.GET("/live/rooms/:id/messages", h.History) +} diff --git a/internal/server/router/live_client.go b/internal/server/router/live_client.go new file mode 100644 index 0000000..6dd2bfa --- /dev/null +++ b/internal/server/router/live_client.go @@ -0,0 +1,14 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) { + public.POST("/live/client/auth/login", h.Login) + public.POST("/live/client/auth/register", h.Register) + private.GET("/live/client/me", h.Me) + private.PUT("/live/client/me", h.UpdateProfile) + private.PUT("/live/client/me/password", h.ChangePassword) +} diff --git a/internal/server/router/platform_setting.go b/internal/server/router/platform_setting.go new file mode 100644 index 0000000..86f9fbc --- /dev/null +++ b/internal/server/router/platform_setting.go @@ -0,0 +1,11 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterPlatformSetting(public, private *gin.RouterGroup, h *handler.PlatformSetting) { + public.GET("/live/settings", h.Get) + private.PUT("/live/admin/settings", h.Save) +} diff --git a/internal/server/router/recharge.go b/internal/server/router/recharge.go new file mode 100644 index 0000000..403a7ff --- /dev/null +++ b/internal/server/router/recharge.go @@ -0,0 +1,14 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) { + public.GET("/live/recharge/plans", h.Plans) + private.PUT("/live/admin/recharge/plans", h.SavePlans) + private.GET("/live/admin/orders", h.List) + private.GET("/live/admin/orders/:orderNo", h.Get) + private.POST("/live/recharge/orders", h.Create) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index fa75454..3461c9f 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -42,6 +42,14 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En func() { RegisterEmail(private, r.handlers.Email) }, func() { RegisterIntegrationConfig(private, r.handlers.IntegrationConfig) }, func() { RegisterPayment(private, public, r.handlers.Payment) }, + func() { RegisterLive(public, private, r.handlers.Live) }, + func() { RegisterLiveChat(public, r.handlers.LiveChat) }, + func() { RegisterLiveClient(public, private, r.handlers.LiveClient) }, + func() { RegisterGift(public, private, r.handlers.Gift) }, + func() { RegisterRecharge(public, private, r.handlers.Recharge) }, + func() { RegisterAsset(private, r.handlers.Asset) }, + func() { RegisterPlatformSetting(public, private, r.handlers.PlatformSetting) }, + func() { RegisterAnalytics(private, r.handlers.Analytics) }, } for _, register := range registrations { register() diff --git a/internal/service/analytics/dashboard.go b/internal/service/analytics/dashboard.go new file mode 100644 index 0000000..e2b8bab --- /dev/null +++ b/internal/service/analytics/dashboard.go @@ -0,0 +1,36 @@ +package analytics + +import ( + "context" + biz "kra/internal/biz/analytics" + "kra/internal/service/dto" + "time" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } +func (s *Service) Get(ctx context.Context, days int) (*dto.DashboardResponse, error) { + if days != 0 && days != 1 && days != 7 && days != 30 { + days = 1 + } + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + from, to := today.AddDate(0, 0, -days+1), now + if days == 0 { + from = today.AddDate(0, 0, -1) + to = today.Add(-time.Nanosecond) + } + v, e := s.uc.Get(ctx, from, to) + if e != nil { + return nil, e + } + out := &dto.DashboardResponse{RoomTotal: v.RoomTotal, RoomLive: v.RoomLive, RechargeCents: v.RechargeCents, PaidOrderCount: v.PaidOrderCount, GiftDiamonds: v.GiftDiamonds, GiftSendCount: v.GiftSendCount, DiamondRateCents: v.DiamondRateCents} + for _, x := range v.RechargeTrend { + out.RechargeTrend = append(out.RechargeTrend, dto.DashboardTrend{Date: x.Date, AmountCents: x.AmountCents}) + } + for _, x := range v.GiftRanking { + out.GiftRanking = append(out.GiftRanking, dto.DashboardRank{RoomID: x.RoomID, RoomName: x.RoomName, AnchorName: x.AnchorName, Diamonds: x.Diamonds}) + } + return out, nil +} diff --git a/internal/service/analytics/provider.go b/internal/service/analytics/provider.go new file mode 100644 index 0000000..20c2e5e --- /dev/null +++ b/internal/service/analytics/provider.go @@ -0,0 +1,5 @@ +package analytics + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/asset/asset.go b/internal/service/asset/asset.go new file mode 100644 index 0000000..5d1f3bd --- /dev/null +++ b/internal/service/asset/asset.go @@ -0,0 +1,68 @@ +package asset + +import ( + "context" + biz "kra/internal/biz/asset" + "kra/internal/service/dto" + "time" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } +func dr(a, b string) []time.Time { + if a == "" || b == "" { + return nil + } + x, e1 := time.ParseInLocation("2006-01-02", a, time.Local) + y, e2 := time.ParseInLocation("2006-01-02", b, time.Local) + if e1 != nil || e2 != nil { + return nil + } + return []time.Time{x, y.Add(24*time.Hour - time.Nanosecond)} +} +func (s *Service) List(ctx context.Context, in *dto.AssetUserListRequest) ([]*dto.AssetUserResponse, int64, error) { + p, z := in.Page, in.PageSize + if p < 1 { + p = 1 + } + if z < 1 { + z = 10 + } + items, total, e := s.uc.List(ctx, biz.Filter{Keyword: in.Keyword, UserID: in.UserID, Enabled: in.Enabled, CreatedAt: dr(in.StartedAt, in.EndedAt)}, biz.ListOptions{Offset: (p - 1) * z, Limit: z, OrderBy: in.OrderBy}) + if e != nil { + return nil, 0, e + } + out := make([]*dto.AssetUserResponse, 0, len(items)) + for _, v := range items { + out = append(out, user(v)) + } + return out, total, nil +} +func (s *Service) Get(ctx context.Context, id uint) (*dto.AssetUserResponse, error) { + v, e := s.uc.Get(ctx, id) + if e != nil { + return nil, e + } + return user(v), nil +} +func (s *Service) SetEnabled(ctx context.Context, actor, id uint, enabled bool) error { + return s.uc.SetEnabled(ctx, actor, id, enabled) +} +func (s *Service) Adjust(ctx context.Context, actor, id uint, in *dto.AssetAdjustmentRequest) (*dto.AssetUserResponse, error) { + v, e := s.uc.Adjust(ctx, &biz.Adjustment{UserID: id, OperatorUserID: actor, Type: in.Type, Amount: in.Amount, Reason: in.Reason}) + if e != nil { + return nil, e + } + return user(v), nil +} +func user(v *biz.User) *dto.AssetUserResponse { + out := &dto.AssetUserResponse{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enabled, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt} + for _, x := range v.RecentOrders { + out.RecentOrders = append(out.RecentOrders, dto.AssetOrderRecord{OrderNo: x.OrderNo, Diamonds: x.Diamonds, AmountCents: x.AmountCents, Status: x.Status, CreatedAt: x.CreatedAt}) + } + for _, x := range v.RecentSends { + out.RecentSends = append(out.RecentSends, dto.AssetSendRecord{SerialNo: x.SerialNo, RoomName: x.RoomName, GiftName: x.GiftName, GiftIcon: x.GiftIcon, DiamondTotal: x.DiamondTotal, CreatedAt: x.CreatedAt}) + } + return out +} diff --git a/internal/service/asset/provider.go b/internal/service/asset/provider.go new file mode 100644 index 0000000..441e357 --- /dev/null +++ b/internal/service/asset/provider.go @@ -0,0 +1,5 @@ +package asset + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/dto/analytics.go b/internal/service/dto/analytics.go new file mode 100644 index 0000000..9ed1622 --- /dev/null +++ b/internal/service/dto/analytics.go @@ -0,0 +1,23 @@ +package dto + +type DashboardResponse struct { + RoomTotal int64 `json:"roomTotal"` + RoomLive int64 `json:"roomLive"` + RechargeCents int64 `json:"rechargeCents"` + PaidOrderCount int64 `json:"paidOrderCount"` + GiftDiamonds int64 `json:"giftDiamonds"` + GiftSendCount int64 `json:"giftSendCount"` + DiamondRateCents int64 `json:"diamondRateCents"` + RechargeTrend []DashboardTrend `json:"rechargeTrend"` + GiftRanking []DashboardRank `json:"giftRanking"` +} +type DashboardTrend struct { + Date string `json:"date"` + AmountCents int64 `json:"amountCents"` +} +type DashboardRank struct { + RoomID uint `json:"roomId"` + RoomName string `json:"roomName"` + AnchorName string `json:"anchorName"` + Diamonds int64 `json:"diamonds"` +} diff --git a/internal/service/dto/asset.go b/internal/service/dto/asset.go new file mode 100644 index 0000000..43ad086 --- /dev/null +++ b/internal/service/dto/asset.go @@ -0,0 +1,46 @@ +package dto + +import "time" + +type AssetUserListRequest struct { + LiveListRequest + Keyword string `form:"keyword"` + UserID uint `form:"userId"` + Enabled *bool `form:"enabled"` + StartedAt string `form:"startedAt"` + EndedAt string `form:"endedAt"` +} +type AssetAdjustmentRequest struct { + Type string `json:"type" binding:"required"` + Amount int64 `json:"amount" binding:"required"` + Reason string `json:"reason" binding:"required"` +} +type AssetOrderRecord struct { + OrderNo string `json:"orderNo"` + Diamonds int64 `json:"diamonds"` + AmountCents int64 `json:"amountCents"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} +type AssetSendRecord struct { + SerialNo string `json:"serialNo"` + RoomName string `json:"roomName"` + GiftName string `json:"giftName"` + GiftIcon string `json:"giftIcon"` + DiamondTotal int64 `json:"diamondTotal"` + CreatedAt time.Time `json:"createdAt"` +} +type AssetUserResponse struct { + UserID uint `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Enabled bool `json:"enabled"` + DiamondBalance int64 `json:"diamondBalance"` + TotalRechargeCents int64 `json:"totalRechargeCents"` + TotalSpentDiamonds int64 `json:"totalSpentDiamonds"` + CreatedAt time.Time `json:"createdAt"` + LastLoginAt *time.Time `json:"lastLoginAt"` + RecentOrders []AssetOrderRecord `json:"recentOrders,omitempty"` + RecentSends []AssetSendRecord `json:"recentSends,omitempty"` +} diff --git a/internal/service/dto/gift.go b/internal/service/dto/gift.go new file mode 100644 index 0000000..9ed12ea --- /dev/null +++ b/internal/service/dto/gift.go @@ -0,0 +1,71 @@ +package dto + +import "time" + +type GiftListRequest struct { + LiveListRequest + Name string `form:"name"` + MinPrice int64 `form:"minPrice"` + MaxPrice int64 `form:"maxPrice"` + Enabled *bool `form:"enabled"` +} +type GiftRequest struct { + Name string `json:"name" binding:"required"` + Icon string `json:"icon" binding:"required"` + Price int64 `json:"price" binding:"required"` + Description string `json:"description"` + Tiers []int `json:"tiers" binding:"required"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` +} +type GiftResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Price int64 `json:"price"` + Description string `json:"description"` + Tiers []int `json:"tiers"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` + SendCount int64 `json:"sendCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +type GiftSendListRequest struct { + LiveListRequest + RoomName string `form:"roomName"` + AnchorName string `form:"anchorName"` + UserKeyword string `form:"userKeyword"` + GiftID uint `form:"giftId"` + StartedAt string `form:"startedAt"` + EndedAt string `form:"endedAt"` +} +type GiftSendRequest struct { + GiftID uint `json:"giftId" binding:"required"` + Quantity int `json:"quantity" binding:"required"` +} +type GiftSendResponse struct { + ID uint `json:"id"` + SerialNo string `json:"serialNo"` + UserID uint `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + RoomID *uint `json:"roomId"` + RoomName string `json:"roomName"` + RoomDeleted bool `json:"roomDeleted"` + AnchorUserID uint `json:"anchorUserId"` + AnchorName string `json:"anchorName"` + GiftID *uint `json:"giftId"` + GiftName string `json:"giftName"` + GiftIcon string `json:"giftIcon"` + GiftPrice int64 `json:"giftPrice"` + Quantity int `json:"quantity"` + DiamondTotal int64 `json:"diamondTotal"` + EquivalentCents int64 `json:"equivalentCents"` + CreatedAt time.Time `json:"createdAt"` +} +type GiftSendSummary struct { + Count int64 `json:"count"` + Diamonds int64 `json:"diamonds"` + AmountCents int64 `json:"amountCents"` +} diff --git a/internal/service/dto/live.go b/internal/service/dto/live.go new file mode 100644 index 0000000..51ade77 --- /dev/null +++ b/internal/service/dto/live.go @@ -0,0 +1,82 @@ +package dto + +import "time" + +type LiveStreamResponse struct { + StreamID string `json:"streamId"` + PublisherOnline bool `json:"publisherOnline"` + SubscriberCount int `json:"subscriberCount"` +} +type LiveListRequest struct { + Page int `form:"page" json:"page"` + PageSize int `form:"pageSize" json:"pageSize"` + OrderBy string `form:"orderBy" json:"orderBy"` +} +type LiveCategoryListRequest struct { + LiveListRequest + Name string `form:"name"` + Enabled *bool `form:"enabled"` +} +type LiveCategoryRequest struct { + Name string `json:"name" binding:"required"` + Icon string `json:"icon"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` +} +type LiveCategoryResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` + RoomCount int64 `json:"roomCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +type LiveRoomListRequest struct { + LiveListRequest + Name string `form:"name"` + AnchorName string `form:"anchorName"` + CategoryID uint `form:"categoryId"` + Status string `form:"status"` + Enabled *bool `form:"enabled"` + StartedAt string `form:"startedAt"` + EndedAt string `form:"endedAt"` +} +type LiveRoomRequest struct { + Name string `json:"name" binding:"required"` + CategoryID uint `json:"categoryId" binding:"required"` + CoverURL string `json:"coverUrl" binding:"required"` + Intro string `json:"intro"` + Sort int `json:"sort"` + AnchorUserID uint `json:"anchorUserId" binding:"required"` + StreamURL string `json:"streamUrl"` + ReplayURL string `json:"replayUrl"` + Enabled bool `json:"enabled"` +} +type LiveRoomResponse struct { + ID uint `json:"id"` + StreamID string `json:"streamId"` + Name string `json:"name"` + CategoryID uint `json:"categoryId"` + CategoryName string `json:"categoryName"` + CoverURL string `json:"coverUrl"` + Intro string `json:"intro"` + Sort int `json:"sort"` + AnchorUserID uint `json:"anchorUserId"` + AnchorName string `json:"anchorName"` + AnchorAvatar string `json:"anchorAvatar"` + StreamURL string `json:"streamUrl"` + ReplayURL string `json:"replayUrl"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + ViewerCount int `json:"viewerCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} +type LiveStatusRequest struct { + Enabled bool `json:"enabled"` +} +type LiveSortRequest struct { + Values map[uint]int `json:"values" binding:"required"` +} diff --git a/internal/service/dto/live_chat.go b/internal/service/dto/live_chat.go new file mode 100644 index 0000000..1c1dd47 --- /dev/null +++ b/internal/service/dto/live_chat.go @@ -0,0 +1,24 @@ +package dto + +import "time" + +type LiveChatHistoryRequest struct { + Limit int `form:"limit"` +} + +type LiveChatMessageResponse struct { + ID uint `json:"id"` + RoomID uint `json:"roomId"` + Type string `json:"type"` + UserID uint `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Content string `json:"content,omitempty"` + GiftID uint `json:"giftId,omitempty"` + GiftName string `json:"giftName,omitempty"` + GiftIcon string `json:"giftIcon,omitempty"` + Quantity int `json:"quantity,omitempty"` + DiamondTotal int64 `json:"diamondTotal,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/service/dto/live_client.go b/internal/service/dto/live_client.go new file mode 100644 index 0000000..9bf32c4 --- /dev/null +++ b/internal/service/dto/live_client.go @@ -0,0 +1,41 @@ +package dto + +import "time" + +type LiveClientLoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type LiveClientRegisterRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + ConfirmPassword string `json:"confirmPassword" binding:"required"` +} + +type LiveClientProfileRequest struct { + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +type LiveClientPasswordRequest struct { + OldPassword string `json:"oldPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required"` + ConfirmPassword string `json:"confirmPassword" binding:"required"` +} + +type LiveClientAccountResponse struct { + UserID uint `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Enabled bool `json:"enabled"` + DiamondBalance int64 `json:"diamondBalance"` + CreatedAt time.Time `json:"createdAt"` +} + +type LiveClientAuthResponse struct { + User *LiveClientAccountResponse `json:"user"` + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` +} diff --git a/internal/service/dto/platform_setting.go b/internal/service/dto/platform_setting.go new file mode 100644 index 0000000..71124d7 --- /dev/null +++ b/internal/service/dto/platform_setting.go @@ -0,0 +1,5 @@ +package dto + +type PlatformSetting struct { + DiamondRateCents int64 `json:"diamondRateCents"` +} diff --git a/internal/service/dto/recharge.go b/internal/service/dto/recharge.go new file mode 100644 index 0000000..973ca69 --- /dev/null +++ b/internal/service/dto/recharge.go @@ -0,0 +1,58 @@ +package dto + +import "time" + +type RechargePlan struct { + ID uint `json:"id"` + Sort int `json:"sort"` + Diamonds int64 `json:"diamonds"` + AmountCents int64 `json:"amountCents"` + Enabled bool `json:"enabled"` +} +type RechargePlansRequest struct { + Plans []*RechargePlan `json:"plans" binding:"required"` +} +type RechargeOrderListRequest struct { + LiveListRequest + OrderNo string `form:"orderNo"` + UserKeyword string `form:"userKeyword"` + PaymentStatus string `form:"paymentStatus"` + Provider string `form:"provider"` + StartedAt string `form:"startedAt"` + EndedAt string `form:"endedAt"` +} +type RechargeOrderRequest struct { + PlanID uint `json:"planId" binding:"required"` + Provider string `json:"provider" binding:"required"` + NotifyURL string `json:"notifyUrl"` + ReturnURL string `json:"returnUrl"` + Extra map[string]any `json:"extra"` + ClientIP string `json:"-"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` +} +type RechargeOrderResponse struct { + ID uint `json:"id"` + OrderNo string `json:"orderNo"` + UserID uint `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Diamonds int64 `json:"diamonds"` + AmountCents int64 `json:"amountCents"` + Provider string `json:"provider"` + ProviderTradeNo string `json:"providerTradeNo"` + PaymentStatus string `json:"paymentStatus"` + FulfillmentStatus string `json:"fulfillmentStatus"` + RefundStatus string `json:"refundStatus"` + CreatedAt time.Time `json:"createdAt"` + CreditedAt *time.Time `json:"creditedAt"` +} +type RechargeCheckoutResponse struct { + Order *RechargeOrderResponse `json:"order"` + Payment *PaymentResultResponse `json:"payment"` +} +type RechargeSummary struct { + Count int64 `json:"count"` + PaidCount int64 `json:"paidCount"` + AmountCents int64 `json:"amountCents"` +} diff --git a/internal/service/gift/gift.go b/internal/service/gift/gift.go new file mode 100644 index 0000000..7df180a --- /dev/null +++ b/internal/service/gift/gift.go @@ -0,0 +1,87 @@ +package gift + +import ( + "context" + biz "kra/internal/biz/gift" + "kra/internal/service/dto" + "time" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } +func rangeOf(a, b string) []time.Time { + if a == "" || b == "" { + return nil + } + x, e1 := time.ParseInLocation("2006-01-02", a, time.Local) + y, e2 := time.ParseInLocation("2006-01-02", b, time.Local) + if e1 != nil || e2 != nil { + return nil + } + return []time.Time{x, y.Add(24*time.Hour - time.Nanosecond)} +} +func opts(v dto.LiveListRequest) biz.ListOptions { + p, s := v.Page, v.PageSize + if p < 1 { + p = 1 + } + if s < 1 { + s = 10 + } + return biz.ListOptions{Offset: (p - 1) * s, Limit: s, OrderBy: v.OrderBy} +} +func (s *Service) List(ctx context.Context, in *dto.GiftListRequest) ([]*dto.GiftResponse, int64, error) { + items, total, err := s.uc.List(ctx, biz.Filter{Name: in.Name, MinPrice: in.MinPrice, MaxPrice: in.MaxPrice, Enabled: in.Enabled}, opts(in.LiveListRequest)) + if err != nil { + return nil, 0, err + } + out := make([]*dto.GiftResponse, 0, len(items)) + for _, v := range items { + out = append(out, gift(v)) + } + return out, total, nil +} +func (s *Service) Get(ctx context.Context, id uint) (*dto.GiftResponse, error) { + v, e := s.uc.Get(ctx, id) + if e != nil { + return nil, e + } + return gift(v), nil +} +func (s *Service) Save(ctx context.Context, id uint, in *dto.GiftRequest) (*dto.GiftResponse, error) { + v, e := s.uc.Save(ctx, &biz.Gift{ID: id, Name: in.Name, Icon: in.Icon, Price: in.Price, Description: in.Description, Tiers: in.Tiers, Sort: in.Sort, Enabled: in.Enabled}) + if e != nil { + return nil, e + } + return gift(v), nil +} +func (s *Service) Delete(ctx context.Context, id uint) error { return s.uc.Delete(ctx, id) } +func (s *Service) SetEnabled(ctx context.Context, id uint, v bool) error { + return s.uc.SetEnabled(ctx, id, v) +} +func (s *Service) SaveSort(ctx context.Context, v map[uint]int) error { return s.uc.SaveSort(ctx, v) } +func (s *Service) ListSends(ctx context.Context, in *dto.GiftSendListRequest) ([]*dto.GiftSendResponse, int64, *dto.GiftSendSummary, error) { + items, total, sum, err := s.uc.ListSends(ctx, biz.SendFilter{RoomName: in.RoomName, AnchorName: in.AnchorName, UserKeyword: in.UserKeyword, GiftID: in.GiftID, CreatedAt: rangeOf(in.StartedAt, in.EndedAt)}, opts(in.LiveListRequest)) + if err != nil { + return nil, 0, nil, err + } + out := make([]*dto.GiftSendResponse, 0, len(items)) + for _, v := range items { + out = append(out, send(v)) + } + return out, total, &dto.GiftSendSummary{Count: sum.Count, Diamonds: sum.Diamonds, AmountCents: sum.AmountCents}, nil +} +func (s *Service) Send(ctx context.Context, userID, roomID uint, in *dto.GiftSendRequest) (*dto.GiftSendResponse, error) { + v, e := s.uc.Send(ctx, userID, roomID, in.GiftID, in.Quantity) + if e != nil { + return nil, e + } + return send(v), nil +} +func gift(v *biz.Gift) *dto.GiftResponse { + return &dto.GiftResponse{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: v.Tiers, Sort: v.Sort, Enabled: v.Enabled, SendCount: v.SendCount, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt} +} +func send(v *biz.Send) *dto.GiftSendResponse { + return &dto.GiftSendResponse{ID: v.ID, SerialNo: v.SerialNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, RoomID: v.RoomID, RoomName: v.RoomName, RoomDeleted: v.RoomDeleted, AnchorUserID: v.AnchorUserID, AnchorName: v.AnchorName, GiftID: v.GiftID, GiftName: v.GiftName, GiftIcon: v.GiftIcon, GiftPrice: v.GiftPrice, Quantity: v.Quantity, DiamondTotal: v.DiamondTotal, EquivalentCents: v.EquivalentCents, CreatedAt: v.CreatedAt} +} diff --git a/internal/service/gift/provider.go b/internal/service/gift/provider.go new file mode 100644 index 0000000..de27a08 --- /dev/null +++ b/internal/service/gift/provider.go @@ -0,0 +1,5 @@ +package gift + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/live/catalog.go b/internal/service/live/catalog.go new file mode 100644 index 0000000..7c83b0e --- /dev/null +++ b/internal/service/live/catalog.go @@ -0,0 +1,117 @@ +package live + +import ( + "context" + "fmt" + bizlive "kra/internal/biz/live" + "kra/internal/service/dto" + "time" +) + +type CatalogService struct { + categories *bizlive.CategoryUsecase + rooms *bizlive.RoomUsecase +} + +func NewCatalogService(categories *bizlive.CategoryUsecase, rooms *bizlive.RoomUsecase) *CatalogService { + return &CatalogService{categories: categories, rooms: rooms} +} +func liveListOptions(input dto.LiveListRequest) bizlive.ListOptions { + page, size := input.Page, input.PageSize + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + return bizlive.ListOptions{Offset: (page - 1) * size, Limit: size, OrderBy: input.OrderBy} +} +func liveDateRange(start, end string) []time.Time { + if start == "" || end == "" { + return nil + } + from, e1 := time.ParseInLocation("2006-01-02", start, time.Local) + to, e2 := time.ParseInLocation("2006-01-02", end, time.Local) + if e1 != nil || e2 != nil { + return nil + } + return []time.Time{from, to.Add(24*time.Hour - time.Nanosecond)} +} +func (s *CatalogService) ListCategories(ctx context.Context, input *dto.LiveCategoryListRequest) ([]*dto.LiveCategoryResponse, int64, error) { + items, total, err := s.categories.List(ctx, bizlive.CategoryFilter{Name: input.Name, Enabled: input.Enabled}, liveListOptions(input.LiveListRequest)) + if err != nil { + return nil, 0, err + } + result := make([]*dto.LiveCategoryResponse, 0, len(items)) + for _, item := range items { + result = append(result, categoryResponse(item)) + } + return result, total, nil +} +func (s *CatalogService) CreateCategory(ctx context.Context, input *dto.LiveCategoryRequest) (*dto.LiveCategoryResponse, error) { + item, err := s.categories.Create(ctx, &bizlive.Category{Name: input.Name, Icon: input.Icon, Sort: input.Sort, Enabled: input.Enabled}) + if err != nil { + return nil, err + } + return categoryResponse(item), nil +} +func (s *CatalogService) UpdateCategory(ctx context.Context, id uint, input *dto.LiveCategoryRequest) (*dto.LiveCategoryResponse, error) { + item, err := s.categories.Update(ctx, &bizlive.Category{ID: id, Name: input.Name, Icon: input.Icon, Sort: input.Sort, Enabled: input.Enabled}) + if err != nil { + return nil, err + } + return categoryResponse(item), nil +} +func (s *CatalogService) DeleteCategory(ctx context.Context, id uint) error { + return s.categories.Delete(ctx, id) +} +func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) { + items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, AnchorName: input.AnchorName, CategoryID: input.CategoryID, Status: input.Status, Enabled: input.Enabled, CreatedAt: liveDateRange(input.StartedAt, input.EndedAt)}, liveListOptions(input.LiveListRequest)) + if err != nil { + return nil, 0, err + } + result := make([]*dto.LiveRoomResponse, 0, len(items)) + for _, item := range items { + result = append(result, roomResponse(item)) + } + return result, total, nil +} +func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) { + item, err := s.rooms.Get(ctx, id) + if err != nil { + return nil, err + } + return roomResponse(item), nil +} +func liveRoomDO(id uint, input *dto.LiveRoomRequest) *bizlive.Room { + return &bizlive.Room{ID: id, Name: input.Name, CategoryID: input.CategoryID, CoverURL: input.CoverURL, Intro: input.Intro, Sort: input.Sort, AnchorUserID: input.AnchorUserID, StreamURL: input.StreamURL, ReplayURL: input.ReplayURL, Enabled: input.Enabled} +} +func (s *CatalogService) CreateRoom(ctx context.Context, input *dto.LiveRoomRequest) (*dto.LiveRoomResponse, error) { + item, err := s.rooms.Create(ctx, liveRoomDO(0, input)) + if err != nil { + return nil, err + } + return roomResponse(item), nil +} +func (s *CatalogService) UpdateRoom(ctx context.Context, id uint, input *dto.LiveRoomRequest) (*dto.LiveRoomResponse, error) { + item, err := s.rooms.Update(ctx, liveRoomDO(id, input)) + if err != nil { + return nil, err + } + return roomResponse(item), nil +} +func (s *CatalogService) DeleteRoom(ctx context.Context, id uint) error { + return s.rooms.Delete(ctx, id) +} +func (s *CatalogService) SetRoomEnabled(ctx context.Context, id uint, enabled bool) error { + return s.rooms.SetEnabled(ctx, id, enabled) +} +func (s *CatalogService) SaveRoomSort(ctx context.Context, values map[uint]int) error { + return s.rooms.SaveSort(ctx, values) +} +func categoryResponse(item *bizlive.Category) *dto.LiveCategoryResponse { + return &dto.LiveCategoryResponse{ID: item.ID, Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled, RoomCount: item.RoomCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt} +} +func roomResponse(item *bizlive.Room) *dto.LiveRoomResponse { + return &dto.LiveRoomResponse{ID: item.ID, StreamID: fmt.Sprintf("room-%d", item.ID), Name: item.Name, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, AnchorName: item.AnchorName, AnchorAvatar: item.AnchorAvatar, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled, Status: item.Status, ViewerCount: item.ViewerCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt} +} diff --git a/internal/service/live/provider.go b/internal/service/live/provider.go new file mode 100644 index 0000000..20c9537 --- /dev/null +++ b/internal/service/live/provider.go @@ -0,0 +1,5 @@ +package live + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService) diff --git a/internal/service/live/stream.go b/internal/service/live/stream.go new file mode 100644 index 0000000..f5707bd --- /dev/null +++ b/internal/service/live/stream.go @@ -0,0 +1,32 @@ +package live + +import ( + "context" + + bizlive "kra/internal/biz/live" + "kra/internal/service/dto" +) + +type StreamService struct{ uc *bizlive.StreamUsecase } + +func NewStreamService(uc *bizlive.StreamUsecase) *StreamService { return &StreamService{uc: uc} } + +func (s *StreamService) List(ctx context.Context) ([]*dto.LiveStreamResponse, error) { + streams, err := s.uc.List(ctx) + if err != nil { + return nil, err + } + result := make([]*dto.LiveStreamResponse, 0, len(streams)) + for _, stream := range streams { + result = append(result, &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}) + } + return result, nil +} + +func (s *StreamService) Get(ctx context.Context, streamID string) (*dto.LiveStreamResponse, error) { + stream, err := s.uc.Get(ctx, streamID) + if err != nil { + return nil, err + } + return &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}, nil +} diff --git a/internal/service/livechat/chat.go b/internal/service/livechat/chat.go new file mode 100644 index 0000000..ea450a8 --- /dev/null +++ b/internal/service/livechat/chat.go @@ -0,0 +1,27 @@ +package livechat + +import ( + "context" + biz "kra/internal/biz/livechat" + "kra/internal/service/dto" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } + +func (s *Service) History(ctx context.Context, roomID uint, limit int) ([]*dto.LiveChatMessageResponse, error) { + items, err := s.uc.ListRecent(ctx, roomID, limit) + if err != nil { + return nil, err + } + out := make([]*dto.LiveChatMessageResponse, 0, len(items)) + for _, item := range items { + out = append(out, MessageResponse(item)) + } + return out, nil +} + +func MessageResponse(item *biz.Message) *dto.LiveChatMessageResponse { + return &dto.LiveChatMessageResponse{ID: item.ID, RoomID: item.RoomID, Type: item.Type, UserID: item.UserID, Username: item.Username, Nickname: item.Nickname, Avatar: item.Avatar, Content: item.Content, GiftID: item.GiftID, GiftName: item.GiftName, GiftIcon: item.GiftIcon, Quantity: item.Quantity, DiamondTotal: item.DiamondTotal, CreatedAt: item.CreatedAt} +} diff --git a/internal/service/livechat/provider.go b/internal/service/livechat/provider.go new file mode 100644 index 0000000..ef47934 --- /dev/null +++ b/internal/service/livechat/provider.go @@ -0,0 +1,5 @@ +package livechat + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/liveclient/account.go b/internal/service/liveclient/account.go new file mode 100644 index 0000000..7b3b2e4 --- /dev/null +++ b/internal/service/liveclient/account.go @@ -0,0 +1,60 @@ +package liveclient + +import ( + "context" + + biz "kra/internal/biz/liveclient" + "kra/internal/service/dto" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } + +func (s *Service) Register(ctx context.Context, in *dto.LiveClientRegisterRequest, ip, agent string) (*dto.LiveClientAuthResponse, error) { + result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent) + if err != nil { + return nil, err + } + return authResponse(result), nil +} + +func (s *Service) Login(ctx context.Context, in *dto.LiveClientLoginRequest, ip, agent string) (*dto.LiveClientAuthResponse, error) { + result, err := s.uc.Login(ctx, &biz.LoginInput{Username: in.Username, Password: in.Password, IP: ip, Agent: agent}) + if err != nil { + return nil, err + } + return authResponse(result), nil +} + +func (s *Service) Me(ctx context.Context, userID uint) (*dto.LiveClientAccountResponse, error) { + account, err := s.uc.Get(ctx, userID) + if err != nil { + return nil, err + } + return accountResponse(account), nil +} + +func (s *Service) UpdateProfile(ctx context.Context, userID uint, in *dto.LiveClientProfileRequest) (*dto.LiveClientAccountResponse, error) { + account, err := s.uc.UpdateProfile(ctx, &biz.ProfileInput{UserID: userID, Nickname: in.Nickname, Avatar: in.Avatar}) + if err != nil { + return nil, err + } + return accountResponse(account), nil +} + +func (s *Service) ChangePassword(ctx context.Context, userID uint, in *dto.LiveClientPasswordRequest, ip, agent string) (*dto.LiveClientAuthResponse, error) { + result, err := s.uc.ChangePassword(ctx, &biz.PasswordInput{UserID: userID, OldPassword: in.OldPassword, NewPassword: in.NewPassword, ConfirmPassword: in.ConfirmPassword, IP: ip, Agent: agent}) + if err != nil { + return nil, err + } + return authResponse(result), nil +} + +func accountResponse(account *biz.Account) *dto.LiveClientAccountResponse { + return &dto.LiveClientAccountResponse{UserID: account.UserID, Username: account.Username, Nickname: account.Nickname, Avatar: account.Avatar, Enabled: account.Enabled, DiamondBalance: account.DiamondBalance, CreatedAt: account.CreatedAt} +} + +func authResponse(result *biz.AuthResult) *dto.LiveClientAuthResponse { + return &dto.LiveClientAuthResponse{User: accountResponse(result.Account), Token: result.Auth.Token, ExpiresAt: result.Auth.ExpiresAt.UnixMilli()} +} diff --git a/internal/service/liveclient/provider.go b/internal/service/liveclient/provider.go new file mode 100644 index 0000000..40833ec --- /dev/null +++ b/internal/service/liveclient/provider.go @@ -0,0 +1,5 @@ +package liveclient + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/platformsetting/provider.go b/internal/service/platformsetting/provider.go new file mode 100644 index 0000000..9d9d634 --- /dev/null +++ b/internal/service/platformsetting/provider.go @@ -0,0 +1,5 @@ +package platformsetting + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/platformsetting/setting.go b/internal/service/platformsetting/setting.go new file mode 100644 index 0000000..9c378a6 --- /dev/null +++ b/internal/service/platformsetting/setting.go @@ -0,0 +1,21 @@ +package platformsetting + +import ( + "context" + biz "kra/internal/biz/platformsetting" + "kra/internal/service/dto" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } +func (s *Service) Get(ctx context.Context) (*dto.PlatformSetting, error) { + v, e := s.uc.Get(ctx) + if e != nil { + return nil, e + } + return &dto.PlatformSetting{DiamondRateCents: v.DiamondRateCents}, nil +} +func (s *Service) Save(ctx context.Context, v *dto.PlatformSetting) error { + return s.uc.Save(ctx, &biz.Setting{DiamondRateCents: v.DiamondRateCents}) +} diff --git a/internal/service/recharge/provider.go b/internal/service/recharge/provider.go new file mode 100644 index 0000000..809a769 --- /dev/null +++ b/internal/service/recharge/provider.go @@ -0,0 +1,5 @@ +package recharge + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewService) diff --git a/internal/service/recharge/recharge.go b/internal/service/recharge/recharge.go new file mode 100644 index 0000000..23f5ca3 --- /dev/null +++ b/internal/service/recharge/recharge.go @@ -0,0 +1,110 @@ +package recharge + +import ( + "context" + "time" + + bizpayment "kra/internal/biz/payment" + biz "kra/internal/biz/recharge" + "kra/internal/service/dto" +) + +type Service struct{ uc *biz.Usecase } + +func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } + +func dates(a, b string) []time.Time { + if a == "" || b == "" { + return nil + } + x, e1 := time.ParseInLocation("2006-01-02", a, time.Local) + y, e2 := time.ParseInLocation("2006-01-02", b, time.Local) + if e1 != nil || e2 != nil { + return nil + } + return []time.Time{x, y.Add(24*time.Hour - time.Nanosecond)} +} + +func options(v dto.LiveListRequest) biz.ListOptions { + p, size := v.Page, v.PageSize + if p < 1 { + p = 1 + } + if size < 1 { + size = 10 + } + return biz.ListOptions{Offset: (p - 1) * size, Limit: size, OrderBy: v.OrderBy} +} + +func (s *Service) Plans(ctx context.Context) ([]*dto.RechargePlan, error) { + items, err := s.uc.Plans(ctx) + if err != nil { + return nil, err + } + out := make([]*dto.RechargePlan, 0, len(items)) + for _, v := range items { + out = append(out, &dto.RechargePlan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled}) + } + return out, nil +} + +func (s *Service) SavePlans(ctx context.Context, in *dto.RechargePlansRequest) error { + plans := make([]*biz.Plan, 0, len(in.Plans)) + for _, v := range in.Plans { + plans = append(plans, &biz.Plan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled}) + } + return s.uc.SavePlans(ctx, plans) +} + +func (s *Service) List(ctx context.Context, in *dto.RechargeOrderListRequest) ([]*dto.RechargeOrderResponse, int64, *dto.RechargeSummary, error) { + items, total, sum, err := s.uc.List(ctx, biz.Filter{OrderNo: in.OrderNo, UserKeyword: in.UserKeyword, PaymentStatus: in.PaymentStatus, Provider: in.Provider, CreatedAt: dates(in.StartedAt, in.EndedAt)}, options(in.LiveListRequest)) + if err != nil { + return nil, 0, nil, err + } + out := make([]*dto.RechargeOrderResponse, 0, len(items)) + for _, v := range items { + out = append(out, order(v)) + } + return out, total, &dto.RechargeSummary{Count: sum.Count, PaidCount: sum.PaidCount, AmountCents: sum.AmountCents}, nil +} + +func (s *Service) Get(ctx context.Context, no string) (*dto.RechargeOrderResponse, error) { + v, err := s.uc.Get(ctx, no) + if err != nil { + return nil, err + } + return order(v), nil +} + +func (s *Service) Create(ctx context.Context, userID uint, in *dto.RechargeOrderRequest) (*dto.RechargeCheckoutResponse, error) { + v, result, err := s.uc.Create(ctx, userID, in.PlanID, biz.Checkout{Provider: in.Provider, NotifyURL: in.NotifyURL, ReturnURL: in.ReturnURL, ClientIP: in.ClientIP, UserAgent: in.UserAgent, DeviceID: in.DeviceID, Extra: in.Extra}) + if err != nil { + return nil, err + } + return &dto.RechargeCheckoutResponse{Order: order(v), Payment: paymentResult(result)}, nil +} + +func order(v *biz.Order) *dto.RechargeOrderResponse { + return &dto.RechargeOrderResponse{ + ID: v.ID, OrderNo: v.OrderNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, + Diamonds: v.Diamonds, AmountCents: v.AmountCents, Provider: v.Provider, ProviderTradeNo: v.ProviderTradeNo, + PaymentStatus: v.PaymentStatus, FulfillmentStatus: v.FulfillmentStatus, RefundStatus: v.RefundStatus, + CreatedAt: v.CreatedAt, CreditedAt: v.CreditedAt, + } +} + +func paymentResult(result *bizpayment.PaymentResult) *dto.PaymentResultResponse { + if result == nil { + return nil + } + return &dto.PaymentResultResponse{ + Provider: result.Provider, Status: result.Status, TradeNo: result.TradeNo, + ProviderTradeNo: result.ProviderTradeNo, Amount: result.Amount, + PayerPaidAmount: result.PayerPaidAmount, CashPaidAmount: result.CashPaidAmount, + PointPaidAmount: result.PointPaidAmount, DiscountAmount: result.DiscountAmount, + ProviderDiscountAmount: result.ProviderDiscountAmount, MerchantDiscountAmount: result.MerchantDiscountAmount, + SettlementAmount: result.SettlementAmount, Currency: result.Currency, PayerCurrency: result.PayerCurrency, + AmountBreakdownKnown: result.AmountBreakdownKnown, Duplicate: result.Duplicate, Payload: result.Payload, + OrderStatus: result.OrderStatus, FulfillmentStatus: result.FulfillmentStatus, RefundStatus: result.RefundStatus, + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 6f1df60..f158ffa 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,8 +1,16 @@ package service import ( + analyticsservice "kra/internal/service/analytics" + assetservice "kra/internal/service/asset" + giftservice "kra/internal/service/gift" integrationservice "kra/internal/service/integration" + liveservice "kra/internal/service/live" + livechatservice "kra/internal/service/livechat" + liveclientservice "kra/internal/service/liveclient" paymentservice "kra/internal/service/payment" + platformsettingservice "kra/internal/service/platformsetting" + rechargeservice "kra/internal/service/recharge" systemservice "kra/internal/service/system" taskservice "kra/internal/service/task" @@ -14,5 +22,13 @@ var ProviderSet = wire.NewSet( systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, + liveservice.ProviderSet, + livechatservice.ProviderSet, + liveclientservice.ProviderSet, + giftservice.ProviderSet, + rechargeservice.ProviderSet, + assetservice.ProviderSet, + platformsettingservice.ProviderSet, + analyticsservice.ProviderSet, taskservice.ProviderSet, ) diff --git a/web/.env.production b/web/.env.production index 1dbca05..ad1d39a 100644 --- a/web/.env.production +++ b/web/.env.production @@ -4,5 +4,8 @@ ENV = 'production' VITE_BASE_API = /api VITE_FILE_API = /api +# 管理后台部署于 live.fuhua91.com/admin/ 路径下(宝塔 nginx 去 /admin 前缀转发到 frontend 容器) +VITE_APP_BASE = /admin/ + # 后端与前端端口不一致时配置,例如 VITE_SERVER_PORT = 30081。 # 也可以使用 VITE_WEBSOCKET_URL = http://192.168.200.128:30081 指定后端地址。 diff --git a/web/Dockerfile b/web/Dockerfile index 51179be..539a309 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,6 @@ -FROM docker.m.daocloud.io/nginx:alpine +FROM docker.m.daocloud.io/nginx:1.24-alpine +# 1.24-alpine 兼容 CentOS 7 (kernel 3.10) 老版本 docker 的 seccomp; +# 新版 nginx:alpine (1.31+/alpine 3.22) 会报 pwrite() "/run/nginx.pid" EPERM。 # nginx.k8s.conf is rendered by Jenkins from # .docker-compose/nginx/conf.d/my.conf (envsubst '$KRA_APP'). COPY nginx.k8s.conf /etc/nginx/conf.d/default.conf diff --git a/web/Dockerfile.build b/web/Dockerfile.build new file mode 100644 index 0000000..d6046cd --- /dev/null +++ b/web/Dockerfile.build @@ -0,0 +1,13 @@ +# 本地快速构建 dist 用(CI 上无需此文件)。用法: +# docker build -f web/Dockerfile.build --target export -o "type=local,dest=web/dist" web +FROM docker.m.daocloud.io/node:20-slim +ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com +ENV npm_config_registry=https://registry.npmmirror.com +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN corepack enable && corepack prepare pnpm@10.15.1 --activate && pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM scratch AS export +COPY --from=0 /app/dist /dist diff --git a/web/src/modules/live/api/live.js b/web/src/modules/live/api/live.js new file mode 100644 index 0000000..5ec23e3 --- /dev/null +++ b/web/src/modules/live/api/live.js @@ -0,0 +1,51 @@ +import service from '@/utils/request' + +const get = (url, params) => service({ url, method: 'get', params }) +const post = (url, data) => service({ url, method: 'post', data }) +const put = (url, data) => service({ url, method: 'put', data }) +const patch = (url, data) => service({ url, method: 'patch', data }) +const remove = (url) => service({ url, method: 'delete' }) + +export const getDashboard = (params) => get('/live/admin/dashboard', params) + +export const getCategories = (params) => get('/live/admin/categories', params) +export const getPublicCategories = () => get('/live/categories') +export const createCategory = (data) => post('/live/admin/categories', data) +export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`, data) +export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`) + +export const getRooms = (params) => get('/live/admin/rooms', params) +export const getPublicRooms = () => get('/live/rooms') +export const getRoom = (id) => get(`/live/rooms/${id}`) +export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params) +export const createRoom = (data) => post('/live/admin/rooms', data) +export const updateRoom = (id, data) => put(`/live/admin/rooms/${id}`, data) +export const deleteRoom = (id) => remove(`/live/admin/rooms/${id}`) +export const setRoomStatus = (id, enabled) => patch(`/live/admin/rooms/${id}/status`, { enabled }) +export const saveRoomSort = (values) => put('/live/admin/rooms/sort', { values }) + +export const getGifts = (params) => get('/live/admin/gifts', params) +export const getPublicGifts = () => get('/live/gifts') +export const createGift = (data) => post('/live/admin/gifts', data) +export const updateGift = (id, data) => put(`/live/admin/gifts/${id}`, data) +export const deleteGift = (id) => remove(`/live/admin/gifts/${id}`) +export const setGiftStatus = (id, enabled) => patch(`/live/admin/gifts/${id}/status`, { enabled }) +export const saveGiftSort = (values) => put('/live/admin/gifts/sort', { values }) +export const sendGift = (roomId, data) => post(`/live/rooms/${roomId}/gifts`, data) +export const getGiftSends = (params) => get('/live/admin/sends', params) + +export const getRechargePlans = () => get('/live/recharge/plans') +export const saveRechargePlans = (plans) => put('/live/admin/recharge/plans', { plans }) +export const getOrders = (params) => get('/live/admin/orders', params) +export const getOrder = (orderNo) => get(`/live/admin/orders/${orderNo}`) +export const createRechargeOrder = (data) => post('/live/recharge/orders', data) + +export const getAssetUsers = (params) => get('/live/admin/users', params) +export const getAssetUser = (id) => get(`/live/admin/users/${id}`) +export const setAssetUserStatus = (id, enabled) => patch(`/live/admin/users/${id}/status`, { enabled }) +export const adjustAssetBalance = (id, data) => post(`/live/admin/users/${id}/balance`, data) + +export const getLiveSettings = () => get('/live/settings') +export const saveLiveSettings = (data) => put('/live/admin/settings', data) + +export const getLiveStreams = () => get('/live/streams') diff --git a/web/src/modules/live/components/ImageUploader.vue b/web/src/modules/live/components/ImageUploader.vue new file mode 100644 index 0000000..f7a5435 --- /dev/null +++ b/web/src/modules/live/components/ImageUploader.vue @@ -0,0 +1,48 @@ + + + diff --git a/web/src/modules/live/components/LivePageHeader.vue b/web/src/modules/live/components/LivePageHeader.vue new file mode 100644 index 0000000..90ec672 --- /dev/null +++ b/web/src/modules/live/components/LivePageHeader.vue @@ -0,0 +1,14 @@ + + + diff --git a/web/src/modules/live/components/VideoUploader.vue b/web/src/modules/live/components/VideoUploader.vue new file mode 100644 index 0000000..760b7be --- /dev/null +++ b/web/src/modules/live/components/VideoUploader.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/modules/live/composables/useLiveRTC.js b/web/src/modules/live/composables/useLiveRTC.js new file mode 100644 index 0000000..2a8be80 --- /dev/null +++ b/web/src/modules/live/composables/useLiveRTC.js @@ -0,0 +1,310 @@ +import { onBeforeUnmount, reactive, ref, shallowRef } from 'vue' +import { useUserStore } from '@/pinia/modules/user' + +const websocketURL = (token) => { + const override = String(import.meta.env.VITE_WEBSOCKET_URL || '').trim() + const base = override || String(import.meta.env.VITE_BASE_API || '').trim() + let url + if (/^wss?:\/\//i.test(base)) url = new URL(base) + else if (/^https?:\/\//i.test(base)) url = new URL(base) + else url = new URL(base || '/', window.location.origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + if (!override || !/\/ws\/?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/$/, '')}/ws`.replace(/\/+/g, '/') + url.searchParams.set('token', token) + return url.toString() +} + +const waitForIce = (pc) => { + if (pc.iceGatheringState === 'complete') return Promise.resolve() + return new Promise((resolve) => { + const listener = () => { + if (pc.iceGatheringState !== 'complete') return + pc.removeEventListener('icegatheringstatechange', listener) + resolve() + } + pc.addEventListener('icegatheringstatechange', listener) + window.setTimeout(resolve, 5000) + }) +} + +export function useLiveRTC() { + const userStore = useUserStore() + const state = ref('idle') + const message = ref('') + const viewerCount = ref(0) + const remoteStream = shallowRef(null) + const chatMessages = ref([]) + const events = ref([]) + const diagnostics = reactive({ + websocket: 'closed', + peerConnection: 'new', + iceConnection: 'new', + signaling: 'stable', + publisherReady: false, + localTracks: 0, + remoteTracks: 0, + bytesSent: 0, + bytesReceived: 0, + packetsLost: 0, + roundTripTime: 0, + connectedAt: '', + lastHeartbeatAt: '', + lastError: '', + }) + let socket = null + let pc = null + let heartbeat = 0 + let statsTimer = 0 + let role = '' + let streamId = '' + let localStream = null + let connectResolve = null + let connectReject = null + + const record = (level, text, detail = '') => { + events.value = [...events.value.slice(-79), { at: new Date().toISOString(), level, text, detail }] + } + + const resetDiagnostics = () => { + Object.assign(diagnostics, { + websocket: 'closed', peerConnection: 'new', iceConnection: 'new', signaling: 'stable', publisherReady: false, + localTracks: 0, remoteTracks: 0, bytesSent: 0, bytesReceived: 0, packetsLost: 0, roundTripTime: 0, + connectedAt: '', lastHeartbeatAt: '', lastError: '', + }) + events.value = [] + } + + const send = (type, payload = {}) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type, payload })) + } + + const closePeer = () => { + window.clearInterval(statsTimer) + pc?.close() + pc = null + remoteStream.value = null + diagnostics.peerConnection = 'closed' + diagnostics.iceConnection = 'closed' + diagnostics.remoteTracks = 0 + } + + const collectStats = async () => { + if (!pc) return + const reports = await pc.getStats() + let sent = 0 + let received = 0 + let lost = 0 + let roundTrip = 0 + reports.forEach((report) => { + if (report.type === 'outbound-rtp' && !report.isRemote) sent += Number(report.bytesSent || 0) + if (report.type === 'inbound-rtp' && !report.isRemote) { + received += Number(report.bytesReceived || 0) + lost += Number(report.packetsLost || 0) + } + if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.nominated || report.selected)) { + roundTrip = Number(report.currentRoundTripTime || 0) + } + }) + diagnostics.bytesSent = sent + diagnostics.bytesReceived = received + diagnostics.packetsLost = lost + diagnostics.roundTripTime = roundTrip + } + + const startStats = () => { + window.clearInterval(statsTimer) + collectStats().catch(() => {}) + statsTimer = window.setInterval(() => collectStats().catch(() => {}), 1000) + } + + const negotiate = async () => { + closePeer() + const peer = new RTCPeerConnection() + pc = peer + diagnostics.localTracks = role === 'publisher' ? (localStream?.getTracks().length || 0) : 0 + diagnostics.peerConnection = peer.connectionState + diagnostics.iceConnection = peer.iceConnectionState + diagnostics.signaling = peer.signalingState + record('info', role === 'publisher' ? '创建主播 PeerConnection' : '创建观众 PeerConnection') + peer.onconnectionstatechange = () => { + if (pc !== peer) return + diagnostics.peerConnection = peer.connectionState + record(peer.connectionState === 'failed' ? 'error' : 'info', `PeerConnection: ${diagnostics.peerConnection}`) + if (peer.connectionState === 'connected') { + state.value = 'connected' + message.value = '' + diagnostics.connectedAt = new Date().toISOString() + startStats() + } + if (['failed', 'disconnected'].includes(peer.connectionState)) { + state.value = 'waiting' + message.value = '连接中断,正在等待恢复' + } + } + peer.oniceconnectionstatechange = () => { + if (pc !== peer) return + diagnostics.iceConnection = peer.iceConnectionState + record(diagnostics.iceConnection === 'failed' ? 'error' : 'info', `ICE: ${diagnostics.iceConnection}`) + } + peer.onsignalingstatechange = () => { if (pc === peer) diagnostics.signaling = peer.signalingState } + if (role === 'publisher') { + localStream?.getTracks().forEach((track) => peer.addTrack(track, localStream)) + } else { + const incoming = new MediaStream() + remoteStream.value = incoming + peer.ontrack = (event) => { + const source = event.streams?.[0] + if (source) source.getTracks().forEach((track) => { if (!incoming.getTrackById(track.id)) incoming.addTrack(track) }) + else incoming.addTrack(event.track) + diagnostics.remoteTracks = incoming.getTracks().length + record('success', `收到${event.track.kind === 'video' ? '视频' : '音频'}轨道`, event.track.id) + } + peer.addTransceiver('video', { direction: 'recvonly' }) + peer.addTransceiver('audio', { direction: 'recvonly' }) + } + const offer = await peer.createOffer() + await peer.setLocalDescription(offer) + await waitForIce(peer) + record('info', '本地 SDP 与 ICE 候选已就绪') + send('Offer', { type: peer.localDescription.type, sdp: peer.localDescription.sdp }) + } + + const handleMessage = async (event) => { + const packet = JSON.parse(event.data) + const payload = packet.payload || {} + switch (packet.type) { + case 'Joined': + record('success', 'WebSocket 加入直播间成功', `${role}:${streamId}`) + state.value = 'negotiating' + message.value = role === 'publisher' ? '正在建立推流连接' : '正在连接直播画面' + await negotiate() + connectResolve?.() + connectResolve = null + connectReject = null + break + case 'Answer': + if (pc) await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp }) + record('success', '服务端 SDP 应答已应用') + break + case 'PublisherStatus': + diagnostics.publisherReady = Boolean(payload.ready) + record(payload.ready ? 'success' : 'info', payload.ready ? '主播媒体轨道已就绪' : '主播媒体轨道未就绪', payload.reason || '') + if (role === 'subscriber' && !payload.ready) { state.value = 'waiting'; message.value = '主播暂未开播' } + break + case 'Renegotiate': + if (role === 'subscriber') { record('info', '主播轨道变化,重新协商拉流'); await negotiate() } + break + case 'NewSubscriber': + viewerCount.value += 1 + break + case 'SubscriberLeft': + viewerCount.value = Math.max(0, viewerCount.value - 1) + break + case 'ChatHistory': + chatMessages.value = (payload.messages || []).slice(-100) + break + case 'ChatMessage': + case 'GiftMessage': + chatMessages.value = [...chatMessages.value.slice(-99), payload] + break + case 'HeartbeatAck': + diagnostics.lastHeartbeatAt = new Date().toISOString() + break + case 'Kicked': + state.value = 'kicked' + message.value = '账号已在其他位置进入该直播间' + diagnostics.lastError = message.value + record('error', message.value) + disconnect() + break + case 'JoinRejected': + connectReject?.(new Error(payload.message || '直播连接被拒绝')) + connectResolve = null + connectReject = null + state.value = 'error' + message.value = payload.message || '直播连接被拒绝' + diagnostics.lastError = message.value + record('error', message.value, payload.code || '') + break + case 'Error': + state.value = 'error' + message.value = payload.message || '直播连接失败' + diagnostics.lastError = message.value + record('error', message.value, payload.code || '') + break + } + } + + const connect = ({ mode, id, media }) => new Promise((resolve, reject) => { + if (!userStore.token) { + state.value = 'auth-required' + message.value = '登录后才能进入实时直播' + reject(new Error(message.value)) + return + } + resetDiagnostics() + role = mode + streamId = id + localStream = media || null + state.value = 'connecting' + message.value = '正在连接直播服务' + connectResolve = resolve + connectReject = reject + socket = new WebSocket(websocketURL(userStore.token)) + diagnostics.websocket = 'connecting' + record('info', '正在连接直播 WebSocket') + socket.onopen = () => { + diagnostics.websocket = 'open' + record('success', '直播 WebSocket 已连接') + send('Join', { role, streamId }) + heartbeat = window.setInterval(() => send('Heartbeat', { streamId }), 15000) + } + socket.onmessage = (event) => handleMessage(event).catch((error) => { + state.value = 'error' + message.value = error.message + diagnostics.lastError = error.message + record('error', error.message) + }) + socket.onerror = () => { + diagnostics.websocket = 'error' + diagnostics.lastError = '无法连接直播服务' + record('error', diagnostics.lastError) + state.value = 'error' + message.value = diagnostics.lastError + reject(new Error(message.value)) + } + socket.onclose = (event) => { + diagnostics.websocket = 'closed' + record(event.wasClean ? 'info' : 'error', '直播 WebSocket 已关闭', `${event.code}`) + window.clearInterval(heartbeat) + connectReject?.(new Error('直播 WebSocket 在加入房间前关闭')) + connectResolve = null + connectReject = null + if (!['idle', 'kicked'].includes(state.value)) { + state.value = 'closed' + message.value = '直播连接已关闭' + } + } + }) + + const disconnect = () => { + window.clearInterval(heartbeat) + send('Leave', { streamId }) + socket?.close() + socket = null + connectResolve = null + connectReject = null + closePeer() + if (state.value !== 'kicked') state.value = 'idle' + } + + const sendChat = (content) => { + const value = String(content || '').trim() + if (!value || socket?.readyState !== WebSocket.OPEN) return false + send('ChatSend', { content: value }) + return true + } + + onBeforeUnmount(disconnect) + return { state, message, viewerCount, remoteStream, chatMessages, events, diagnostics, connect, disconnect, sendChat, collectStats } +} diff --git a/web/src/modules/live/styles/live.scss b/web/src/modules/live/styles/live.scss new file mode 100644 index 0000000..a526fba --- /dev/null +++ b/web/src/modules/live/styles/live.scss @@ -0,0 +1,231 @@ +.live-page { + min-width: 0; +} + +.live-page > .kra-search-box, +.live-page > .kra-table-box { + min-width: 0; +} + +.live-search-form { + display: flex; + flex-wrap: wrap; + align-items: flex-start; +} + +.live-search-form .el-form-item { + margin-right: 18px; +} + +.live-search-form .el-input-number { + width: 208px; +} + +.live-search-form .el-date-editor--daterange { + width: 320px; +} + +.live-search-form .live-search-actions { + margin-right: 0; +} + +.live-table-box .live-sort-input { + width: 72px; +} + +.live-table-box .el-button + .el-dropdown, +.live-table-box .el-dropdown + .el-button { + margin-left: 8px; +} + +.live-summary-box { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.live-summary-item { + min-width: 0; + padding: 4px 0; +} + +.live-summary-item + .live-summary-item { + padding-left: 24px; + border-left: 1px solid var(--el-border-color-lighter); +} + +.live-summary-item span { + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.live-summary-item strong { + display: block; + margin-top: 10px; + color: var(--el-text-color-primary); + font-size: 24px; + font-weight: 600; + letter-spacing: 0; +} + +.live-summary-item small { + margin-left: 5px; + color: var(--el-text-color-placeholder); + font-size: 12px; + font-weight: 400; +} + +.live-cover { + width: 82px; + height: 48px; + border-radius: 4px; + background: var(--el-fill-color); +} + +.live-icon-preview { + width: 42px; + height: 42px; + border-radius: 4px; + background: var(--el-fill-color); + object-fit: cover; +} + +.live-uploader .live-upload-preview { + position: relative; + width: 180px; + height: 104px; + overflow: hidden; + border: 1px dashed var(--el-border-color); + border-radius: var(--kra-radius, 6px); +} + +.live-upload-preview .el-image { + width: 100%; + height: 100%; +} + +.live-upload-preview span { + position: absolute; + inset: auto 0 0; + padding: 6px; + background: rgb(0 0 0 / 55%); + color: #fff; + font-size: 12px; + text-align: center; +} + +.live-upload-progress { + display: grid; + grid-template-columns: minmax(0, 180px) minmax(180px, 1fr); + align-items: center; + gap: 12px; + margin-top: 10px; + font-size: 12px; +} + +.live-upload-progress span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-upload-url { + margin-top: 8px; +} + +.live-money { + font-variant-numeric: tabular-nums; +} + +.live-muted { + color: var(--el-text-color-secondary); + font-size: 12px; +} + +.live-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 12px; +} + +.live-drawer-header > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.live-drawer-header .el-button + .el-button { + margin-left: 0; +} + +.live-drawer-header strong { + font-size: 16px; + font-weight: 500; +} + +.live-drawer-form { + padding-top: 4px; +} + +.live-drawer-form .el-input, +.live-drawer-form .el-input-number, +.live-drawer-form .el-select, +.live-dialog-form .el-input, +.live-dialog-form .el-input-number, +.live-dialog-form .el-select, +.live-dialog-form .el-segmented { + width: 100%; +} + +.live-form-section { + margin: 8px 0 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--el-border-color-lighter); + color: var(--el-text-color-primary); + font-size: 14px; + font-weight: 600; +} + +.live-empty { + padding: 48px 20px; + text-align: center; +} + +@media (max-width: 1100px) { + .live-summary-box { + grid-template-columns: 1fr; + gap: 14px; + } + + .live-summary-item + .live-summary-item { + padding-top: 14px; + padding-left: 0; + border-top: 1px solid var(--el-border-color-lighter); + border-left: 0; + } +} + +@media (max-width: 680px) { + .live-search-form { + display: block; + } + + .live-search-form .el-form-item, + .live-search-form .el-form-item > .el-input, + .live-search-form .el-form-item .el-select, + .live-search-form .el-form-item .el-input-number, + .live-search-form .el-form-item .el-date-editor { + width: 100%; + margin-right: 0; + } + + .live-search-form .live-search-actions { + display: flex; + } + + .live-upload-progress { + grid-template-columns: 1fr; + } +} diff --git a/web/src/modules/live/view/categories.vue b/web/src/modules/live/view/categories.vue new file mode 100644 index 0000000..4380506 --- /dev/null +++ b/web/src/modules/live/view/categories.vue @@ -0,0 +1,41 @@ + + + + diff --git a/web/src/modules/live/view/diagnostics.vue b/web/src/modules/live/view/diagnostics.vue new file mode 100644 index 0000000..0f37325 --- /dev/null +++ b/web/src/modules/live/view/diagnostics.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/web/src/modules/live/view/gifts.vue b/web/src/modules/live/view/gifts.vue new file mode 100644 index 0000000..38d1ba5 --- /dev/null +++ b/web/src/modules/live/view/gifts.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/web/src/modules/live/view/orders.vue b/web/src/modules/live/view/orders.vue new file mode 100644 index 0000000..9143daf --- /dev/null +++ b/web/src/modules/live/view/orders.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/web/src/modules/live/view/room.vue b/web/src/modules/live/view/room.vue new file mode 100644 index 0000000..835d15c --- /dev/null +++ b/web/src/modules/live/view/room.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/modules/live/view/rooms.vue b/web/src/modules/live/view/rooms.vue new file mode 100644 index 0000000..d703204 --- /dev/null +++ b/web/src/modules/live/view/rooms.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/web/src/modules/live/view/sends.vue b/web/src/modules/live/view/sends.vue new file mode 100644 index 0000000..d062166 --- /dev/null +++ b/web/src/modules/live/view/sends.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/modules/live/view/settings.vue b/web/src/modules/live/view/settings.vue new file mode 100644 index 0000000..31d8337 --- /dev/null +++ b/web/src/modules/live/view/settings.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/src/modules/live/view/studio.vue b/web/src/modules/live/view/studio.vue new file mode 100644 index 0000000..57b36b7 --- /dev/null +++ b/web/src/modules/live/view/studio.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/modules/live/view/users.vue b/web/src/modules/live/view/users.vue new file mode 100644 index 0000000..cb1871a --- /dev/null +++ b/web/src/modules/live/view/users.vue @@ -0,0 +1,52 @@ + + + diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index dd1e10f..1bace1e 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -65,5 +65,18 @@ "/src/view/systemTools/version/version.vue": "SysVersion", "/src/modules/announcement/form/info.vue": "InfoForm", "/src/modules/announcement/view/info.vue": "Info", - "/src/modules/email/view/index.vue": "Email" + "/src/modules/email/view/index.vue": "Email", + "/src/modules/live/components/ImageUploader.vue": "LiveImageUploader", + "/src/modules/live/components/LivePageHeader.vue": "LivePageHeader", + "/src/modules/live/components/VideoUploader.vue": "LiveVideoUploader", + "/src/modules/live/view/categories.vue": "LiveCategories", + "/src/modules/live/view/diagnostics.vue": "LiveDiagnostics", + "/src/modules/live/view/gifts.vue": "LiveGifts", + "/src/modules/live/view/orders.vue": "LiveOrders", + "/src/modules/live/view/room.vue": "LiveViewer", + "/src/modules/live/view/rooms.vue": "LiveRooms", + "/src/modules/live/view/sends.vue": "LiveSends", + "/src/modules/live/view/settings.vue": "LiveSettings", + "/src/modules/live/view/studio.vue": "LiveStudio", + "/src/modules/live/view/users.vue": "LiveUsers" } diff --git a/web/src/router/index.js b/web/src/router/index.js index b13a303..877406b 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -30,6 +30,24 @@ const routes = [ component: () => import('@/view/system/security/forceChangePassword.vue'), meta: { title: '修改密码' } }, + { + path: '/live/room/:id', + name: 'LiveViewer', + component: () => import('@/modules/live/view/room.vue'), + meta: { title: '直播间', client: true } + }, + { + path: '/live/studio/:id', + name: 'LiveStudio', + component: () => import('@/modules/live/view/studio.vue'), + meta: { title: '主播开播台' } + }, + { + path: '/live/diagnostics/:id', + name: 'LiveDiagnostics', + component: () => import('@/modules/live/view/diagnostics.vue'), + meta: { title: '直播间诊断' } + }, { path: '/:catchAll(.*)', meta: { diff --git a/web/src/view/dashboard/index.vue b/web/src/view/dashboard/index.vue index f59fd1c..1fe20cd 100644 --- a/web/src/view/dashboard/index.vue +++ b/web/src/view/dashboard/index.vue @@ -11,6 +11,47 @@
{{ item.title }}
{{ item.value }}
+
+
+
+

宇宙直播数据

+

直播间、充值和礼物业务的实时概览

+
+
+ + 刷新 +
+
+
+
+ {{ item.title }} + {{ item.value }} + {{ item.detail }} +
+
+
+
+

近 7 日充值趋势

按支付完成金额统计

¥{{ liveMoney(liveTrendTotal) }}
+
+
+ {{ liveShortMoney(item.amountCents) }} +
+ {{ String(item.date || '').slice(5) }} +
+
+ +
+
+

直播间礼物榜

当前筛选周期 Top 5

+
+
+ {{ index + 1 }}
{{ item.roomName || `直播间 ${item.roomId}` }}{{ item.anchorName || '未设置主播' }}
{{ liveNumber(item.diamonds) }} 钻石 +
+
+ +
+
+
@@ -21,18 +62,78 @@ + + diff --git a/web/vite.config.js b/web/vite.config.js index 4420bb9..939d384 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -93,7 +93,8 @@ export default ({ mode }) => { vue$: 'vue/dist/vue.runtime.esm-bundler.js' } - const base = '/' + // 生产环境部署在 live.fuhua91.com/admin/ 下,由 .env.production 的 VITE_APP_BASE 控制 + const base = env.VITE_APP_BASE || '/' const root = './' const outDir = 'dist' @@ -122,6 +123,7 @@ export default ({ mode }) => { // 需要代理的路径 例如 '/api' target: `${env.VITE_BASE_PATH}:${env.VITE_SERVER_PORT}/`, // 代理到 目标路径 changeOrigin: true, + ws: true, rewrite: (path) => path.replace(new RegExp('^' + env.VITE_BASE_API), '') }