diff --git a/.gitignore b/.gitignore index c108be9..ff379ae 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,13 @@ web/node_modules/ # Local reference sources and desktop-only tooling state /gva/ /.claude/settings.local.json + +# Local runtime data +yzyzlive.db +.tmp-* + +# Nested git repo (live-webrtc-frontend, own repo: desktop/live-webrtc-frontend) +frontend/ + +# Old reference sources +old/ diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0bc0331..ac5c1d7 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -202,7 +202,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg categoryUsecase := live2.NewCategoryUsecase(catalogRepo) roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase) catalogService := live3.NewCatalogService(categoryUsecase, roomUsecase) - handlerLive := handler.NewLive(streamService, catalogService) + followRepo := live.NewFollowRepo(dataData) + followUsecase := live2.NewFollowUsecase(followRepo) + followService := live3.NewFollowService(followUsecase) + handlerLive := handler.NewLive(streamService, catalogService, followService) repo := livechat.NewRepo(dataData) usecase := livechat2.NewUsecase(repo) service := livechat3.NewService(usecase) @@ -223,7 +226,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg cleanup() return nil, nil, err } - giftUsecase := gift2.NewUsecase(giftRepo, manager) + giftUsecase := gift2.NewUsecase(giftRepo, manager, roomUsecase) giftService := gift3.NewService(giftUsecase) handlerGift := handler.NewGift(giftService) rechargeRepo := recharge.NewRepo(dataData) @@ -234,7 +237,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg cleanup() return nil, nil, err } - rechargeService := recharge3.NewService(rechargeUsecase) + rechargeService := recharge3.NewService(rechargeUsecase, integrationConfigUsecase) handlerRecharge := handler.NewRecharge(rechargeService) assetRepo := asset.NewRepo(dataData) assetUsecase := asset2.NewUsecase(assetRepo) diff --git a/configs/config.yaml b/configs/config.yaml index 0dd552d..7d48190 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -6,11 +6,12 @@ server: data: database: driver: mysql - source: root:Xu950329.@tcp(127.0.0.1:3306)/yzyzlive?charset=utf8mb4&parseTime=True&loc=Local + # Supply production credentials through KRA_DATA_DATABASE_*. + source: "" host: 127.0.0.1 port: "3306" user: root - password: Xu950329. + password: "" name: yzyzlive config: charset=utf8mb4&parseTime=True&loc=Local path: "" @@ -88,7 +89,7 @@ admin: router_prefix: "" jwt: # Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally. - signing_key: e8e37061-b137-4438-ad48-30f85bf8c68b + signing_key: "" expires_time: 604800s buffer_time: 86400s issuer: kra diff --git a/deploy/Jenkinsfile b/deploy/Jenkinsfile index 6571180..7bb336e 100644 --- a/deploy/Jenkinsfile +++ b/deploy/Jenkinsfile @@ -35,6 +35,7 @@ pipeline { REGISTRY_NAMESPACE = 'live' BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend" FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend" + LIVEWEB_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/live-web" DEPLOY_HOST = 'root@192.168.2.226' BIN_NAME = 'kratos-admin' APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀 @@ -67,6 +68,7 @@ pipeline { echo "Deploy mode: ${params.DEPLOY_MODE}" echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}" echo "Frontend: ${env.FRONTEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}" + echo "LiveWeb : ${env.LIVEWEB_IMAGE}:${env.DEPLOY_IMAGE_TAG}" } } } @@ -103,6 +105,23 @@ pipeline { } } + stage('Build Live Web') { + when { + expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } + } + steps { + // PC 直播用户端与后端同仓维护,确保当前提交中的接口与页面一起发布。 + // HTTP/WS 均请求同源 /api,由站点 nginx 去前缀转发到后端。 + sh ''' + docker run --rm \ + -v "$PWD/frontend:/app" -w /app \ + -e npm_config_registry=https://registry.npmmirror.com \ + docker.m.daocloud.io/node:20-slim \ + bash -lc "npm ci --no-audit --no-fund && npm run build" + ''' + } + } + stage('Build Images') { when { expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } @@ -119,6 +138,10 @@ pipeline { --tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \ --tag ${FRONTEND_IMAGE}:latest \ web + docker build \ + --tag ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG} \ + --tag ${LIVEWEB_IMAGE}:latest \ + frontend ''' } } @@ -135,6 +158,8 @@ pipeline { docker push ${BACKEND_IMAGE}:latest docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} docker push ${FRONTEND_IMAGE}:latest + docker push ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG} + docker push ${LIVEWEB_IMAGE}:latest docker logout "${REGISTRY_HOST}" ''' } @@ -154,7 +179,7 @@ pipeline { post { success { - echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}" + echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG}" } } } diff --git a/deploy/bt-nginx-live.fuhua91.com.conf b/deploy/bt-nginx-live.fuhua91.com.conf index ebd0960..b25fa92 100644 --- a/deploy/bt-nginx-live.fuhua91.com.conf +++ b/deploy/bt-nginx-live.fuhua91.com.conf @@ -1,8 +1,8 @@ -# live.fuhua91.com 宝塔站点配置 +# live.fuhua91.com 宝塔站点配置(与 2026-09-21 服务器生效版本一致) # 路径规划: -# / -> live 用户端前端(静态目录,或替换为反代) -# /admin/ -> 管理后台(frontend 容器 9501,nginx 去前缀转发) -# /api -> 后端 API/WebSocket(backend 容器 9502) +# / -> live 用户端前端(PC 直播页,live-web 容器 9503) +# /admin/ -> 管理后台(frontend 容器 9501,rewrite 去前缀转发) +# /api -> 后端 API/WebSocket(backend 容器 9502,rewrite 去前缀转发) # 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。 map $http_upgrade $live_connection_upgrade { @@ -63,8 +63,9 @@ server return 404; } - # 后端 API / WebSocket(容器内后端无 /api 前缀,proxy_pass 尾斜杠自动去掉) - location ^~ /api { + # 后端 API / WebSocket(rewrite 去掉 /api 前缀,后端路由无 /api) + location ^~ /api/ { + rewrite ^/api/(.*)$ /$1 break; proxy_pass http://127.0.0.1:9502; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -76,6 +77,14 @@ server proxy_read_timeout 600s; proxy_send_timeout 600s; } + location = /api { + proxy_pass http://127.0.0.1:9502/; + 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; + } # 管理后台(frontend 容器;rewrite 去掉 /admin 前缀,前端已按 /admin/ base 构建) location = /admin { @@ -91,17 +100,19 @@ server 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 + # live 用户端前端(PC 直播页,live-web 容器,主域名根路径) location / { - root /www/wwwroot/live.fuhua91.com/live-web; - try_files $uri $uri/ /index.html; + proxy_pass http://127.0.0.1:9503; + 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; } - #禁止在证书验证目录放入敏感文件 + # 禁止在证书验证目录放入敏感文件 if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { return 403; } diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 9aad11c..fac4052 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -14,25 +14,27 @@ set -euo pipefail # ---------- 可按需调整的参数 ---------- REGISTRY="harbor.fuhua91.com" PROJECT="live" -# Harbor 机器人账号(也可通过环境变量 HARBOR_USER / HARBOR_PASSWORD 覆盖) +# Harbor 机器人账号(密码只通过 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 容器 +FRONTEND_PORT=9501 # admin 管理后台 nginx 容器 BACKEND_PORT=9502 # 后端 API/WebSocket 容器 +LIVEWEB_PORT=9503 # live 用户端(PC 直播页) nginx 容器 NETWORK="live-net" BACKEND_NAME="live-backend" FRONTEND_NAME="live-frontend" +LIVEWEB_NAME="live-web" # -------------------------------------- TAG="${1:-latest}" BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG" FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG" +LIVEWEB_IMAGE="$REGISTRY/$PROJECT/live-web:$TAG" echo "==> 部署版本: $TAG" @@ -47,21 +49,27 @@ if [ ! -f "$CONFIG_DIR/config.yaml" ]; then exit 1 fi -# 3. 登录 Harbor(优先使用环境变量 HARBOR_USER / HARBOR_PASSWORD) +# 3. 登录 Harbor;未传密码时复用服务器现有的 Docker 登录态 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 +HARBOR_LOGGED_IN=false +if [ -n "${HARBOR_PASSWORD:-}" ]; then + echo "==> 登录 $REGISTRY" + echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null + HARBOR_LOGGED_IN=true +else + echo "==> 未提供 HARBOR_PASSWORD,复用服务器现有 Docker 登录态" +fi # 4. 拉取最新镜像 echo "==> 拉取镜像" docker pull "$BACKEND_IMAGE" docker pull "$FRONTEND_IMAGE" +docker pull "$LIVEWEB_IMAGE" # 5. 重建容器 echo "==> 重启容器" docker network create "$NETWORK" >/dev/null 2>&1 || true -docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" 2>/dev/null || true +docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" "$LIVEWEB_NAME" 2>/dev/null || true # 容器内进程以 uid 1000 (kra) 运行,日志/上传目录需要写权限 chown -R 1000:1000 "$LOG_DIR" "$UPLOADS_DIR" 2>/dev/null || \ @@ -84,11 +92,21 @@ docker run -d --name "$FRONTEND_NAME" \ -p "$FRONTEND_PORT:80" \ "$FRONTEND_IMAGE" -docker logout "$REGISTRY" >/dev/null 2>&1 || true +# live 用户端(PC 直播页): nginx 静态站点,主域名 / 反代到该容器 +docker run -d --name "$LIVEWEB_NAME" \ + --restart always \ + --network "$NETWORK" \ + -p "$LIVEWEB_PORT:80" \ + "$LIVEWEB_IMAGE" + +if [ "$HARBOR_LOGGED_IN" = true ]; then + docker logout "$REGISTRY" >/dev/null 2>&1 || true +fi echo "==> 部署完成" -docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME" +docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME" --filter "name=$LIVEWEB_NAME" echo "" -echo "前端容器: http://<服务器IP>:$FRONTEND_PORT" +echo "admin 前端容器: http://<服务器IP>:$FRONTEND_PORT" echo "后端容器: http://<服务器IP>:$BACKEND_PORT" +echo "live 前端容器: http://<服务器IP>:$LIVEWEB_PORT" echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。" diff --git a/docs/LIVE.md b/docs/LIVE.md index 57b9048..6f8998a 100644 --- a/docs/LIVE.md +++ b/docs/LIVE.md @@ -4,7 +4,7 @@ ## WebSocket -连接现有 WebSocket 地址(默认 `/ws`),握手必须携带 GVA JWT: +连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力: ```text wss://host/ws?token= @@ -16,7 +16,7 @@ wss://host/ws?token= {"type":"Join","payload":{"role":"publisher","streamId":"room-1"}} ``` -角色支持 `publisher`、`subscriber`、`observer`。主播和观众完成 Join 后,浏览器发送 SDP offer: +角色支持 `publisher`、`subscriber`、`observer`。`subscriber` 可匿名加入,`publisher` 必须登录且只能发布自己拥有的启用直播间。主播和观众完成 Join 后,浏览器发送 SDP offer: ```json {"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}} @@ -34,7 +34,6 @@ wss://host/ws?token= - `SubscriberLeft` - `StreamListUpdate` - `Kicked` -- `ChatHistory`:加入房间后返回最近 100 条聊天和礼物消息 - `ChatMessage`:普通聊天广播 - `GiftMessage`:送礼成功后的礼物广播 @@ -44,7 +43,7 @@ wss://host/ws?token= {"type":"ChatSend","payload":{"content":"大家好"}} ``` -聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。 +聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。消息只保存在当前服务进程内,每个房间最多 100 条;加入房间不会回放进入前的消息,开播、关播或服务重启都会清空该房间聊天。 主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。 @@ -55,34 +54,48 @@ wss://host/ws?token= ```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?page=1&pageSize=20&categoryId=1&status=live&orderBy=viewer_count_desc GET /live/rooms/:id +POST /live/rooms/:id/like GET /live/rooms/:id/messages?limit=100 GET /live/gifts +GET /live/recharge/providers +GET /live/recharge/plans ``` +`/live/rooms` 默认只返回当前直播中的启用房间,并按在线人数倒序排列;支持 `categoryId`、`page`、`pageSize` 和 `orderBy` 筛选/排序。房间响应同时返回 `viewerCountText`(如 `5.1k`、`1.0w`)以及直播中的 `startedAt`。 + +聊天历史 HTTP 接口保留用于客户端契约兼容,但验证房间后始终返回空列表,不会向新进入用户泄露此前聊天。 + PC 客户端账号接口: ```text POST /live/client/auth/register POST /live/client/auth/login +POST /live/client/auth/logout GET /live/client/me +GET /live/client/balance PUT /live/client/me PUT /live/client/me/password ``` -客户端账号使用系统的 `sys_users` 表,注册时创建普通直播角色并初始化 100 钻石。注册和修改密码都会返回新的 JWT 登录态。 +客户端账号使用系统的 `sys_users` 表。注册昵称可选,未填写时使用用户名,填写时限制为 2–16 个字符;新账号创建普通直播角色并初始化 100 钻石,历史账号缺少钱包时也补建 100 钻石。注册和修改密码都会返回新的 JWT 登录态。 登录后可调用: ```text +GET /live/rooms/:id/follow +POST /live/rooms/:id/follow +DELETE /live/rooms/:id/follow POST /live/rooms/:id/gifts -GET /live/recharge/plans POST /live/recharge/orders +GET /live/recharge/orders/:orderNo +GET /live/recharge/orders/:orderNo/status ``` +点赞不要求登录,点赞数只在当前进程的直播运行态中累加;关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。 + 创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识: ```json @@ -96,7 +109,7 @@ POST /live/recharge/orders 充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。 -礼物数量必须使用礼物接口返回的 `tiers`,扣款、礼物流水和钱包余额在同一事务内完成;成功后通过 `GiftMessage` 广播给房间内在线用户。 +礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。 全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。 diff --git a/docs/harbor.json b/docs/harbor.json new file mode 100644 index 0000000..be0f7da --- /dev/null +++ b/docs/harbor.json @@ -0,0 +1 @@ + {"creation_time":"2026-09-21T06:30:55.339Z","expires_at":-1,"id":7,"name":"robot$live+live","secret":"R4p6ieacIogXE779eL1HXhCpA4bx4SA7"} \ No newline at end of file diff --git a/internal/biz/gift/gift.go b/internal/biz/gift/gift.go index e6159ed..ac56c89 100644 --- a/internal/biz/gift/gift.go +++ b/internal/biz/gift/gift.go @@ -2,10 +2,14 @@ package gift import ( "context" - kratoserrors "github.com/go-kratos/kratos/v3/errors" + "fmt" "sort" "strings" "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" + + bizlive "kra/internal/biz/live" ) var ( @@ -17,6 +21,24 @@ var ( ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent") ) +// InsufficientBalanceError preserves the wallet snapshot needed by clients +// to decide whether to open the recharge flow. It unwraps to the stable +// ErrInsufficientBalance reason so existing errors.Is checks keep working. +type InsufficientBalanceError struct { + Required int64 + Balance int64 + Missing int64 +} + +func (e *InsufficientBalanceError) Error() string { + if e == nil { + return ErrInsufficientBalance.Error() + } + return fmt.Sprintf("insufficient diamond balance: need %d, have %d, missing %d", e.Required, e.Balance, e.Missing) +} + +func (e *InsufficientBalanceError) Unwrap() error { return ErrInsufficientBalance } + type Gift struct { ID uint Name, Icon, Description string @@ -72,13 +94,21 @@ type Repo interface { type Notifier interface { GiftSent(context.Context, *Send) } +type roomAvailability interface { + IsLive(context.Context, uint) (bool, error) +} type Usecase struct { repo Repo notifier Notifier + rooms roomAvailability } -func NewUsecase(repo Repo, notifier Notifier) *Usecase { - return &Usecase{repo: repo, notifier: notifier} +func NewUsecase(repo Repo, notifier Notifier, rooms *bizlive.RoomUsecase) *Usecase { + return newUsecase(repo, notifier, rooms) +} + +func newUsecase(repo Repo, notifier Notifier, rooms roomAvailability) *Usecase { + return &Usecase{repo: repo, notifier: notifier, rooms: rooms} } func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) { f.Name = strings.TrimSpace(f.Name) @@ -115,6 +145,13 @@ func (uc *Usecase) Send(ctx context.Context, userID, roomID, giftID uint, quanti if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 { return nil, ErrSendNotAllowed } + if uc.rooms == nil { + return nil, ErrSendNotAllowed + } + live, err := uc.rooms.IsLive(ctx, roomID) + if err != nil || !live { + return nil, ErrSendNotAllowed + } result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity) if err != nil { return nil, err diff --git a/internal/biz/gift/gift_test.go b/internal/biz/gift/gift_test.go new file mode 100644 index 0000000..bad7c6e --- /dev/null +++ b/internal/biz/gift/gift_test.go @@ -0,0 +1,49 @@ +package gift + +import ( + "context" + "errors" + "testing" +) + +type sendRepoStub struct { + Repo + called bool +} + +func (r *sendRepoStub) Send(_ context.Context, userID, roomID, giftID uint, quantity int) (*Send, error) { + r.called = true + return &Send{UserID: userID, RoomID: &roomID, GiftID: &giftID, Quantity: quantity}, nil +} + +type availabilityStub struct { + live bool + err error +} + +func (s availabilityStub) IsLive(context.Context, uint) (bool, error) { return s.live, s.err } + +func TestSendRejectsOfflineRoomBeforeWalletMutation(t *testing.T) { + repo := &sendRepoStub{} + uc := newUsecase(repo, nil, availabilityStub{live: false}) + + if _, err := uc.Send(context.Background(), 1, 7, 3, 2); !errors.Is(err, ErrSendNotAllowed) { + t.Fatalf("offline send error = %v", err) + } + if repo.called { + t.Fatal("offline room reached gift repository") + } +} + +func TestSendAcceptsCustomQuantityForLiveRoom(t *testing.T) { + repo := &sendRepoStub{} + uc := newUsecase(repo, nil, availabilityStub{live: true}) + + item, err := uc.Send(context.Background(), 1, 7, 3, 37) + if err != nil { + t.Fatal(err) + } + if !repo.called || item.Quantity != 37 { + t.Fatalf("custom quantity result = %+v called=%v", item, repo.called) + } +} diff --git a/internal/biz/live/catalog.go b/internal/biz/live/catalog.go index 8f886c5..7526f39 100644 --- a/internal/biz/live/catalog.go +++ b/internal/biz/live/catalog.go @@ -2,6 +2,8 @@ package live import ( "context" + "fmt" + "sort" "strings" "time" @@ -22,6 +24,7 @@ var ( 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") + ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting") ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found") ) @@ -37,23 +40,26 @@ type Category struct { } 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 + 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 + ViewerCountText string + LikeCount int + StartedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } type CategoryFilter struct { @@ -62,12 +68,13 @@ type CategoryFilter struct { } type RoomFilter struct { - Name string - AnchorName string - CategoryID uint - Status string - Enabled *bool - CreatedAt []time.Time + Name string + AnchorName string + CategoryID uint + Status string + Enabled *bool + CategoryEnabled *bool + CreatedAt []time.Time } type ListOptions struct { @@ -174,6 +181,17 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte matched = append(matched, room) } } + if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") { + sort.SliceStable(matched, func(i, j int) bool { + if matched[i].ViewerCount == matched[j].ViewerCount { + return matched[i].ID < matched[j].ID + } + if strings.EqualFold(options.OrderBy, "viewer_count_asc") { + return matched[i].ViewerCount < matched[j].ViewerCount + } + return matched[i].ViewerCount > matched[j].ViewerCount + }) + } total := int64(len(matched)) if options.Offset >= len(matched) { return []*Room{}, total, nil @@ -196,6 +214,32 @@ func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) { return uc.attachRuntime(ctx, []*Room{room})[0], nil } +func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) { + room, err := uc.Get(ctx, id) + if err != nil { + return nil, err + } + if !room.Enabled || room.Status != RoomStatusLive { + return nil, ErrRoomNotLive + } + category, err := uc.repo.GetCategory(ctx, room.CategoryID) + if err != nil { + return nil, err + } + if !category.Enabled { + return nil, ErrRoomNotLive + } + return room, nil +} + +func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) { + room, err := uc.Get(ctx, id) + if err != nil { + return false, err + } + return room.Enabled && room.Status == RoomStatusLive, nil +} + func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) { if err := validateRoom(room); err != nil { return nil, err @@ -258,17 +302,41 @@ func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room for _, room := range rooms { if !room.Enabled { room.Status = RoomStatusDisabled + room.ViewerCountText = formatViewerCount(room.ViewerCount) continue } room.Status = RoomStatusOffline if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline { room.Status = RoomStatusLive room.ViewerCount = stream.SubscriberCount + room.LikeCount = stream.LikeCount + if !stream.StartedAt.IsZero() { + startedAt := stream.StartedAt + room.StartedAt = &startedAt + } } + room.ViewerCountText = formatViewerCount(room.ViewerCount) } return rooms } +func formatViewerCount(value int) string { + if value < 1000 { + if value < 0 { + value = 0 + } + return formatUint(uint(value)) + } + if value >= 10000 { + return formatDecimal(float64(value)/10000, "w") + } + return formatDecimal(float64(value)/1000, "k") +} + +func formatDecimal(value float64, suffix string) string { + return fmt.Sprintf("%.1f%s", value, suffix) +} + func roomStreamID(id uint) string { return "room-" + formatUint(id) } func formatUint(value uint) string { diff --git a/internal/biz/live/catalog_runtime_test.go b/internal/biz/live/catalog_runtime_test.go new file mode 100644 index 0000000..7228a42 --- /dev/null +++ b/internal/biz/live/catalog_runtime_test.go @@ -0,0 +1,160 @@ +package live + +import ( + "context" + "errors" + "testing" +) + +type catalogRepoStub struct { + categories []*Category + rooms []*Room +} + +func (*catalogRepoStub) ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) { + return nil, 0, nil +} +func (r *catalogRepoStub) GetCategory(_ context.Context, id uint) (*Category, error) { + for _, category := range r.categories { + if category.ID == id { + copy := *category + return ©, nil + } + } + return nil, ErrCategoryNotFound +} +func (*catalogRepoStub) CreateCategory(context.Context, *Category) (*Category, error) { + return nil, nil +} +func (*catalogRepoStub) UpdateCategory(context.Context, *Category) (*Category, error) { + return nil, nil +} +func (*catalogRepoStub) DeleteCategory(context.Context, uint) error { return nil } + +func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) { + filtered := make([]*Room, 0, len(r.rooms)) + for _, room := range r.rooms { + if filter.CategoryID > 0 && room.CategoryID != filter.CategoryID { + continue + } + if filter.Enabled != nil && room.Enabled != *filter.Enabled { + continue + } + if filter.CategoryEnabled != nil { + category, err := r.GetCategory(context.Background(), room.CategoryID) + if err != nil || category.Enabled != *filter.CategoryEnabled { + continue + } + } + copy := *room + filtered = append(filtered, ©) + } + total := int64(len(filtered)) + if options.Offset >= len(filtered) { + return []*Room{}, total, nil + } + end := options.Offset + options.Limit + if end > len(filtered) { + end = len(filtered) + } + return filtered[options.Offset:end], total, nil +} + +func (r *catalogRepoStub) GetRoom(_ context.Context, id uint) (*Room, error) { + for _, room := range r.rooms { + if room.ID == id { + copy := *room + return ©, nil + } + } + return nil, ErrRoomNotFound +} + +func (*catalogRepoStub) CreateRoom(context.Context, *Room) (*Room, error) { return nil, nil } +func (*catalogRepoStub) UpdateRoom(context.Context, *Room) (*Room, error) { return nil, nil } +func (*catalogRepoStub) DeleteRoom(context.Context, uint) error { return nil } +func (*catalogRepoStub) UpdateRoomEnabled(context.Context, uint, bool) error { + return nil +} +func (*catalogRepoStub) SaveRoomSort(context.Context, map[uint]int) error { return nil } + +func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) { + enabled := true + catalog := &catalogRepoStub{rooms: []*Room{ + {ID: 1, CategoryID: 1, Enabled: true}, + {ID: 2, CategoryID: 1, Enabled: true}, + {ID: 3, CategoryID: 1, Enabled: true}, + {ID: 4, CategoryID: 2, Enabled: true}, + }} + streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ + "room-1": {StreamID: "room-1", PublisherOnline: true, SubscriberCount: 100}, + "room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 5600}, + "room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000}, + }}) + rooms := NewRoomUsecase(catalog, streams) + + items, total, err := rooms.List(context.Background(), RoomFilter{CategoryID: 1, Status: RoomStatusLive, Enabled: &enabled}, ListOptions{Offset: 1, Limit: 1, OrderBy: "viewer_count_desc"}) + if err != nil { + t.Fatal(err) + } + if total != 2 || len(items) != 1 || items[0].ID != 1 { + t.Fatalf("live page total=%d items=%+v", total, items) + } + if items[0].ViewerCount != 100 || items[0].ViewerCountText != "100" || items[0].Status != RoomStatusLive { + t.Fatalf("runtime fields = %+v", items[0]) + } +} + +func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) { + catalog := &catalogRepoStub{rooms: []*Room{ + {ID: 1, Enabled: true}, + {ID: 2, Enabled: true}, + {ID: 3, Enabled: false}, + }} + streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ + "room-1": {StreamID: "room-1", PublisherOnline: true}, + "room-3": {StreamID: "room-3", PublisherOnline: true}, + }}) + rooms := NewRoomUsecase(catalog, streams) + + for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} { + got, err := rooms.IsLive(context.Background(), roomID) + if err != nil { + t.Fatalf("IsLive(%d) error = %v", roomID, err) + } + if got != want { + t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want) + } + } +} + +func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) { + catalog := &catalogRepoStub{ + categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}}, + rooms: []*Room{ + {ID: 1, CategoryID: 1, Enabled: true}, + {ID: 2, CategoryID: 1, Enabled: true}, + {ID: 3, CategoryID: 1, Enabled: false}, + {ID: 4, CategoryID: 2, Enabled: true}, + }, + } + streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ + "room-1": {StreamID: "room-1", PublisherOnline: true}, + "room-3": {StreamID: "room-3", PublisherOnline: true}, + "room-4": {StreamID: "room-4", PublisherOnline: true}, + }}) + rooms := NewRoomUsecase(catalog, streams) + + item, err := rooms.GetPublic(context.Background(), 1) + if err != nil || item.ID != 1 || item.Status != RoomStatusLive { + t.Fatalf("GetPublic(1) item=%+v err=%v", item, err) + } + for _, roomID := range []uint{2, 3, 4} { + if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) { + t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive) + } + if _, err = rooms.Get(context.Background(), roomID); err != nil { + t.Errorf("admin Get(%d) error=%v", roomID, err) + } + } +} diff --git a/internal/biz/live/follow.go b/internal/biz/live/follow.go new file mode 100644 index 0000000..45bb396 --- /dev/null +++ b/internal/biz/live/follow.go @@ -0,0 +1,58 @@ +package live + +import ( + "context" + "errors" + "time" + + kratoserrors "github.com/go-kratos/kratos/v3/errors" +) + +var ( + ErrFollowNotFound = kratoserrors.NotFound("LIVE_FOLLOW_NOT_FOUND", "follow relation not found") + ErrFollowInvalid = kratoserrors.BadRequest("LIVE_FOLLOW_INVALID_ARGUMENT", "invalid follow argument") +) + +type Follow struct { + ID uint + UserID uint + RoomID uint + AnchorUserID uint + CreatedAt time.Time +} + +type FollowRepo interface { + Get(context.Context, uint, uint) (*Follow, error) + Create(context.Context, *Follow) (*Follow, error) + Delete(context.Context, uint, uint) error +} + +type FollowUsecase struct{ repo FollowRepo } + +func NewFollowUsecase(repo FollowRepo) *FollowUsecase { return &FollowUsecase{repo: repo} } + +func (uc *FollowUsecase) Get(ctx context.Context, userID, roomID uint) (*Follow, error) { + if userID == 0 || roomID == 0 { + return nil, ErrFollowInvalid + } + return uc.repo.Get(ctx, userID, roomID) +} + +func (uc *FollowUsecase) Follow(ctx context.Context, userID, roomID uint) (*Follow, error) { + if userID == 0 || roomID == 0 { + return nil, ErrFollowInvalid + } + if existing, err := uc.repo.Get(ctx, userID, roomID); err == nil { + return existing, nil + } else if !errors.Is(err, ErrFollowNotFound) { + return nil, err + } + return uc.repo.Create(ctx, &Follow{UserID: userID, RoomID: roomID}) +} + +func (uc *FollowUsecase) Unfollow(ctx context.Context, userID, roomID uint) error { + if userID == 0 || roomID == 0 { + return ErrFollowInvalid + } + return uc.repo.Delete(ctx, userID, roomID) +} diff --git a/internal/biz/live/provider.go b/internal/biz/live/provider.go index aad5e62..4454068 100644 --- a/internal/biz/live/provider.go +++ b/internal/biz/live/provider.go @@ -2,4 +2,4 @@ package live import "github.com/google/wire" -var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase) +var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase) diff --git a/internal/biz/live/stream.go b/internal/biz/live/stream.go index 2fb9efe..24eaefd 100644 --- a/internal/biz/live/stream.go +++ b/internal/biz/live/stream.go @@ -27,6 +27,8 @@ type Stream struct { PublisherID uint PublisherOnline bool SubscriberCount int + LikeCount int + StartedAt time.Time UpdatedAt time.Time } @@ -37,6 +39,7 @@ type StreamRepo interface { UnregisterPublisher(context.Context, string, uint) error RegisterSubscriber(context.Context, string, uint) (*Stream, error) UnregisterSubscriber(context.Context, string, uint) error + Like(context.Context, string) (int, error) } type StreamUsecase struct{ repo StreamRepo } @@ -79,6 +82,14 @@ func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID stri return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID) } +func (uc *StreamUsecase) Like(ctx context.Context, streamID string) (int, error) { + streamID = normalizeStreamID(streamID) + if streamID == "" { + return 0, ErrInvalidStreamID + } + return uc.repo.Like(ctx, streamID) +} + 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 index 08a14fe..54a6c02 100644 --- a/internal/biz/live/stream_test.go +++ b/internal/biz/live/stream_test.go @@ -5,6 +5,15 @@ import ( "testing" ) +func TestFormatViewerCount(t *testing.T) { + tests := map[int]string{0: "0", 999: "999", 1000: "1.0k", 5600: "5.6k", 10000: "1.0w", 12500: "1.2w"} + for input, want := range tests { + if got := formatViewerCount(input); got != want { + t.Fatalf("formatViewerCount(%d) = %q, want %q", input, got, want) + } + } +} + type testRepo struct { streams map[string]*Stream } @@ -41,6 +50,7 @@ func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, e return nil, nil } func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil } +func (r *testRepo) Like(context.Context, string) (int, error) { return 0, nil } func TestStreamUsecaseValidatesAndDelegates(t *testing.T) { repo := &testRepo{streams: make(map[string]*Stream)} diff --git a/internal/biz/livechat/chat.go b/internal/biz/livechat/chat.go index abfb91a..ea463ff 100644 --- a/internal/biz/livechat/chat.go +++ b/internal/biz/livechat/chat.go @@ -53,6 +53,7 @@ type Repo interface { ListRecent(context.Context, uint, int) ([]*Message, error) CreateChat(context.Context, uint, uint, string) (*Message, error) CreateGift(context.Context, *GiftSnapshot) (*Message, error) + Clear(context.Context, uint) error } type Usecase struct{ repo Repo } @@ -83,3 +84,10 @@ func (uc *Usecase) RecordGift(ctx context.Context, gift *GiftSnapshot) (*Message } return uc.repo.CreateGift(ctx, gift) } + +func (uc *Usecase) Clear(ctx context.Context, roomID uint) error { + if roomID == 0 { + return ErrRoomNotFound + } + return uc.repo.Clear(ctx, roomID) +} diff --git a/internal/biz/liveclient/account.go b/internal/biz/liveclient/account.go index 48186fc..256c7d6 100644 --- a/internal/biz/liveclient/account.go +++ b/internal/biz/liveclient/account.go @@ -36,7 +36,7 @@ type Account struct { } type RegisterInput struct { - Username, Password, ConfirmPassword string + Username, Nickname, Password, ConfirmPassword string } type LoginInput struct { @@ -84,6 +84,12 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword { return nil, ErrInvalidAccount } + in.Nickname = strings.TrimSpace(in.Nickname) + if in.Nickname == "" { + in.Nickname = in.Username + } else if n := len([]rune(in.Nickname)); n < 2 || n > 16 { + return nil, ErrInvalidAccount + } config, err := uc.security.Current(ctx) if err != nil { return nil, err @@ -95,7 +101,7 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st 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) + account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Nickname, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds) if err != nil { return nil, err } diff --git a/internal/biz/liveclient/account_test.go b/internal/biz/liveclient/account_test.go new file mode 100644 index 0000000..af82549 --- /dev/null +++ b/internal/biz/liveclient/account_test.go @@ -0,0 +1,106 @@ +package liveclient + +import ( + "context" + "errors" + "testing" + "time" + + "kra/internal/biz/system" +) + +type registerRepoStub struct { + Repo + account *Account +} + +func (r *registerRepoStub) Register(_ context.Context, account *Account, _ string, _ int64) (*Account, error) { + copy := *account + copy.UserID = 1 + copy.UUID = "client-uuid" + copy.CreatedAt = time.Now() + r.account = © + return ©, nil +} + +type registerSecurityRepoStub struct{ system.SecurityRepo } + +func (*registerSecurityRepoStub) SecurityConfig(context.Context) (*system.SecurityConfig, error) { + return &system.SecurityConfig{PwdMinLength: 8}, nil +} + +type registerSettingsStub struct{ system.RuntimeSettings } + +func (*registerSettingsStub) UseMultipoint() bool { return false } + +type registerIssuerStub struct{ system.TokenIssuer } + +func (*registerIssuerStub) IssueToken(*system.User, uint, bool, time.Duration) (*system.IssuedToken, error) { + return &system.IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil +} + +func (*registerIssuerStub) ReissueToken(claims *system.AuthClaims, _ uint) (*system.IssuedToken, error) { + return &system.IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil +} + +func (*registerIssuerStub) ParseToken(string) (*system.AuthClaims, error) { return nil, nil } + +func newRegisterUsecase(repo *registerRepoStub) *Usecase { + security := system.NewSecurityUsecase(®isterSecurityRepoStub{}, nil, ®isterSettingsStub{}, nil) + auth := system.NewAuthenticationUsecase(nil, security, ®isterIssuerStub{}, nil) + return NewUsecase(repo, security, auth, nil) +} + +func TestRegisterUsesTrimmedNickname(t *testing.T) { + repo := ®isterRepoStub{} + uc := newRegisterUsecase(repo) + result, err := uc.Register(context.Background(), &RegisterInput{ + Username: "viewer", + Nickname: " 直播用户 ", + Password: "password", + ConfirmPassword: "password", + }, "", "") + if err != nil { + t.Fatal(err) + } + if result.Account.Nickname != "直播用户" || repo.account.Nickname != "直播用户" { + t.Fatalf("nickname = %q, repo nickname = %q", result.Account.Nickname, repo.account.Nickname) + } +} + +func TestRegisterDefaultsNicknameToUsername(t *testing.T) { + repo := ®isterRepoStub{} + uc := newRegisterUsecase(repo) + result, err := uc.Register(context.Background(), &RegisterInput{ + Username: "viewer", + Password: "password", + ConfirmPassword: "password", + }, "", "") + if err != nil { + t.Fatal(err) + } + if result.Account.Nickname != "viewer" { + t.Fatalf("nickname = %q, want username fallback", result.Account.Nickname) + } +} + +func TestRegisterRejectsNicknameOutsideRange(t *testing.T) { + for _, nickname := range []string{"A", "12345678901234567"} { + t.Run(nickname, func(t *testing.T) { + repo := ®isterRepoStub{} + uc := newRegisterUsecase(repo) + _, err := uc.Register(context.Background(), &RegisterInput{ + Username: "viewer", + Nickname: nickname, + Password: "password", + ConfirmPassword: "password", + }, "", "") + if !errors.Is(err, ErrInvalidAccount) { + t.Fatalf("error = %v, want ErrInvalidAccount", err) + } + if repo.account != nil { + t.Fatal("repository called for invalid nickname") + } + }) + } +} diff --git a/internal/data/asset/asset.go b/internal/data/asset/asset.go index b95db73..cfba7eb 100644 --- a/internal/data/asset/asset.go +++ b/internal/data/asset/asset.go @@ -136,7 +136,7 @@ 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"} + m := map[string]string{"id_asc": "sys_users.id ASC", "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_asc": "sys_users.created_at ASC", "created_desc": "sys_users.created_at 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 } diff --git a/internal/data/asset/migrations.go b/internal/data/asset/migrations.go index 8029745..f8e5cac 100644 --- a/internal/data/asset/migrations.go +++ b/internal/data/asset/migrations.go @@ -9,5 +9,14 @@ import ( 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{}) + }}, {ID: "202609220003_live_wallet_backfill", Migrate: func(db *gorm.DB) error { + // Historical client accounts predate live_wallets. Backfill only missing + // rows with the same 100-diamond welcome balance as a new registration; + // existing balances and administrative adjustments remain untouched. + return db.Exec(`INSERT INTO live_wallets (user_id, diamond_balance, total_recharge_cents, total_spent_diamonds, created_at, updated_at) + SELECT u.id, 100, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM sys_users u + WHERE u.deleted_at IS NULL AND u.authority_id = ? + AND NOT EXISTS (SELECT 1 FROM live_wallets w WHERE w.user_id = u.id)`, 1000).Error }}} } diff --git a/internal/data/gift/gift.go b/internal/data/gift/gift.go index 3283c26..08af368 100644 --- a/internal/data/gift/gift.go +++ b/internal/data/gift/gift.go @@ -228,23 +228,13 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i 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 + return &bizgift.InsufficientBalanceError{Required: total, Balance: wallet.DiamondBalance, Missing: total - wallet.DiamondBalance} } rate := int64(10) var setting ledger.SettingPO @@ -292,18 +282,18 @@ func mapError(err error) error { 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"} + m := map[string]string{"sort_asc": "sort ASC", "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", "created_at_asc": "created_at ASC", "created_at_desc": "created_at DESC"} if x := m[strings.ToLower(v)]; x != "" { return x } - return "sort ASC,created_at DESC" + return "sort ASC,created_at DESC,id ASC" } 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"} + m := map[string]string{"created_at_asc": "live_gift_sends.created_at ASC", "created_at_desc": "live_gift_sends.created_at DESC", "price_asc": "live_gift_sends.gift_price ASC", "price_desc": "live_gift_sends.gift_price DESC", "quantity_asc": "live_gift_sends.quantity ASC", "quantity_desc": "live_gift_sends.quantity DESC", "total_asc": "live_gift_sends.diamond_total ASC", "total_desc": "live_gift_sends.diamond_total DESC"} if x := m[strings.ToLower(v)]; x != "" { return x } - return "created_at DESC" + return "live_gift_sends.created_at DESC,live_gift_sends.id 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 index b150a87..871f59d 100644 --- a/internal/data/gift/gift_test.go +++ b/internal/data/gift/gift_test.go @@ -67,12 +67,12 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) { 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) + if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil { + t.Fatalf("custom quantity should be accepted: %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 = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 { + t.Fatalf("wallet did not debit custom quantity: wallet=%+v err=%v", before, err) } if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil { t.Fatal(err) @@ -81,7 +81,38 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) { 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 { + if after.DiamondBalance != 40 || after.TotalSpentDiamonds != 60 || sends != 2 { t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends) } } + +func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) { + db := openGiftTestDB(t) + custom := giftPO{Name: "玫瑰", Icon: "custom", Price: 99, Description: "admin configured", Tiers: "[1]", Sort: 88, Enabled: false} + if err := db.Create(&custom).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&giftPO{}).Where("id = ?", custom.ID).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + if err := seedDefaultCatalog(db); err != nil { + t.Fatal(err) + } + if err := seedDefaultCatalog(db); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&giftPO{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 24 { + t.Fatalf("gift count = %d, want 24", count) + } + var rose giftPO + if err := db.Where("name = ?", "玫瑰").First(&rose).Error; err != nil { + t.Fatal(err) + } + if rose.Icon != "custom" || rose.Price != 99 || rose.Sort != 88 || rose.Enabled { + t.Fatalf("existing gift was overwritten: %+v", rose) + } +} diff --git a/internal/data/gift/migrations.go b/internal/data/gift/migrations.go index fcbe2f1..0be24b4 100644 --- a/internal/data/gift/migrations.go +++ b/internal/data/gift/migrations.go @@ -19,5 +19,59 @@ func Migrations() []migration.Step { 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 - }}} + }}, {ID: "202609220001_live_gift_catalog_seed", Migrate: seedDefaultCatalog}} +} + +// seedDefaultCatalog is intentionally additive. Existing installations may +// have edited names, prices, icons, tiers, sort, or enabled state in the +// administration UI; migration must never overwrite those choices. We only +// insert a missing named gift so the client can rely on the six quick gifts +// and the 24-item "more gifts" grid being available after an upgrade. +func seedDefaultCatalog(db *gorm.DB) error { + defaults := []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}, + {Name: "城堡", Icon: "🏰", Price: 1314, Description: "城堡降临,点亮直播间", Tiers: "[1,10,66,1314]", Sort: 5, Enabled: true}, + {Name: "皇冠", Icon: "👑", Price: 5200, Description: "皇冠加冕,彰显特别支持", Tiers: "[1,10,66,1314]", Sort: 6, Enabled: true}, + {Name: "爱心", Icon: "❤️", Price: 5, Description: "把喜欢送到主播身边", Tiers: "[1,10,66,1314]", Sort: 7, Enabled: true}, + {Name: "棒棒糖", Icon: "🍭", Price: 8, Description: "甜甜的支持送给你", Tiers: "[1,10,66,1314]", Sort: 8, Enabled: true}, + {Name: "咖啡", Icon: "☕", Price: 15, Description: "为主播续上一杯咖啡", Tiers: "[1,10,66,1314]", Sort: 9, Enabled: true}, + {Name: "蛋糕", Icon: "🍰", Price: 20, Description: "分享一份甜蜜祝福", Tiers: "[1,10,66,1314]", Sort: 10, Enabled: true}, + {Name: "气球", Icon: "🎈", Price: 30, Description: "让快乐在直播间起飞", Tiers: "[1,10,66,1314]", Sort: 11, Enabled: true}, + {Name: "钻戒", Icon: "💍", Price: 88, Description: "闪耀的心意送给你", Tiers: "[1,10,66,1314]", Sort: 12, Enabled: true}, + {Name: "麦克风", Icon: "🎤", Price: 99, Description: "为精彩歌声喝彩", Tiers: "[1,10,66,1314]", Sort: 13, Enabled: true}, + {Name: "吉他", Icon: "🎸", Price: 120, Description: "伴着旋律一起摇摆", Tiers: "[1,10,66,1314]", Sort: 14, Enabled: true}, + {Name: "花束", Icon: "💐", Price: 166, Description: "把鲜花和掌声送给主播", Tiers: "[1,10,66,1314]", Sort: 15, Enabled: true}, + {Name: "甜甜圈", Icon: "🍩", Price: 188, Description: "甜甜圈住每一份快乐", Tiers: "[1,10,66,1314]", Sort: 16, Enabled: true}, + {Name: "烟花", Icon: "🎆", Price: 233, Description: "烟花为精彩时刻喝彩", Tiers: "[1,10,66,1314]", Sort: 17, Enabled: true}, + {Name: "豪华游艇", Icon: "🛥️", Price: 520, Description: "乘风破浪驶入直播间", Tiers: "[1,10,66,520]", Sort: 18, Enabled: true}, + {Name: "直升机", Icon: "🚁", Price: 666, Description: "高调登场,气氛拉满", Tiers: "[1,10,66,666]", Sort: 19, Enabled: true}, + {Name: "热气球", Icon: "🎈", Price: 888, Description: "带着祝福飞向天空", Tiers: "[1,10,66,888]", Sort: 20, Enabled: true}, + {Name: "月亮", Icon: "🌙", Price: 999, Description: "月光陪伴每一个夜晚", Tiers: "[1,10,66,999]", Sort: 21, Enabled: true}, + {Name: "太阳", Icon: "☀️", Price: 1024, Description: "把热烈的能量送给主播", Tiers: "[1,10,66,1024]", Sort: 22, Enabled: true}, + {Name: "彩虹", Icon: "🌈", Price: 1888, Description: "愿每一次相遇都如彩虹", Tiers: "[1,10,66,1314]", Sort: 23, Enabled: true}, + {Name: "宝箱", Icon: "🎁", Price: 2888, Description: "打开宝箱收获惊喜", Tiers: "[1,10,66,1314]", Sort: 24, Enabled: true}, + } + for _, item := range defaults { + var existing giftPO + err := db.Unscoped().Where("name = ?", item.Name).First(&existing).Error + if err == nil { + continue + } + if err != gorm.ErrRecordNotFound { + return err + } + if err := db.Create(&item).Error; err != nil { + // A concurrent migration/admin write may win the unique race. The + // named gift is present then, so leave its configuration untouched. + var raced giftPO + if db.Unscoped().Where("name = ?", item.Name).First(&raced).Error == nil { + continue + } + return err + } + } + return nil } diff --git a/internal/data/live/catalog.go b/internal/data/live/catalog.go index b7e3065..970bbc6 100644 --- a/internal/data/live/catalog.go +++ b/internal/data/live/catalog.go @@ -174,7 +174,8 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, 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+"%") + value := "%" + filter.Name + "%" + query = query.Where("live_rooms.name LIKE ? OR CAST(live_rooms.id AS CHAR) LIKE ?", value, value) } if filter.AnchorName != "" { value := "%" + filter.AnchorName + "%" @@ -186,6 +187,9 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, if filter.Enabled != nil { query = query.Where("live_rooms.enabled = ?", *filter.Enabled) } + if filter.CategoryEnabled != nil { + query = query.Where("live_categories.enabled = ?", *filter.CategoryEnabled) + } if filter.Status == bizlive.RoomStatusDisabled { query = query.Where("live_rooms.enabled = ?", false) } else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive { diff --git a/internal/data/live/catalog_test.go b/internal/data/live/catalog_test.go index e00e0e0..4e66814 100644 --- a/internal/data/live/catalog_test.go +++ b/internal/data/live/catalog_test.go @@ -74,3 +74,39 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) { t.Fatalf("room get scan result=%+v", got) } } + +func TestCatalogRepoFiltersRoomsByCategoryEnabled(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) + } + enabledCategory := categoryPO{Name: "Games", Enabled: true} + disabledCategory := categoryPO{Name: "Hidden", Enabled: false} + if err := db.Create(&enabledCategory).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&disabledCategory).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&disabledCategory).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + for _, room := range []*roomPO{ + {Name: "Visible", CategoryID: enabledCategory.ID, CoverURL: "visible.png", AnchorUserID: 2, Enabled: true}, + {Name: "Hidden", CategoryID: disabledCategory.ID, CoverURL: "hidden.png", AnchorUserID: 2, Enabled: true}, + } { + if err := db.Create(room).Error; err != nil { + t.Fatal(err) + } + } + + repo := NewCatalogRepo(catalogTestProvider{db: db}) + enabled := true + rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(rooms) != 1 || rooms[0].Name != "Visible" { + t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms) + } +} diff --git a/internal/data/live/follow.go b/internal/data/live/follow.go new file mode 100644 index 0000000..b424a1e --- /dev/null +++ b/internal/data/live/follow.go @@ -0,0 +1,76 @@ +package live + +import ( + "context" + "errors" + + "gorm.io/gorm" + bizlive "kra/internal/biz/live" +) + +type followPO struct { + ID uint `gorm:"primaryKey"` + UserID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"` + RoomID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"` + AnchorUserID uint `gorm:"not null;index"` + CreatedAt int64 `gorm:"autoCreateTime:milli"` +} + +func (followPO) TableName() string { return "live_room_follows" } + +type followRepo struct{ data Provider } + +func NewFollowRepo(data Provider) bizlive.FollowRepo { return &followRepo{data: data} } + +func (r *followRepo) Get(ctx context.Context, userID, roomID uint) (*bizlive.Follow, error) { + var row followPO + err := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, bizlive.ErrFollowNotFound + } + if err != nil { + return nil, err + } + return followToBiz(&row), nil +} + +func (r *followRepo) Create(ctx context.Context, item *bizlive.Follow) (*bizlive.Follow, error) { + var room roomPO + if err := r.db(ctx).First(&room, item.RoomID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, bizlive.ErrRoomNotFound + } + return nil, err + } + item.AnchorUserID = room.AnchorUserID + po := &followPO{UserID: item.UserID, RoomID: item.RoomID, AnchorUserID: item.AnchorUserID} + if err := r.db(ctx).Create(po).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return r.Get(ctx, item.UserID, item.RoomID) + } + return nil, err + } + return followToBiz(po), nil +} + +func (r *followRepo) Delete(ctx context.Context, userID, roomID uint) error { + result := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).Delete(&followPO{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return bizlive.ErrFollowNotFound + } + return nil +} + +func (r *followRepo) 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 followToBiz(row *followPO) *bizlive.Follow { + return &bizlive.Follow{ID: row.ID, UserID: row.UserID, RoomID: row.RoomID, AnchorUserID: row.AnchorUserID} +} diff --git a/internal/data/live/migrations.go b/internal/data/live/migrations.go index 974b28a..48ee29d 100644 --- a/internal/data/live/migrations.go +++ b/internal/data/live/migrations.go @@ -18,5 +18,7 @@ func Migrations() []migration.Step { 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 + }}, {ID: "202609220002_live_follow_schema", Migrate: func(db *gorm.DB) error { + return migration.CreateMissingTables(db, &followPO{}) }}} } diff --git a/internal/data/live/provider.go b/internal/data/live/provider.go index 048eb7d..fe480f0 100644 --- a/internal/data/live/provider.go +++ b/internal/data/live/provider.go @@ -9,5 +9,6 @@ import ( var ProviderSet = wire.NewSet( NewStreamRepo, NewCatalogRepo, + NewFollowRepo, wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)), ) diff --git a/internal/data/live/stream.go b/internal/data/live/stream.go index 30da988..03ad659 100644 --- a/internal/data/live/stream.go +++ b/internal/data/live/stream.go @@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI } state.stream.PublisherID = userID state.stream.PublisherOnline = true + // A new broadcast starts with a fresh in-room like counter. Keeping the + // stream state object is useful when viewers connected before the publisher, + // but likes must never leak into the next broadcast session. + state.stream.LikeCount = 0 + state.stream.StartedAt = time.Now() state.stream.UpdatedAt = time.Now() return clone(state.stream), nil } @@ -75,6 +80,7 @@ func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, use } state.stream.PublisherID = 0 state.stream.PublisherOnline = false + state.stream.StartedAt = time.Time{} state.stream.UpdatedAt = time.Now() if len(state.users) == 0 { delete(r.streams, streamID) @@ -121,6 +127,19 @@ func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, us return nil } +func (r *streamRepo) Like(_ context.Context, streamID string) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + state := r.streams[streamID] + if state == nil { + state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})} + r.streams[streamID] = state + } + state.stream.LikeCount++ + state.stream.UpdatedAt = time.Now() + return state.stream.LikeCount, nil +} + func clone(stream *bizlive.Stream) *bizlive.Stream { if stream == nil { return nil diff --git a/internal/data/livechat/chat.go b/internal/data/livechat/chat.go index 7a1c90e..8deeb36 100644 --- a/internal/data/livechat/chat.go +++ b/internal/data/livechat/chat.go @@ -2,7 +2,7 @@ package livechat import ( "context" - "errors" + "sync" "time" "gorm.io/gorm" @@ -12,9 +12,14 @@ import ( type Provider interface{ DB() *gorm.DB } -type repo struct{ data Provider } +type repo struct { + data Provider + mu sync.RWMutex + nextID uint + rooms map[uint][]*biz.Message +} -func NewRepo(data Provider) biz.Repo { return &repo{data: data} } +func NewRepo(data Provider) biz.Repo { return &repo{data: data, rooms: make(map[uint][]*biz.Message)} } type messagePO struct { ID uint `gorm:"primaryKey"` @@ -46,14 +51,16 @@ func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.M 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 + r.mu.RLock() + items := r.rooms[roomID] + if len(items) > limit { + items = items[len(items)-limit:] } - out := make([]*biz.Message, 0, len(rows)) - for i := len(rows) - 1; i >= 0; i-- { - out = append(out, toBiz(&rows[i])) + out := make([]*biz.Message, 0, len(items)) + for _, item := range items { + out = append(out, cloneMessage(item)) } + r.mu.RUnlock() return out, nil } @@ -65,11 +72,7 @@ func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content stri 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 + return r.append(&biz.Message{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}), nil } func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) { @@ -87,28 +90,28 @@ func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Mes 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 + return r.append(&biz.Message{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}), 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) append(item *biz.Message) *biz.Message { + r.mu.Lock() + defer r.mu.Unlock() + r.nextID++ + item.ID = r.nextID + item.CreatedAt = time.Now() + items := append(r.rooms[item.RoomID], cloneMessage(item)) + if len(items) > 100 { + items = append([]*biz.Message(nil), items[len(items)-100:]...) + } + r.rooms[item.RoomID] = items + return cloneMessage(item) +} + +func (r *repo) Clear(_ context.Context, roomID uint) error { + r.mu.Lock() + delete(r.rooms, roomID) + r.mu.Unlock() + return nil } func (r *repo) ensureRoom(ctx context.Context, roomID uint) error { @@ -136,3 +139,11 @@ func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) { 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} } + +func cloneMessage(item *biz.Message) *biz.Message { + if item == nil { + return nil + } + copy := *item + return © +} diff --git a/internal/data/livechat/chat_test.go b/internal/data/livechat/chat_test.go index 107e7ab..c9206b8 100644 --- a/internal/data/livechat/chat_test.go +++ b/internal/data/livechat/chat_test.go @@ -21,9 +21,6 @@ func openChatTestDB(t *testing.T) *gorm.DB { 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)`, @@ -37,6 +34,22 @@ func openChatTestDB(t *testing.T) *gorm.DB { return db } +func TestChatHistoryIsProcessLocalNotPersisted(t *testing.T) { + db := openChatTestDB(t) + first := NewRepo(testProvider{db: db}) + if _, err := first.CreateChat(context.Background(), 7, 3, "current session"); err != nil { + t.Fatal(err) + } + second := NewRepo(testProvider{db: db}) + items, err := second.ListRecent(context.Background(), 7, 100) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("chat leaked across runtime sessions: %+v", items) + } +} + func TestChatKeepsLatestHundredMessages(t *testing.T) { db := openChatTestDB(t) repo := NewRepo(testProvider{db: db}) @@ -65,3 +78,21 @@ func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) { t.Fatalf("gift message = %+v", item) } } + +func TestClearDropsRoomMessagesWhenBroadcastEnds(t *testing.T) { + db := openChatTestDB(t) + repo := NewRepo(testProvider{db: db}) + if _, err := repo.CreateChat(context.Background(), 7, 3, "before close"); err != nil { + t.Fatal(err) + } + if err := repo.Clear(context.Background(), 7); err != nil { + t.Fatal(err) + } + items, err := repo.ListRecent(context.Background(), 7, 100) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("messages remained after room close: %+v", items) + } +} diff --git a/internal/data/liveclient/account.go b/internal/data/liveclient/account.go index c16d256..c658e41 100644 --- a/internal/data/liveclient/account.go +++ b/internal/data/liveclient/account.go @@ -105,11 +105,18 @@ func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash func clientRoutes() []struct{ method, path string } { return []struct{ method, path string }{ + {method: "POST", path: "/live/client/auth/logout"}, {method: "GET", path: "/live/client/me"}, + {method: "GET", path: "/live/client/balance"}, {method: "PUT", path: "/live/client/me"}, {method: "PUT", path: "/live/client/me/password"}, {method: "POST", path: "/live/rooms/:id/gifts"}, + {method: "GET", path: "/live/rooms/:id/follow"}, + {method: "POST", path: "/live/rooms/:id/follow"}, + {method: "DELETE", path: "/live/rooms/:id/follow"}, {method: "POST", path: "/live/recharge/orders"}, + {method: "GET", path: "/live/recharge/orders/:orderNo"}, + {method: "GET", path: "/live/recharge/orders/:orderNo/status"}, } } diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 46a5278..61c05a3 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -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 != 22 { - t.Fatalf("migration versions = %d, want 22", versions) + if versions != 26 { + t.Fatalf("migration versions = %d, want 26", 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/recharge/recharge.go b/internal/data/recharge/recharge.go index 81e8c69..e24a861 100644 --- a/internal/data/recharge/recharge.go +++ b/internal/data/recharge/recharge.go @@ -179,7 +179,7 @@ func toBiz(v *row) *biz.Order { } 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"} + 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", "created_at_desc": "live_recharge_orders.created_at DESC", "id_asc": "live_recharge_orders.id ASC", "id_desc": "live_recharge_orders.id DESC"} if x := m[strings.ToLower(v)]; x != "" { return x } diff --git a/internal/data/system/surface_upgrade.go b/internal/data/system/surface_upgrade.go index 624e2e3..4c6b46f 100644 --- a/internal/data/system/surface_upgrade.go +++ b/internal/data/system/surface_upgrade.go @@ -206,6 +206,36 @@ func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetM }) } +// GrantAuthorityAPIs idempotently grants an explicit API set to one authority. +// Keeping this helper in data/system preserves ownership of the Casbin storage +// shape while allowing module migrations to add narrowly scoped client routes. +func GrantAuthorityAPIs(db *gorm.DB, authorityID uint, apis []platformmodule.API) error { + if db == nil || authorityID == 0 || len(apis) == 0 || !db.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + for _, api := range apis { + method := strings.ToUpper(strings.TrimSpace(api.Method)) + path := strings.TrimSpace(api.Path) + if method == "" || path == "" { + continue + } + exists, err := policyExists(tx, authorityID, path, method) + if err != nil { + return err + } + if exists { + continue + } + rule := newPolicyRule(authorityID, path, method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + return nil + }) +} + // GrantMenuAPIs makes a visible administration menu usable by granting only // its explicitly supplied API set to authorities that already own the menu. func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error { diff --git a/internal/integration/live/server.go b/internal/integration/live/server.go index e787337..1fef4dd 100644 --- a/internal/integration/live/server.go +++ b/internal/integration/live/server.go @@ -34,6 +34,7 @@ type Manager struct { peers map[*melody.Session]*peer observers map[*melody.Session]struct{} nextSub atomic.Uint64 + nextGuest atomic.Uint64 stop chan struct{} stopOnce sync.Once } @@ -53,16 +54,19 @@ type publishedTrack struct { } 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 + session *melody.Session + manager *Manager + room *room + streamID string + userID uint + role string + authenticated bool + subscriberID string + pcMu sync.RWMutex + pc *webrtc.PeerConnection + closing bool + lastBeat atomic.Int64 + cleanup sync.Once } type message struct { @@ -210,20 +214,28 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) { m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"}) return } - userID, ok := sessionUserID(session) - if !ok { + userID, authenticated := sessionUserID(session) + if role == "publisher" && !authenticated { m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"}) return } + if role == "subscriber" && !authenticated { + userID = m.anonymousViewerID() + } 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 } + } else if err := m.authorizeRoomSubscriber(streamID); err != nil { + m.send(session, "JoinRejected", map[string]any{"code": "ROOM_UNAVAILABLE", "message": err.Error()}) + return + } + r := m.getOrCreateRoom(streamID) + if role == "publisher" { r.mu.RLock() occupied := r.publisher != nil r.mu.RUnlock() @@ -235,7 +247,10 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) { 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} + if roomID, ok := roomIDFromStream(streamID); ok { + _ = m.chat.Clear(context.Background(), roomID) + } + p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: true} p.lastBeat.Store(time.Now().UnixMilli()) r.mu.Lock() r.publisher = p @@ -244,7 +259,6 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) { 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 @@ -269,7 +283,7 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) { 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 := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: authenticated, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)} p.lastBeat.Store(time.Now().UnixMilli()) r.mu.Lock() r.viewers[p] = struct{}{} @@ -278,14 +292,17 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) { 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) + publicUserID := userID + if !authenticated { + publicUserID = 0 + } + m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID, "mode": "pion-sfu"}) 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.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID}) } m.broadcastStreamList() } @@ -296,6 +313,10 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) { m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"}) return } + if !p.authenticated { + m.send(session, "Error", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required for chat"}) + 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"}) @@ -309,23 +330,6 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) { 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. @@ -388,6 +392,23 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error { return nil } +func (m *Manager) authorizeRoomSubscriber(streamID string) error { + if strings.HasPrefix(streamID, "room-") { + id, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64) + if err != nil || id == 0 { + return errors.New("invalid live room stream id") + } + if _, err = m.roomCatalog.GetPublic(context.Background(), uint(id)); err != nil { + return errors.New("live room is unavailable") + } + } + stream, err := m.streams.Get(context.Background(), streamID) + if err != nil || stream == nil || !stream.PublisherOnline { + return errors.New("publisher is offline") + } + return nil +} + func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) { p := m.peer(session) if p == nil || (p.role != "publisher" && p.role != "subscriber") { @@ -398,20 +419,22 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio 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 + oldPC, installed := p.replacePeerConnection(pc) + if !installed { + _ = pc.Close() + return + } pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { - if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { - m.closePeer(p, "ice_"+state.String()) - } + m.handleICEConnectionStateChange(p, pc, state) }) + if oldPC != nil { + _ = oldPC.Close() + } if p.role == "publisher" { pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { m.onPublisherTrack(p, track) @@ -448,6 +471,13 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio } } +func (m *Manager) handleICEConnectionStateChange(p *peer, pc *webrtc.PeerConnection, state webrtc.ICEConnectionState) { + if state != webrtc.ICEConnectionStateFailed && state != webrtc.ICEConnectionStateClosed { + return + } + m.closePeerConnection(p, pc, "ice_"+state.String()) +} + func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) { codec := remote.Codec().RTPCodecCapability local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID) @@ -514,15 +544,26 @@ func (m *Manager) heartbeatLoop() { } func (m *Manager) closePeer(p *peer, reason string) { + m.closePeerConnection(p, nil, reason) +} + +func (m *Manager) closePeerConnection(p *peer, candidate *webrtc.PeerConnection, reason string) { if p == nil { return } + pc, ok := p.beginClose(candidate) + if !ok { + return + } p.cleanup.Do(func() { - if p.pc != nil { - _ = p.pc.Close() + if pc != nil { + _ = pc.Close() } if p.role == "publisher" { _ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID) + if roomID, ok := roomIDFromStream(p.streamID); ok { + _ = m.chat.Clear(context.Background(), roomID) + } p.room.mu.Lock() tracks := make([]*publishedTrack, 0, len(p.room.tracks)) for _, track := range p.room.tracks { @@ -566,6 +607,36 @@ func (m *Manager) closePeer(p *peer, reason string) { }) } +func (p *peer) replacePeerConnection(next *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) { + p.pcMu.Lock() + defer p.pcMu.Unlock() + if p.closing { + return nil, false + } + previous := p.pc + p.pc = next + return previous, true +} + +func (p *peer) hasPeerConnection(candidate *webrtc.PeerConnection) bool { + p.pcMu.RLock() + current := p.pc + p.pcMu.RUnlock() + return candidate != nil && current == candidate +} + +func (p *peer) beginClose(candidate *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) { + p.pcMu.Lock() + defer p.pcMu.Unlock() + if p.closing || candidate != nil && p.pc != candidate { + return nil, false + } + p.closing = true + current := p.pc + p.pc = nil + return current, true +} + func (m *Manager) getOrCreateRoom(streamID string) *room { m.mu.Lock() defer m.mu.Unlock() @@ -685,6 +756,12 @@ func sessionUserID(session *melody.Session) (uint, bool) { } } +func (m *Manager) anonymousViewerID() uint { + // Allocate downward from the maximum uint so anonymous connections never + // share the zero ID and cannot collide with ordinary auto-increment users. + return ^uint(0) - uint(m.nextGuest.Add(1)) +} + func peerStreamID(m *Manager, session *melody.Session) string { if p := m.peer(session); p != nil { return p.streamID diff --git a/internal/integration/live/server_test.go b/internal/integration/live/server_test.go new file mode 100644 index 0000000..8c9b66d --- /dev/null +++ b/internal/integration/live/server_test.go @@ -0,0 +1,201 @@ +package live + +import ( + "context" + "sync" + "testing" + + "github.com/olahol/melody" + "github.com/pion/webrtc/v4" + + bizlive "kra/internal/biz/live" + platformws "kra/pkg/websocket" +) + +type liveHubStub struct { + mu sync.Mutex + messages map[*melody.Session][][]byte +} + +func (h *liveHubStub) Enabled() bool { return true } +func (h *liveHubStub) Broadcast([]byte) error { return nil } +func (h *liveHubStub) BroadcastBinary([]byte) error { return nil } +func (h *liveHubStub) Sessions() ([]*platformws.Session, error) { return nil, nil } +func (h *liveHubStub) Len() int { return 0 } +func (h *liveHubStub) SendBinary(*platformws.Session, []byte) error { return nil } +func (h *liveHubStub) OnMessage(func(*platformws.Session, []byte)) {} +func (h *liveHubStub) OnBinaryMessage(func(*platformws.Session, []byte)) {} +func (h *liveHubStub) OnConnect(func(*platformws.Session)) {} +func (h *liveHubStub) OnDisconnect(func(*platformws.Session)) {} +func (h *liveHubStub) Send(session *platformws.Session, message []byte) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.messages == nil { + h.messages = make(map[*melody.Session][][]byte) + } + h.messages[session] = append(h.messages[session], append([]byte(nil), message...)) + return nil +} + +type liveCatalogStub struct { + bizlive.CatalogRepo + room *bizlive.Room + err error +} + +func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) { + if s.err != nil { + return nil, s.err + } + copy := *s.room + return ©, nil +} + +func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) { + return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil +} + +type liveStreamStub struct { + bizlive.StreamRepo + mu sync.Mutex + streams map[string]*bizlive.Stream + users map[uint]struct{} + registerCalls int +} + +func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) { + s.mu.Lock() + defer s.mu.Unlock() + stream := s.streams[streamID] + if stream == nil { + return &bizlive.Stream{StreamID: streamID}, nil + } + copy := *stream + return ©, nil +} + +func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) { + s.mu.Lock() + defer s.mu.Unlock() + items := make([]*bizlive.Stream, 0, len(s.streams)) + for _, stream := range s.streams { + if stream.PublisherOnline { + copy := *stream + items = append(items, ©) + } + } + return items, nil +} +func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.registerCalls++ + if s.users == nil { + s.users = make(map[uint]struct{}) + } + if _, exists := s.users[userID]; exists { + return nil, bizlive.ErrSubscriberExists + } + s.users[userID] = struct{}{} + return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil +} + +func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager { + streamUC := bizlive.NewStreamUsecase(streams) + return &Manager{ + hub: &liveHubStub{}, + streams: streamUC, + roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC), + rooms: make(map[string]*room), + peers: make(map[*melody.Session]*peer), + observers: make(map[*melody.Session]struct{}), + stop: make(chan struct{}), + } +} + +func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) { + streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}} + manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams) + first := &melody.Session{Keys: map[string]any{}} + second := &melody.Session{Keys: map[string]any{}} + + manager.join(first, joinPayload{Role: "subscriber", StreamID: "room-7"}) + manager.join(second, joinPayload{Role: "subscriber", StreamID: "room-7"}) + + p1, p2 := manager.peer(first), manager.peer(second) + if p1 == nil || p2 == nil { + t.Fatalf("anonymous subscribers did not join: first=%+v second=%+v", p1, p2) + } + if p1.authenticated || p2.authenticated || p1.userID == 0 || p2.userID == 0 || p1.userID == p2.userID { + t.Fatalf("anonymous identities are not unique: first=%d second=%d", p1.userID, p2.userID) + } + if len(streams.users) != 2 { + t.Fatalf("subscriber count = %d, want 2", len(streams.users)) + } +} + +func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) { + manager := newAnonymousTestManager(&liveCatalogStub{err: bizlive.ErrRoomNotFound}, &liveStreamStub{}) + session := &melody.Session{Keys: map[string]any{}} + + manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-999999"}) + + if manager.peer(session) != nil || len(manager.rooms) != 0 { + t.Fatalf("unknown room created runtime state: peers=%d rooms=%d", len(manager.peers), len(manager.rooms)) + } +} + +func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) { + streams := &liveStreamStub{} + manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams) + session := &melody.Session{Keys: map[string]any{}} + + manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"}) + + if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 { + t.Fatalf("offline room created runtime state: peers=%d rooms=%d users=%d registrations=%d", len(manager.peers), len(manager.rooms), len(streams.users), streams.registerCalls) + } +} + +func TestAnonymousPublisherIsRejected(t *testing.T) { + manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, Enabled: true}}, &liveStreamStub{}) + session := &melody.Session{Keys: map[string]any{}} + + manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"}) + + if manager.peer(session) != nil { + t.Fatal("anonymous publisher joined") + } +} + +func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) { + oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + defer oldPC.Close() + newPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + defer newPC.Close() + + p := &peer{pc: oldPC} + replaced, installed := p.replacePeerConnection(newPC) + if !installed || replaced != oldPC { + t.Fatalf("replaced peer connection = %p, want %p", replaced, oldPC) + } + manager := &Manager{} + manager.handleICEConnectionStateChange(p, oldPC, webrtc.ICEConnectionStateClosed) + + cleanupAvailable := false + p.cleanup.Do(func() { cleanupAvailable = true }) + if !cleanupAvailable { + t.Fatal("superseded peer connection consumed peer cleanup") + } + if !p.hasPeerConnection(newPC) { + t.Fatal("replacement peer connection is no longer active") + } +} + +var _ platformws.Hub = (*liveHubStub)(nil) diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index f8be8d2..3ccd9f2 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 22 { - t.Fatalf("module migrations = %d, want 22", len(got)) + if got := catalog.MigrationSteps(); len(got) != 26 { + t.Fatalf("module migrations = %d, want 26", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 11 || apis != 50 { - t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis) + if menus != 11 || apis != 69 { + t.Fatalf("admin surface = %d menus/%d APIs, want 11/69", 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 index 33e3665..e1d9d85 100644 --- a/internal/modules/live/definition.go +++ b/internal/modules/live/definition.go @@ -39,10 +39,24 @@ func Definition() module.Definition { steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error { return datasystem.RemoveAdminMenus(db, "liveDashboard") }}) + steps = append(steps, migration.Step{ID: "202609220004_live_client_policies", Migrate: func(db *gorm.DB) error { + // Existing live-client accounts do not pass through the registration + // path after an upgrade, so add newly introduced client endpoints to + // their role without replacing any administrator-managed policies. + return datasystem.GrantAuthorityAPIs(db, 1000, []module.API{ + {Method: "POST", Path: "/live/client/auth/logout"}, + {Method: "GET", Path: "/live/client/balance"}, + {Method: "GET", Path: "/live/rooms/:id/follow"}, + {Method: "POST", Path: "/live/rooms/:id/follow"}, + {Method: "DELETE", Path: "/live/rooms/:id/follow"}, + {Method: "GET", Path: "/live/recharge/orders/:orderNo"}, + {Method: "GET", Path: "/live/recharge/orders/:orderNo/status"}, + }) + }}) 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", "创建充值订单"}} + defs := []struct{ m, p, d string }{{"GET", "/live/streams", "获取在线直播列表"}, {"GET", "/live/streams/:streamId", "获取直播间状态"}, {"GET", "/live/categories", "获取直播分类"}, {"GET", "/live/rooms", "分页获取热门直播间"}, {"GET", "/live/rooms/:id", "获取直播间详情"}, {"POST", "/live/rooms/:id/like", "直播间点赞"}, {"GET", "/live/rooms/:id/follow", "查询关注状态"}, {"POST", "/live/rooms/:id/follow", "关注直播间"}, {"DELETE", "/live/rooms/:id/follow", "取消关注"}, {"GET", "/live/rooms/:id/messages", "获取直播间聊天记录"}, {"GET", "/live/gifts", "获取可用礼物"}, {"GET", "/live/recharge/providers", "获取可用充值支付渠道"}, {"GET", "/live/recharge/plans", "获取充值档位"}, {"POST", "/live/client/auth/login", "直播用户登录"}, {"POST", "/live/client/auth/register", "直播用户注册"}, {"POST", "/live/client/auth/logout", "直播用户退出"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"GET", "/live/client/balance", "获取钻石余额"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"GET", "/live/recharge/orders/:orderNo", "查询我的充值订单"}, {"GET", "/live/recharge/orders/:orderNo/status", "查询我的充值支付状态"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}, {"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", "保存全站设置"}} 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}) diff --git a/internal/modules/live/definition_test.go b/internal/modules/live/definition_test.go new file mode 100644 index 0000000..ce0f4a2 --- /dev/null +++ b/internal/modules/live/definition_test.go @@ -0,0 +1,71 @@ +package live + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestLiveClientPolicyMigrationAddsNewPrivateRoutes(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.Exec(`CREATE TABLE casbin_rule ( + id integer primary key autoincrement, + ptype text, + v0 text, + v1 text, + v2 text, + v3 text, + v4 text, + v5 text + )`).Error; err != nil { + t.Fatal(err) + } + + var migrate func(*gorm.DB) error + for _, step := range Definition().Migrations { + if step.ID == "202609220004_live_client_policies" { + migrate = step.Migrate + break + } + } + if migrate == nil { + t.Fatal("live client policy migration is missing") + } + if err = migrate(db); err != nil { + t.Fatal(err) + } + if err = migrate(db); err != nil { + t.Fatalf("policy migration is not idempotent: %v", err) + } + + want := []struct{ method, path string }{ + {method: "POST", path: "/live/client/auth/logout"}, + {method: "GET", path: "/live/client/balance"}, + {method: "GET", path: "/live/rooms/:id/follow"}, + {method: "POST", path: "/live/rooms/:id/follow"}, + {method: "DELETE", path: "/live/rooms/:id/follow"}, + {method: "GET", path: "/live/recharge/orders/:orderNo"}, + {method: "GET", path: "/live/recharge/orders/:orderNo/status"}, + } + for _, route := range want { + var count int64 + if err = db.Table("casbin_rule").Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "1000", route.path, route.method).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("policy %s %s count = %d, want 1", route.method, route.path, count) + } + } + + var publicProviderPolicies int64 + if err = db.Table("casbin_rule").Where("v0 = ? AND v1 = ?", "1000", "/live/recharge/providers").Count(&publicProviderPolicies).Error; err != nil { + t.Fatal(err) + } + if publicProviderPolicies != 0 { + t.Fatalf("public provider discovery unexpectedly received a client policy: %d", publicProviderPolicies) + } +} diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 9c290d7..9b1dc1d 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -84,17 +84,24 @@ 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}, + "POST /live/rooms/:id/like": {group: "宇宙直播", description: "直播间点赞", public: true}, + "GET /live/rooms/:id/follow": {group: "宇宙直播", description: "查询关注状态"}, + "POST /live/rooms/:id/follow": {group: "宇宙直播", description: "关注主播"}, + "DELETE /live/rooms/:id/follow": {group: "宇宙直播", description: "取消关注"}, "GET /live/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true}, "GET /live/gifts": {group: "宇宙直播", description: "获取礼物", public: true}, + "GET /live/recharge/providers": {group: "宇宙直播", description: "获取可用充值支付渠道", public: true}, "GET /live/recharge/plans": {group: "宇宙直播", description: "获取充值档位", public: true}, + "GET /live/recharge/orders/:orderNo": {group: "宇宙直播", description: "查询我的充值订单"}, + "GET /live/recharge/orders/:orderNo/status": {group: "宇宙直播", description: "查询我的充值支付状态"}, "GET /live/settings": {group: "宇宙直播", description: "获取直播设置", public: true}, "GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"}, + "GET /live/client/balance": {group: "宇宙直播", description: "获取直播用户钻石余额"}, "GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"}, "GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"}, "GET /live/admin/rooms": {group: "宇宙直播", description: "查询直播间"}, @@ -114,6 +121,7 @@ var routes = map[string]routeValue{ "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}, + "POST /live/client/auth/logout": {group: "宇宙直播", description: "直播用户退出", audit: 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}, diff --git a/internal/routecatalog/live_contract_test.go b/internal/routecatalog/live_contract_test.go new file mode 100644 index 0000000..efa9403 --- /dev/null +++ b/internal/routecatalog/live_contract_test.go @@ -0,0 +1,57 @@ +package routecatalog + +import "testing" + +func TestLiveClientRouteVisibilityContract(t *testing.T) { + public := []struct{ method, path string }{ + {"GET", "/live/streams"}, + {"GET", "/live/streams/:streamId"}, + {"GET", "/live/categories"}, + {"GET", "/live/rooms"}, + {"GET", "/live/rooms/:id"}, + {"POST", "/live/rooms/:id/like"}, + {"GET", "/live/rooms/:id/messages"}, + {"GET", "/live/gifts"}, + {"GET", "/live/recharge/providers"}, + {"GET", "/live/recharge/plans"}, + {"POST", "/live/client/auth/login"}, + {"POST", "/live/client/auth/register"}, + } + for _, route := range public { + descriptor, ok := Lookup(route.method, route.path) + if !ok || !descriptor.Public { + t.Errorf("%s %s must be public: found=%v descriptor=%+v", route.method, route.path, ok, descriptor) + } + } + + private := []struct{ method, path string }{ + {"GET", "/live/client/me"}, + {"GET", "/live/client/balance"}, + {"POST", "/live/client/auth/logout"}, + {"GET", "/live/rooms/:id/follow"}, + {"POST", "/live/rooms/:id/follow"}, + {"DELETE", "/live/rooms/:id/follow"}, + {"POST", "/live/rooms/:id/gifts"}, + {"POST", "/live/recharge/orders"}, + {"GET", "/live/recharge/orders/:orderNo"}, + {"GET", "/live/recharge/orders/:orderNo/status"}, + } + for _, route := range private { + descriptor, ok := Lookup(route.method, route.path) + if !ok || descriptor.Public { + t.Errorf("%s %s must require login: found=%v descriptor=%+v", route.method, route.path, ok, descriptor) + } + } +} + +func TestLiveMoneyChangingRoutesAreAudited(t *testing.T) { + for _, route := range []struct{ method, path string }{ + {"POST", "/live/rooms/:id/gifts"}, + {"POST", "/live/recharge/orders"}, + {"POST", "/live/client/auth/logout"}, + } { + if !ShouldAudit(route.method, route.path) { + t.Errorf("%s %s must be audited", route.method, route.path) + } + } +} diff --git a/internal/server/gin.go b/internal/server/gin.go index 6e902a3..b70ede1 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -79,20 +79,21 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access c.Status(http.StatusNotFound) return } - if !middleware.AuthenticateWebSocket(c, auth) { - if wsLogger != nil { - wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed", - "mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(), - "has_token", hasToken, "origin", c.GetHeader("Origin")) - } - return - } - claims := middleware.Claims(c) keys := map[string]any{ - "request_id": contextString(c, "request_id"), + "request_id": contextString(c, "request_id"), + "authenticated": false, } - if claims != nil { - keys["user_id"] = claims.ID + // Watching is public. A valid token enriches the connection; a stale + // token is cleared and continues as anonymous so it cannot block video. + if hasToken { + if authenticated := middleware.AuthenticateWebSocketOptional(c, auth); !authenticated && wsLogger != nil { + wsLogger.InfoContext(c.Request.Context(), "websocket token unavailable; continuing anonymously", + "mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin")) + } + if claims := middleware.Claims(c); claims != nil { + keys["user_id"] = claims.ID + keys["authenticated"] = true + } } if wsLogger != nil { wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection", diff --git a/internal/server/handler/gift.go b/internal/server/handler/gift.go index dcfe5d9..6ecb043 100644 --- a/internal/server/handler/gift.go +++ b/internal/server/handler/gift.go @@ -1,7 +1,9 @@ package handler import ( + "errors" "github.com/gin-gonic/gin" + bizgift "kra/internal/biz/gift" "kra/internal/service/dto" service "kra/internal/service/gift" ) @@ -136,6 +138,16 @@ func (h *Gift) Send(c *gin.Context) { } v, e := h.s.Send(c.Request.Context(), claims.ID, id, in) if e != nil { + var insufficient *bizgift.InsufficientBalanceError + if errors.As(e, &insufficient) { + Write(c, CodeError, gin.H{ + "reason": "insufficient_balance", + "required": insufficient.Required, + "balance": insufficient.Balance, + "missing": insufficient.Missing, + }, e.Error()) + return + } businessFail(c, e) return } diff --git a/internal/server/handler/live.go b/internal/server/handler/live.go index 301411e..b2f2792 100644 --- a/internal/server/handler/live.go +++ b/internal/server/handler/live.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "fmt" "net/http" bizlive "kra/internal/biz/live" @@ -14,10 +15,89 @@ import ( type Live struct { service *liveservice.StreamService catalog *liveservice.CatalogService + follows *liveservice.FollowService } -func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live { - return &Live{service: service, catalog: catalog} +func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService, follows *liveservice.FollowService) *Live { + return &Live{service: service, catalog: catalog, follows: follows} +} + +func (h *Live) FollowStatus(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + followed, err := h.follows.Status(c.Request.Context(), claims.ID, id) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, gin.H{"followed": followed}) +} + +func (h *Live) Follow(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + followed, err := h.follows.Follow(c.Request.Context(), claims.ID, id) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, gin.H{"followed": followed}) +} + +func (h *Live) Unfollow(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + if err := h.follows.Unfollow(c.Request.Context(), claims.ID, id); err != nil { + businessFail(c, err) + return + } + OKWithData(c, gin.H{"followed": false}) +} + +func (h *Live) Like(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + // Do not create runtime stream state for arbitrary room IDs. The stream + // repository is process-local, so validating the catalog first also keeps + // bogus likes from accumulating forever. + room, err := h.catalog.GetRoom(c.Request.Context(), id) + if err != nil { + businessFail(c, err) + return + } + if !room.Enabled || room.Status != bizlive.RoomStatusLive { + businessFail(c, bizlive.ErrRoomNotLive) + return + } + count, err := h.service.Like(c.Request.Context(), fmt.Sprintf("room-%d", id)) + if err != nil { + businessFail(c, err) + return + } + OKWithData(c, gin.H{"likeCount": count}) } func (h *Live) List(c *gin.Context) { @@ -114,17 +194,27 @@ func (h *Live) PublicRooms(c *gin.Context) { in.PageSize = 20 } if in.OrderBy == "" { - in.OrderBy = "sort_asc" + in.OrderBy = "viewer_count_desc" } - enabled := true - in.Enabled = &enabled - items, total, e := h.catalog.ListRooms(c.Request.Context(), in) + items, total, e := h.catalog.ListPublicRooms(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) GetPublicRoom(c *gin.Context) { + id, ok := businessID(c) + if !ok { + return + } + v, e := h.catalog.GetPublicRoom(c.Request.Context(), id) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} func (h *Live) GetRoom(c *gin.Context) { id, ok := businessID(c) if !ok { @@ -203,15 +293,6 @@ func (h *Live) SaveRoomSort(c *gin.Context) { 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 { diff --git a/internal/server/handler/live_chat.go b/internal/server/handler/live_chat.go index 5a5a7e8..78f4a79 100644 --- a/internal/server/handler/live_chat.go +++ b/internal/server/handler/live_chat.go @@ -15,14 +15,14 @@ func (h *LiveChat) History(c *gin.Context) { if !ok { return } - in, ok := bindQuery[dto.LiveChatHistoryRequest](c) - if !ok { - return - } - items, err := h.service.History(c.Request.Context(), roomID, in.Limit) + // Chat is intentionally scoped to the current live connection. Do not + // replay rows persisted by an earlier viewer/session through HTTP. Still + // validate the room so callers receive a useful not-found response. + items, err := h.service.History(c.Request.Context(), roomID, 1) if err != nil { businessFail(c, err) return } - OKWithData(c, gin.H{"list": items}) + _ = items + OKWithData(c, gin.H{"list": []*dto.LiveChatMessageResponse{}}) } diff --git a/internal/server/handler/live_client.go b/internal/server/handler/live_client.go index 49b8554..4001177 100644 --- a/internal/server/handler/live_client.go +++ b/internal/server/handler/live_client.go @@ -58,6 +58,20 @@ func (h *LiveClient) Me(c *gin.Context) { OKWithData(c, result) } +func (h *LiveClient) Balance(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + result, err := h.service.Balance(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 { diff --git a/internal/server/handler/recharge.go b/internal/server/handler/recharge.go index d90d267..6ced69f 100644 --- a/internal/server/handler/recharge.go +++ b/internal/server/handler/recharge.go @@ -9,6 +9,14 @@ import ( type Recharge struct{ s *service.Service } func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} } +func (h *Recharge) Providers(c *gin.Context) { + v, e := h.s.Providers(c.Request.Context()) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, gin.H{"list": v}) +} func (h *Recharge) Plans(c *gin.Context) { v, e := h.s.Plans(c.Request.Context()) if e != nil { @@ -48,6 +56,34 @@ func (h *Recharge) Get(c *gin.Context) { } OKWithData(c, v) } + +func (h *Recharge) GetMine(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + v, e := h.s.GetForUser(c.Request.Context(), claims.ID, c.Param("orderNo")) + if e != nil { + businessFail(c, e) + return + } + OKWithData(c, v) +} + +func (h *Recharge) PaymentStatus(c *gin.Context) { + claims := Claims(c) + if claims == nil { + NoAuth(c, "请登录") + return + } + v, e := h.s.PaymentStatus(c.Request.Context(), claims.ID, 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 { diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index 6f6e2ea..9373e05 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -43,6 +43,33 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool { return authenticate(c, auth, true) } +// AuthenticateWebSocketOptional enriches a public WebSocket connection when +// a valid token is available. Missing, expired, malformed, or revoked tokens +// fall back to an anonymous session; invalid cookie state is cleared so a +// stale login cannot prevent public live viewing. +func AuthenticateWebSocketOptional(c *gin.Context, auth TokenAuthenticator) bool { + token := RequestToken(c, true) + if token == "" || auth == nil { + return false + } + value, err, _ := refreshTokens.Do(token, func() (any, error) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), tokenAuthTimeout) + defer cancel() + return auth.AuthenticateToken(ctx, token) + }) + if err != nil { + httpx.SetCookie(c, "x-token", "", -1) + return false + } + authentication, ok := value.(*system.TokenAuthentication) + if !ok || authentication == nil || authentication.Claims == nil { + httpx.SetCookie(c, "x-token", "", -1) + return false + } + applyTokenAuthentication(c, authentication) + return true +} + func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool { token := RequestToken(c, allowQueryToken) if token == "" { @@ -74,13 +101,17 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) httpx.NoAuth(c, "无法处理此token") return false } + applyTokenAuthentication(c, authentication) + return true +} + +func applyTokenAuthentication(c *gin.Context, authentication *system.TokenAuthentication) { if authentication.Refreshed != nil { c.Header("new-token", authentication.Refreshed.Value) c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10)) httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds())) } c.Set(claimsKey, authentication.Claims) - return true } // RequestToken extracts the authentication token accepted by HTTP handlers. diff --git a/internal/server/middleware/auth_test.go b/internal/server/middleware/auth_test.go index f7b4a9a..9156925 100644 --- a/internal/server/middleware/auth_test.go +++ b/internal/server/middleware/auth_test.go @@ -6,6 +6,7 @@ import ( "kra/internal/biz/system" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -79,3 +80,36 @@ func TestAuthenticateWebSocketSupportsBearerHeaderAndReportsInvalidToken(t *test t.Fatalf("invalid token status=%d, want %d", invalidRecorder.Code, http.StatusUnauthorized) } } + +func TestAuthenticateWebSocketOptionalFallsBackToAnonymous(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := &tokenAuthenticatorStub{err: system.ErrTokenExpired} + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = httptest.NewRequest(http.MethodGet, "/ws", nil) + context.Request.AddCookie(&http.Cookie{Name: "x-token", Value: "expired-token"}) + + if AuthenticateWebSocketOptional(context, stub) { + t.Fatal("expired token was treated as authenticated") + } + if Claims(context) != nil { + t.Fatalf("anonymous fallback retained claims: %#v", Claims(context)) + } + if recorder.Code == http.StatusUnauthorized { + t.Fatalf("optional authentication interrupted public handshake: body=%s", recorder.Body.String()) + } + if cookie := recorder.Header().Get("Set-Cookie"); !strings.Contains(cookie, "x-token=") || !strings.Contains(cookie, "Max-Age=0") { + t.Fatalf("expired token cookie was not cleared: %q", cookie) + } +} + +func TestAuthenticateWebSocketOptionalStoresValidClaims(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := &tokenAuthenticatorStub{claims: &system.AuthClaims{ID: 9}} + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest(http.MethodGet, "/ws?token=valid-token", nil) + + if !AuthenticateWebSocketOptional(context, stub) || Claims(context) == nil || Claims(context).ID != 9 { + t.Fatalf("valid optional token was not applied: claims=%#v", Claims(context)) + } +} diff --git a/internal/server/router/live.go b/internal/server/router/live.go index 61a4bd0..6808b72 100644 --- a/internal/server/router/live.go +++ b/internal/server/router/live.go @@ -8,11 +8,13 @@ import ( 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) + public.GET("/live/rooms/:id", h.GetPublicRoom) + public.POST("/live/rooms/:id/like", h.Like) + private.GET("/live/rooms/:id/follow", h.FollowStatus) + private.POST("/live/rooms/:id/follow", h.Follow) + private.DELETE("/live/rooms/:id/follow", h.Unfollow) private.GET("/live/admin/categories", h.ListCategories) private.POST("/live/admin/categories", h.CreateCategory) private.PUT("/live/admin/categories/:id", h.UpdateCategory) diff --git a/internal/server/router/live_client.go b/internal/server/router/live_client.go index 6dd2bfa..65e9742 100644 --- a/internal/server/router/live_client.go +++ b/internal/server/router/live_client.go @@ -5,10 +5,12 @@ import ( "kra/internal/server/handler" ) -func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) { +func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient, session *handler.Session) { public.POST("/live/client/auth/login", h.Login) public.POST("/live/client/auth/register", h.Register) + private.POST("/live/client/auth/logout", session.Logout) private.GET("/live/client/me", h.Me) + private.GET("/live/client/balance", h.Balance) private.PUT("/live/client/me", h.UpdateProfile) private.PUT("/live/client/me/password", h.ChangePassword) } diff --git a/internal/server/router/recharge.go b/internal/server/router/recharge.go index 403a7ff..e49f281 100644 --- a/internal/server/router/recharge.go +++ b/internal/server/router/recharge.go @@ -6,7 +6,10 @@ import ( ) func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) { + public.GET("/live/recharge/providers", h.Providers) public.GET("/live/recharge/plans", h.Plans) + private.GET("/live/recharge/orders/:orderNo", h.GetMine) + private.GET("/live/recharge/orders/:orderNo/status", h.PaymentStatus) private.PUT("/live/admin/recharge/plans", h.SavePlans) private.GET("/live/admin/orders", h.List) private.GET("/live/admin/orders/:orderNo", h.Get) diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 3461c9f..c577f36 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En 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() { RegisterLiveClient(public, private, r.handlers.LiveClient, r.handlers.Session) }, func() { RegisterGift(public, private, r.handlers.Gift) }, func() { RegisterRecharge(public, private, r.handlers.Recharge) }, func() { RegisterAsset(private, r.handlers.Asset) }, diff --git a/internal/server/websocket_auth_test.go b/internal/server/websocket_auth_test.go index a850a80..9296a16 100644 --- a/internal/server/websocket_auth_test.go +++ b/internal/server/websocket_auth_test.go @@ -30,7 +30,7 @@ func (s *websocketAuthStub) AuthenticateToken(_ context.Context, token string) ( var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil) -func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) { +func TestWebSocketRouteAcceptsAnonymousAndAuthenticatedViewers(t *testing.T) { store := runtimeconfig.NewStore() raw, err := json.Marshal(map[string]any{"path": "/ws"}) if err != nil { @@ -46,26 +46,35 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) { auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}} engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws) - unauthorized := httptest.NewRecorder() - engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil)) - if unauthorized.Code != http.StatusUnauthorized { - t.Fatalf("unauthorized websocket status=%d body=%s", unauthorized.Code, unauthorized.Body.String()) - } - httpServer := httptest.NewServer(engine) defer httpServer.Close() - wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws?token=login-token" - connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil) + baseURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws" + anonymous, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL, nil) if response != nil && response.Body != nil { _ = response.Body.Close() } if err != nil { - t.Fatalf("authorized websocket handshake failed: %v", err) + t.Fatalf("anonymous websocket handshake failed: %v", err) } - if connection == nil { - t.Fatal("authorized websocket handshake returned nil connection") + if anonymous == nil { + t.Fatal("anonymous websocket handshake returned nil connection") } - _ = connection.Close() + _ = anonymous.Close() + if auth.got != "" { + t.Fatalf("anonymous websocket unexpectedly authenticated token=%q", auth.got) + } + + authenticated, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL+"?token=login-token", nil) + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + if err != nil { + t.Fatalf("authenticated websocket handshake failed: %v", err) + } + if authenticated == nil { + t.Fatal("authenticated websocket handshake returned nil connection") + } + _ = authenticated.Close() if auth.got != "login-token" { t.Fatalf("websocket auth token=%q, want login-token", auth.got) } diff --git a/internal/service/dto/live.go b/internal/service/dto/live.go index 51ade77..43b54cd 100644 --- a/internal/service/dto/live.go +++ b/internal/service/dto/live.go @@ -55,24 +55,27 @@ type LiveRoomRequest struct { 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"` + 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"` + ViewerCountText string `json:"viewerCountText"` + LikeCount int `json:"likeCount"` + StartedAt *time.Time `json:"startedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type LiveStatusRequest struct { Enabled bool `json:"enabled"` diff --git a/internal/service/dto/live_client.go b/internal/service/dto/live_client.go index 9bf32c4..dedd48b 100644 --- a/internal/service/dto/live_client.go +++ b/internal/service/dto/live_client.go @@ -9,6 +9,7 @@ type LiveClientLoginRequest struct { type LiveClientRegisterRequest struct { Username string `json:"username" binding:"required"` + Nickname string `json:"nickname"` Password string `json:"password" binding:"required"` ConfirmPassword string `json:"confirmPassword" binding:"required"` } @@ -34,6 +35,10 @@ type LiveClientAccountResponse struct { CreatedAt time.Time `json:"createdAt"` } +type LiveClientBalanceResponse struct { + DiamondBalance int64 `json:"diamondBalance"` +} + type LiveClientAuthResponse struct { User *LiveClientAccountResponse `json:"user"` Token string `json:"token"` diff --git a/internal/service/dto/live_client_test.go b/internal/service/dto/live_client_test.go new file mode 100644 index 0000000..6df7bab --- /dev/null +++ b/internal/service/dto/live_client_test.go @@ -0,0 +1,16 @@ +package dto + +import ( + "encoding/json" + "testing" +) + +func TestLiveClientRegisterRequestDecodesNickname(t *testing.T) { + var request LiveClientRegisterRequest + if err := json.Unmarshal([]byte(`{"username":"viewer","nickname":"直播用户","password":"password","confirmPassword":"password"}`), &request); err != nil { + t.Fatal(err) + } + if request.Nickname != "直播用户" { + t.Fatalf("nickname = %q", request.Nickname) + } +} diff --git a/internal/service/dto/recharge.go b/internal/service/dto/recharge.go index 973ca69..17b70a5 100644 --- a/internal/service/dto/recharge.go +++ b/internal/service/dto/recharge.go @@ -9,6 +9,10 @@ type RechargePlan struct { AmountCents int64 `json:"amountCents"` Enabled bool `json:"enabled"` } +type RechargeProvider struct { + ID string `json:"id"` + Name string `json:"name"` +} type RechargePlansRequest struct { Plans []*RechargePlan `json:"plans" binding:"required"` } diff --git a/internal/service/live/catalog.go b/internal/service/live/catalog.go index 7c83b0e..e263bea 100644 --- a/internal/service/live/catalog.go +++ b/internal/service/live/catalog.go @@ -76,6 +76,18 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR } return result, total, nil } +func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) { + enabled := true + items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: bizlive.RoomStatusLive, Enabled: &enabled, CategoryEnabled: &enabled}, 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 { @@ -83,6 +95,13 @@ func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomRes } return roomResponse(item), nil } +func (s *CatalogService) GetPublicRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) { + item, err := s.rooms.GetPublic(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} } @@ -113,5 +132,5 @@ 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} + 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, ViewerCountText: item.ViewerCountText, LikeCount: item.LikeCount, StartedAt: item.StartedAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt} } diff --git a/internal/service/live/follow.go b/internal/service/live/follow.go new file mode 100644 index 0000000..46177f2 --- /dev/null +++ b/internal/service/live/follow.go @@ -0,0 +1,32 @@ +package live + +import ( + "context" + "errors" + + bizlive "kra/internal/biz/live" +) + +type FollowService struct{ uc *bizlive.FollowUsecase } + +func NewFollowService(uc *bizlive.FollowUsecase) *FollowService { return &FollowService{uc: uc} } + +func (s *FollowService) Status(ctx context.Context, userID, roomID uint) (bool, error) { + _, err := s.uc.Get(ctx, userID, roomID) + if err == nil { + return true, nil + } + if errors.Is(err, bizlive.ErrFollowNotFound) { + return false, nil + } + return false, err +} + +func (s *FollowService) Follow(ctx context.Context, userID, roomID uint) (bool, error) { + _, err := s.uc.Follow(ctx, userID, roomID) + return err == nil, err +} + +func (s *FollowService) Unfollow(ctx context.Context, userID, roomID uint) error { + return s.uc.Unfollow(ctx, userID, roomID) +} diff --git a/internal/service/live/provider.go b/internal/service/live/provider.go index 20c9537..1787e0f 100644 --- a/internal/service/live/provider.go +++ b/internal/service/live/provider.go @@ -2,4 +2,4 @@ package live import "github.com/google/wire" -var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService) +var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService) diff --git a/internal/service/live/stream.go b/internal/service/live/stream.go index f5707bd..c13468c 100644 --- a/internal/service/live/stream.go +++ b/internal/service/live/stream.go @@ -30,3 +30,7 @@ func (s *StreamService) Get(ctx context.Context, streamID string) (*dto.LiveStre } return &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}, nil } + +func (s *StreamService) Like(ctx context.Context, streamID string) (int, error) { + return s.uc.Like(ctx, streamID) +} diff --git a/internal/service/liveclient/account.go b/internal/service/liveclient/account.go index 7b3b2e4..2a5b1c7 100644 --- a/internal/service/liveclient/account.go +++ b/internal/service/liveclient/account.go @@ -12,7 +12,7 @@ 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) + result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Nickname: in.Nickname, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent) if err != nil { return nil, err } @@ -35,6 +35,14 @@ func (s *Service) Me(ctx context.Context, userID uint) (*dto.LiveClientAccountRe return accountResponse(account), nil } +func (s *Service) Balance(ctx context.Context, userID uint) (*dto.LiveClientBalanceResponse, error) { + account, err := s.uc.Get(ctx, userID) + if err != nil { + return nil, err + } + return &dto.LiveClientBalanceResponse{DiamondBalance: account.DiamondBalance}, 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 { diff --git a/internal/service/recharge/providers_test.go b/internal/service/recharge/providers_test.go new file mode 100644 index 0000000..95d4f9e --- /dev/null +++ b/internal/service/recharge/providers_test.go @@ -0,0 +1,69 @@ +package recharge + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + integrationbiz "kra/internal/biz/integration" +) + +type providerConfigRepo struct { + configs []*integrationbiz.IntegrationConfig + err error +} + +func (r *providerConfigRepo) ListIntegrationConfigs(context.Context, string) ([]*integrationbiz.IntegrationConfig, error) { + return r.configs, r.err +} + +func (*providerConfigRepo) FindIntegrationConfig(context.Context, string, string) (*integrationbiz.IntegrationConfig, error) { + return nil, errors.New("not implemented") +} + +func (*providerConfigRepo) SaveIntegrationConfig(context.Context, *integrationbiz.IntegrationConfig) error { + return errors.New("not implemented") +} + +func (*providerConfigRepo) DeleteIntegrationConfig(context.Context, string, string) error { + return errors.New("not implemented") +} + +func TestProvidersOnlyReturnsEnabledSupportedPaymentConfigs(t *testing.T) { + repo := &providerConfigRepo{configs: []*integrationbiz.IntegrationConfig{ + {Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-v3", Enabled: true, Values: []byte(`{"api_key":"must-not-leak"}`)}, + {Kind: integrationbiz.IntegrationKindPayment, Provider: "alipay", Enabled: false}, + {Kind: integrationbiz.IntegrationKindPayment, Provider: "unknown-provider", Enabled: true}, + {Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-v3", Enabled: true}, + nil, + }} + integrations := integrationbiz.NewIntegrationConfigUsecase(repo, nil) + service := NewService(nil, integrations) + + providers, err := service.Providers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(providers) != 1 || providers[0].ID != "wechat-v3" || providers[0].Name == "" { + t.Fatalf("providers = %+v", providers) + } + encoded, err := json.Marshal(providers) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "must-not-leak") || strings.Contains(string(encoded), "api_key") { + t.Fatalf("provider response leaked integration config: %s", encoded) + } +} + +func TestProvidersPropagatesConfigReadFailure(t *testing.T) { + want := errors.New("load failed") + integrations := integrationbiz.NewIntegrationConfigUsecase(&providerConfigRepo{err: want}, nil) + service := NewService(nil, integrations) + + if _, err := service.Providers(context.Background()); !errors.Is(err, want) { + t.Fatalf("Providers() error = %v, want %v", err, want) + } +} diff --git a/internal/service/recharge/recharge.go b/internal/service/recharge/recharge.go index 23f5ca3..3092e87 100644 --- a/internal/service/recharge/recharge.go +++ b/internal/service/recharge/recharge.go @@ -2,16 +2,48 @@ package recharge import ( "context" + "sort" "time" + integrationbiz "kra/internal/biz/integration" bizpayment "kra/internal/biz/payment" biz "kra/internal/biz/recharge" "kra/internal/service/dto" ) -type Service struct{ uc *biz.Usecase } +type Service struct { + uc *biz.Usecase + integrations *integrationbiz.IntegrationConfigUsecase +} -func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } +func NewService(uc *biz.Usecase, integrations *integrationbiz.IntegrationConfigUsecase) *Service { + return &Service{uc: uc, integrations: integrations} +} + +func (s *Service) Providers(ctx context.Context) ([]*dto.RechargeProvider, error) { + configs, err := s.integrations.List(ctx, integrationbiz.IntegrationKindPayment) + if err != nil { + return nil, err + } + providers := make([]*dto.RechargeProvider, 0, len(configs)) + seen := make(map[string]struct{}, len(configs)) + for _, config := range configs { + if config == nil || !config.Enabled || !bizpayment.IsSupportedPaymentProvider(config.Provider) { + continue + } + definition, ok := integrationbiz.IntegrationDefinition(integrationbiz.IntegrationKindPayment, config.Provider) + if !ok { + continue + } + if _, exists := seen[definition.Provider]; exists { + continue + } + seen[definition.Provider] = struct{}{} + providers = append(providers, &dto.RechargeProvider{ID: definition.Provider, Name: definition.Name}) + } + sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID }) + return providers, nil +} func dates(a, b string) []time.Time { if a == "" || b == "" { @@ -76,6 +108,30 @@ func (s *Service) Get(ctx context.Context, no string) (*dto.RechargeOrderRespons return order(v), nil } +// GetForUser is the client-facing order lookup. It deliberately maps an +// order owned by another account to not-found so order numbers cannot be used +// to enumerate another user's payment or diamond balance details. +func (s *Service) GetForUser(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) { + if userID == 0 { + return nil, biz.ErrNotFound + } + item, err := s.uc.Get(ctx, no) + if err != nil { + return nil, err + } + if item.UserID != userID { + return nil, biz.ErrNotFound + } + return order(item), nil +} + +// PaymentStatus is an explicit alias for client polling while a provider +// payment is pending. The response includes both provider and fulfillment +// state through the normal recharge order DTO. +func (s *Service) PaymentStatus(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) { + return s.GetForUser(ctx, userID, no) +} + 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 { diff --git a/tmp/live_smoke/main.go b/tmp/live_smoke/main.go new file mode 100644 index 0000000..330b480 --- /dev/null +++ b/tmp/live_smoke/main.go @@ -0,0 +1,255 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/glebarez/sqlite" + "github.com/gorilla/websocket" + "gorm.io/gorm" +) + +type signalMessage struct { + Type string `json:"type"` + Payload map[string]any `json:"payload"` +} + +func main() { + if len(os.Args) < 2 { + log.Fatal("usage: live_smoke ...") + } + var err error + switch os.Args[1] { + case "seed": + err = seed(os.Args[2:]) + case "publisher": + err = publisher(os.Args[2:]) + case "wscheck": + err = wscheck(os.Args[2:]) + case "inspect": + err = inspect(os.Args[2:]) + default: + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + if err != nil { + log.Fatal(err) + } +} + +func openDB(path string) (*gorm.DB, error) { + return gorm.Open(sqlite.Open(path), &gorm.Config{}) +} + +func seed(args []string) error { + if len(args) != 2 { + return errors.New("seed requires ") + } + userID, err := strconv.ParseUint(args[1], 10, 64) + if err != nil || userID == 0 { + return errors.New("invalid user id") + } + db, err := openDB(args[0]) + if err != nil { + return err + } + if result := db.Exec("UPDATE sys_integration_configs SET enabled = ?, updated_at = ? WHERE kind = ? AND provider = ?", true, time.Now(), "websocket", "melody"); result.Error != nil { + return result.Error + } else if result.RowsAffected != 1 { + return fmt.Errorf("websocket config update affected %d rows", result.RowsAffected) + } + var categoryID uint + if err = db.Raw("SELECT id FROM live_categories WHERE enabled = ? ORDER BY sort, id LIMIT 1", true).Scan(&categoryID).Error; err != nil { + return err + } + if categoryID == 0 { + return errors.New("no enabled live category") + } + name := fmt.Sprintf("Smoke Live Room %d", userID) + if err = db.Exec("DELETE FROM live_rooms WHERE name = ?", name).Error; err != nil { + return err + } + if err = db.Exec(`INSERT INTO live_rooms + (name, category_id, cover_url, intro, sort, anchor_user_id, stream_url, replay_url, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + name, categoryID, "", "isolated live smoke test", 1, uint(userID), "", "", true, time.Now(), time.Now()).Error; err != nil { + return err + } + var roomID uint + if err = db.Raw("SELECT id FROM live_rooms WHERE name = ? ORDER BY id DESC LIMIT 1", name).Scan(&roomID).Error; err != nil { + return err + } + if roomID == 0 { + return errors.New("room insert did not return an id") + } + fmt.Printf("{\"roomId\":%d,\"categoryId\":%d}\n", roomID, categoryID) + return nil +} + +func publisher(args []string) error { + if len(args) != 3 { + return errors.New("publisher requires ") + } + token, err := cookieToken(args[1]) + if err != nil { + return err + } + connection, _, err := websocket.DefaultDialer.Dial(args[0]+"?token="+token, nil) + if err != nil { + return err + } + defer connection.Close() + if err = writeSignal(connection, "Join", map[string]any{"role": "publisher", "streamId": "room-" + args[2]}); err != nil { + return err + } + if _, err = readUntil(connection, "Joined", 5*time.Second); err != nil { + return err + } + fmt.Println("publisher joined") + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err = writeSignal(connection, "Heartbeat", map[string]any{"streamId": "room-" + args[2]}); err != nil { + return err + } + } + return nil +} + +func wscheck(args []string) error { + if len(args) != 3 { + return errors.New("wscheck requires ") + } + streamID := "room-" + args[2] + anonymous, _, err := websocket.DefaultDialer.Dial(args[0], nil) + if err != nil { + return fmt.Errorf("anonymous dial: %w", err) + } + defer anonymous.Close() + if err = writeSignal(anonymous, "Join", map[string]any{"role": "subscriber", "streamId": streamID}); err != nil { + return err + } + if _, err = readUntil(anonymous, "Joined", 5*time.Second); err != nil { + return fmt.Errorf("anonymous join: %w", err) + } + if err = writeSignal(anonymous, "ChatSend", map[string]any{"content": "anonymous must fail"}); err != nil { + return err + } + message, err := readUntil(anonymous, "Error", 5*time.Second) + if err != nil { + return fmt.Errorf("anonymous chat result: %w", err) + } + if message.Payload["code"] != "AUTH_REQUIRED" { + return fmt.Errorf("anonymous chat code=%v", message.Payload["code"]) + } + + token, err := cookieToken(args[1]) + if err != nil { + return err + } + authenticated, _, err := websocket.DefaultDialer.Dial(args[0]+"?token="+token, nil) + if err != nil { + return fmt.Errorf("authenticated dial: %w", err) + } + defer authenticated.Close() + if err = writeSignal(authenticated, "Join", map[string]any{"role": "subscriber", "streamId": streamID}); err != nil { + return err + } + if _, err = readUntil(authenticated, "Joined", 5*time.Second); err != nil { + return fmt.Errorf("authenticated join: %w", err) + } + content := "smoke chat 2026-09-22" + if err = writeSignal(authenticated, "ChatSend", map[string]any{"content": content}); err != nil { + return err + } + message, err = readUntil(authenticated, "ChatMessage", 5*time.Second) + if err != nil { + return fmt.Errorf("authenticated chat result: %w", err) + } + if message.Payload["content"] != content { + return fmt.Errorf("chat content=%v", message.Payload["content"]) + } + fmt.Println("anonymous join/auth rejection and authenticated chat broadcast passed") + return nil +} + +func inspect(args []string) error { + if len(args) != 3 { + return errors.New("inspect requires ") + } + db, err := openDB(args[0]) + if err != nil { + return err + } + userID, err := strconv.ParseUint(args[1], 10, 64) + if err != nil { + return err + } + roomID, err := strconv.ParseUint(args[2], 10, 64) + if err != nil { + return err + } + result := map[string]int64{} + queries := map[string]struct { + query string + args []any + }{ + "balance": {"SELECT diamond_balance FROM live_user_wallets WHERE user_id = ?", []any{userID}}, + "followCount": {"SELECT COUNT(*) FROM live_room_follows WHERE user_id = ? AND room_id = ?", []any{userID, roomID}}, + "giftSendCount": {"SELECT COUNT(*) FROM live_gift_sends WHERE user_id = ? AND room_id = ?", []any{userID, roomID}}, + "giftDiamonds": {"SELECT COALESCE(SUM(diamond_total), 0) FROM live_gift_sends WHERE user_id = ? AND room_id = ?", []any{userID, roomID}}, + "chatRows": {"SELECT COUNT(*) FROM live_chat_messages WHERE room_id = ?", []any{roomID}}, + "rechargeCount": {"SELECT COUNT(*) FROM live_recharge_orders WHERE user_id = ?", []any{userID}}, + } + for key, item := range queries { + var value int64 + if err = db.Raw(item.query, item.args...).Scan(&value).Error; err != nil { + return fmt.Errorf("%s: %w", key, err) + } + result[key] = value + } + encoded, _ := json.Marshal(result) + fmt.Println(string(encoded)) + return nil +} + +func writeSignal(connection *websocket.Conn, kind string, payload map[string]any) error { + return connection.WriteJSON(signalMessage{Type: kind, Payload: payload}) +} + +func readUntil(connection *websocket.Conn, kind string, timeout time.Duration) (*signalMessage, error) { + if err := connection.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return nil, err + } + for { + var message signalMessage + if err := connection.ReadJSON(&message); err != nil { + return nil, err + } + if message.Type == kind { + return &message, nil + } + if message.Type == "JoinRejected" { + return nil, fmt.Errorf("join rejected: %v", message.Payload) + } + } +} + +func cookieToken(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) >= 7 && fields[5] == "x-token" { + return fields[6], nil + } + } + return "", errors.New("x-token cookie not found") +} diff --git a/web/src/modules/live/api/live.js b/web/src/modules/live/api/live.js index 5ec23e3..dcc152d 100644 --- a/web/src/modules/live/api/live.js +++ b/web/src/modules/live/api/live.js @@ -15,7 +15,7 @@ export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`, 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 getPublicRooms = (params) => get('/live/rooms', params) 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) diff --git a/web/src/modules/live/view/categories.vue b/web/src/modules/live/view/categories.vue index 4380506..8e1298b 100644 --- a/web/src/modules/live/view/categories.vue +++ b/web/src/modules/live/view/categories.vue @@ -4,6 +4,7 @@ + 查询重置 @@ -25,13 +26,13 @@ import { getUrl } from '@/utils/image' import ImageUploader from '../components/ImageUploader.vue' import { createCategory, deleteCategory, getCategories, updateCategory } from '../api/live' defineOptions({ name: 'LiveCategories' }) -const query = reactive({ name: '', enabled: undefined }), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref() +const query = reactive({ name: '', enabled: undefined, orderBy: 'sort_asc' }), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref() const { width } = useWindowSize() const compactTable = computed(() => width.value < 1400) const form = reactive({ name: '', icon: '', sort: 0, enabled: true }), rules = { name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] } const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || '')), format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-' -const load = async () => { loading.value = true; try { const res = await getCategories({ page: page.value, pageSize: pageSize.value, orderBy: 'sort_asc', ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } } -const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { name: '', enabled: undefined }); search() } +const load = async () => { loading.value = true; try { const res = await getCategories({ page: page.value, pageSize: pageSize.value, ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } } +const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { name: '', enabled: undefined, orderBy: 'sort_asc' }); search() } const openCreate = () => { editingId.value = 0; Object.assign(form, { name: '', icon: '', sort: 0, enabled: true }); drawer.value = true }, openEdit = (row) => { editingId.value = row.id; Object.assign(form, row); drawer.value = true } const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateCategory(editingId.value, form) : await createCategory(form); if (res.code === 0) { ElMessage.success('分类已保存'); drawer.value = false; load() } } finally { saving.value = false } } const toggle = async (row, enabled) => { const res = await updateCategory(row.id, { name: row.name, icon: row.icon, sort: row.sort, enabled }); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '分类已启用' : '分类已停用') } } diff --git a/web/src/modules/live/view/gifts.vue b/web/src/modules/live/view/gifts.vue index 38d1ba5..81ad49e 100644 --- a/web/src/modules/live/view/gifts.vue +++ b/web/src/modules/live/view/gifts.vue @@ -1,7 +1,7 @@