This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 53e8599bdd
commit 0a8ecbe09b
75 changed files with 2299 additions and 276 deletions
+10
View File
@@ -46,3 +46,13 @@ web/node_modules/
# Local reference sources and desktop-only tooling state # Local reference sources and desktop-only tooling state
/gva/ /gva/
/.claude/settings.local.json /.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/
+6 -3
View File
@@ -202,7 +202,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
categoryUsecase := live2.NewCategoryUsecase(catalogRepo) categoryUsecase := live2.NewCategoryUsecase(catalogRepo)
roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase) roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase)
catalogService := live3.NewCatalogService(categoryUsecase, roomUsecase) 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) repo := livechat.NewRepo(dataData)
usecase := livechat2.NewUsecase(repo) usecase := livechat2.NewUsecase(repo)
service := livechat3.NewService(usecase) service := livechat3.NewService(usecase)
@@ -223,7 +226,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
cleanup() cleanup()
return nil, nil, err return nil, nil, err
} }
giftUsecase := gift2.NewUsecase(giftRepo, manager) giftUsecase := gift2.NewUsecase(giftRepo, manager, roomUsecase)
giftService := gift3.NewService(giftUsecase) giftService := gift3.NewService(giftUsecase)
handlerGift := handler.NewGift(giftService) handlerGift := handler.NewGift(giftService)
rechargeRepo := recharge.NewRepo(dataData) rechargeRepo := recharge.NewRepo(dataData)
@@ -234,7 +237,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
cleanup() cleanup()
return nil, nil, err return nil, nil, err
} }
rechargeService := recharge3.NewService(rechargeUsecase) rechargeService := recharge3.NewService(rechargeUsecase, integrationConfigUsecase)
handlerRecharge := handler.NewRecharge(rechargeService) handlerRecharge := handler.NewRecharge(rechargeService)
assetRepo := asset.NewRepo(dataData) assetRepo := asset.NewRepo(dataData)
assetUsecase := asset2.NewUsecase(assetRepo) assetUsecase := asset2.NewUsecase(assetRepo)
+4 -3
View File
@@ -6,11 +6,12 @@ server:
data: data:
database: database:
driver: mysql 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 host: 127.0.0.1
port: "3306" port: "3306"
user: root user: root
password: Xu950329. password: ""
name: yzyzlive name: yzyzlive
config: charset=utf8mb4&parseTime=True&loc=Local config: charset=utf8mb4&parseTime=True&loc=Local
path: "" path: ""
@@ -88,7 +89,7 @@ admin:
router_prefix: "" router_prefix: ""
jwt: jwt:
# Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally. # Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally.
signing_key: e8e37061-b137-4438-ad48-30f85bf8c68b signing_key: ""
expires_time: 604800s expires_time: 604800s
buffer_time: 86400s buffer_time: 86400s
issuer: kra issuer: kra
+26 -1
View File
@@ -35,6 +35,7 @@ pipeline {
REGISTRY_NAMESPACE = 'live' REGISTRY_NAMESPACE = 'live'
BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend" BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend"
FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend" FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend"
LIVEWEB_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/live-web"
DEPLOY_HOST = 'root@192.168.2.226' DEPLOY_HOST = 'root@192.168.2.226'
BIN_NAME = 'kratos-admin' BIN_NAME = 'kratos-admin'
APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀 APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀
@@ -67,6 +68,7 @@ pipeline {
echo "Deploy mode: ${params.DEPLOY_MODE}" echo "Deploy mode: ${params.DEPLOY_MODE}"
echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}" echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
echo "Frontend: ${env.FRONTEND_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') { stage('Build Images') {
when { when {
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' } expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
@@ -119,6 +138,10 @@ pipeline {
--tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \ --tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \
--tag ${FRONTEND_IMAGE}:latest \ --tag ${FRONTEND_IMAGE}:latest \
web 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 ${BACKEND_IMAGE}:latest
docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}
docker push ${FRONTEND_IMAGE}:latest docker push ${FRONTEND_IMAGE}:latest
docker push ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG}
docker push ${LIVEWEB_IMAGE}:latest
docker logout "${REGISTRY_HOST}" docker logout "${REGISTRY_HOST}"
''' '''
} }
@@ -154,7 +179,7 @@ pipeline {
post { post {
success { 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}"
} }
} }
} }
+25 -14
View File
@@ -1,8 +1,8 @@
# live.fuhua91.com 宝塔站点配置 # live.fuhua91.com 宝塔站点配置(与 2026-09-21 服务器生效版本一致)
# 路径规划: # 路径规划:
# / -> live 用户端前端(静态目录,或替换为反代) # / -> live 用户端前端(PC 直播页,live-web 容器 9503)
# /admin/ -> 管理后台(frontend 容器 9501,nginx 去前缀转发) # /admin/ -> 管理后台(frontend 容器 9501,rewrite 去前缀转发)
# /api -> 后端 API/WebSocket(backend 容器 9502) # /api -> 后端 API/WebSocket(backend 容器 9502,rewrite 去前缀转发)
# 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。 # 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。
map $http_upgrade $live_connection_upgrade { map $http_upgrade $live_connection_upgrade {
@@ -63,8 +63,9 @@ server
return 404; return 404;
} }
# 后端 API / WebSocket(容器内后端无 /api 前缀,proxy_pass 尾斜杠自动去掉) # 后端 API / WebSocket(rewrite 去掉 /api 前缀,后端路由无 /api)
location ^~ /api { location ^~ /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:9502; proxy_pass http://127.0.0.1:9502;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -76,6 +77,14 @@ server
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_send_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 构建) # 管理后台(frontend 容器;rewrite 去掉 /admin 前缀,前端已按 /admin/ base 构建)
location = /admin { location = /admin {
@@ -91,17 +100,19 @@ server
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# live 用户端前端(根路径) # live 用户端前端(PC 直播页,live-web 容器,主域名根路径)
# 方式一: 静态文件放 /www/wwwroot/live.fuhua91.com/live-web 后使用当前配置
# 方式二: 若 live 前端也是 docker 容器,把下面两行换成:
# proxy_pass http://127.0.0.1:<live前端端口>;
# proxy_set_header Host $http_host; 以及上面 /admin/ 块中的其余 proxy_set_header
location / { location / {
root /www/wwwroot/live.fuhua91.com/live-web; proxy_pass http://127.0.0.1:9503;
try_files $uri $uri/ /index.html; 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)$" ) { if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403; return 403;
} }
+29 -11
View File
@@ -14,25 +14,27 @@ set -euo pipefail
# ---------- 可按需调整的参数 ---------- # ---------- 可按需调整的参数 ----------
REGISTRY="harbor.fuhua91.com" REGISTRY="harbor.fuhua91.com"
PROJECT="live" PROJECT="live"
# Harbor 机器人账号(也可通过环境变量 HARBOR_USER / HARBOR_PASSWORD 覆盖) # Harbor 机器人账号(密码只通过 HARBOR_PASSWORD 环境变量提供)
HARBOR_USER_DEFAULT='robot$live+live' HARBOR_USER_DEFAULT='robot$live+live'
HARBOR_PASSWORD_DEFAULT='R4p6ieacIogXE779eL1HXhCpA4bx4SA7'
BASE_DIR="/www/wwwroot/live.fuhua91.com" BASE_DIR="/www/wwwroot/live.fuhua91.com"
CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf
LOG_DIR="$BASE_DIR/log" # 后端日志目录 -> 容器内 /app/logs LOG_DIR="$BASE_DIR/log" # 后端日志目录 -> 容器内 /app/logs
UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads
FRONTEND_PORT=9501 # 前端 nginx 容器 FRONTEND_PORT=9501 # admin 管理后台 nginx 容器
BACKEND_PORT=9502 # 后端 API/WebSocket 容器 BACKEND_PORT=9502 # 后端 API/WebSocket 容器
LIVEWEB_PORT=9503 # live 用户端(PC 直播页) nginx 容器
NETWORK="live-net" NETWORK="live-net"
BACKEND_NAME="live-backend" BACKEND_NAME="live-backend"
FRONTEND_NAME="live-frontend" FRONTEND_NAME="live-frontend"
LIVEWEB_NAME="live-web"
# -------------------------------------- # --------------------------------------
TAG="${1:-latest}" TAG="${1:-latest}"
BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG" BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG"
FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG" FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG"
LIVEWEB_IMAGE="$REGISTRY/$PROJECT/live-web:$TAG"
echo "==> 部署版本: $TAG" echo "==> 部署版本: $TAG"
@@ -47,21 +49,27 @@ if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
exit 1 exit 1
fi fi
# 3. 登录 Harbor(优先使用环境变量 HARBOR_USER / HARBOR_PASSWORD) # 3. 登录 Harbor;未传密码时复用服务器现有的 Docker 登录态
HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}" HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}"
HARBOR_PASSWORD="${HARBOR_PASSWORD:-$HARBOR_PASSWORD_DEFAULT}" HARBOR_LOGGED_IN=false
echo "==> 登录 $REGISTRY" if [ -n "${HARBOR_PASSWORD:-}" ]; then
echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null 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. 拉取最新镜像 # 4. 拉取最新镜像
echo "==> 拉取镜像" echo "==> 拉取镜像"
docker pull "$BACKEND_IMAGE" docker pull "$BACKEND_IMAGE"
docker pull "$FRONTEND_IMAGE" docker pull "$FRONTEND_IMAGE"
docker pull "$LIVEWEB_IMAGE"
# 5. 重建容器 # 5. 重建容器
echo "==> 重启容器" echo "==> 重启容器"
docker network create "$NETWORK" >/dev/null 2>&1 || true 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) 运行,日志/上传目录需要写权限 # 容器内进程以 uid 1000 (kra) 运行,日志/上传目录需要写权限
chown -R 1000:1000 "$LOG_DIR" "$UPLOADS_DIR" 2>/dev/null || \ 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" \ -p "$FRONTEND_PORT:80" \
"$FRONTEND_IMAGE" "$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 "==> 部署完成" 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 ""
echo "前端容器: http://<服务器IP>:$FRONTEND_PORT" echo "admin 前端容器: http://<服务器IP>:$FRONTEND_PORT"
echo "后端容器: http://<服务器IP>:$BACKEND_PORT" echo "后端容器: http://<服务器IP>:$BACKEND_PORT"
echo "live 前端容器: http://<服务器IP>:$LIVEWEB_PORT"
echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。" echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。"
+22 -9
View File
@@ -4,7 +4,7 @@
## WebSocket ## WebSocket
连接现有 WebSocket 地址(默认 `/ws`),握手必须携带 GVA JWT: 连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力:
```text ```text
wss://host/ws?token=<x-token> wss://host/ws?token=<x-token>
@@ -16,7 +16,7 @@ wss://host/ws?token=<x-token>
{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}} {"type":"Join","payload":{"role":"publisher","streamId":"room-1"}}
``` ```
角色支持 `publisher`、`subscriber`、`observer`。主播和观众完成 Join 后,浏览器发送 SDP offer: 角色支持 `publisher`、`subscriber`、`observer`。`subscriber` 可匿名加入,`publisher` 必须登录且只能发布自己拥有的启用直播间。主播和观众完成 Join 后,浏览器发送 SDP offer:
```json ```json
{"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}} {"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}}
@@ -34,7 +34,6 @@ wss://host/ws?token=<x-token>
- `SubscriberLeft` - `SubscriberLeft`
- `StreamListUpdate` - `StreamListUpdate`
- `Kicked` - `Kicked`
- `ChatHistory`:加入房间后返回最近 100 条聊天和礼物消息
- `ChatMessage`:普通聊天广播 - `ChatMessage`:普通聊天广播
- `GiftMessage`:送礼成功后的礼物广播 - `GiftMessage`:送礼成功后的礼物广播
@@ -44,7 +43,7 @@ wss://host/ws?token=<x-token>
{"type":"ChatSend","payload":{"content":"大家好"}} {"type":"ChatSend","payload":{"content":"大家好"}}
``` ```
聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。 聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。消息只保存在当前服务进程内,每个房间最多 100 条;加入房间不会回放进入前的消息,开播、关播或服务重启都会清空该房间聊天。
主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。 主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。
@@ -55,34 +54,48 @@ wss://host/ws?token=<x-token>
```text ```text
GET /live/streams GET /live/streams
GET /live/streams/:streamId GET /live/streams/:streamId
GET /api/streams # 旧客户端兼容别名
GET /live/categories 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 GET /live/rooms/:id
POST /live/rooms/:id/like
GET /live/rooms/:id/messages?limit=100 GET /live/rooms/:id/messages?limit=100
GET /live/gifts 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 客户端账号接口: PC 客户端账号接口:
```text ```text
POST /live/client/auth/register POST /live/client/auth/register
POST /live/client/auth/login POST /live/client/auth/login
POST /live/client/auth/logout
GET /live/client/me GET /live/client/me
GET /live/client/balance
PUT /live/client/me PUT /live/client/me
PUT /live/client/me/password PUT /live/client/me/password
``` ```
客户端账号使用系统的 `sys_users` 表,注册时创建普通直播角色并初始化 100 钻石。注册和修改密码都会返回新的 JWT 登录态。 客户端账号使用系统的 `sys_users` 表。注册昵称可选,未填写时使用用户名,填写时限制为 2–16 个字符;新账号创建普通直播角色并初始化 100 钻石,历史账号缺少钱包时也补建 100 钻石。注册和修改密码都会返回新的 JWT 登录态。
登录后可调用: 登录后可调用:
```text ```text
GET /live/rooms/:id/follow
POST /live/rooms/:id/follow
DELETE /live/rooms/:id/follow
POST /live/rooms/:id/gifts POST /live/rooms/:id/gifts
GET /live/recharge/plans
POST /live/recharge/orders POST /live/recharge/orders
GET /live/recharge/orders/:orderNo
GET /live/recharge/orders/:orderNo/status
``` ```
点赞不要求登录,点赞数只在当前进程的直播运行态中累加;关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。
创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识: 创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识:
```json ```json
@@ -96,7 +109,7 @@ POST /live/recharge/orders
充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。 充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。
礼物数量必须使用礼物接口返回的 `tiers`,扣款、礼物流水和钱包余额在同一事务内完成;成功后通过 `GiftMessage` 广播给房间内在线用户。 礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。
全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。 全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。
+1
View File
@@ -0,0 +1 @@
{"creation_time":"2026-09-21T06:30:55.339Z","expires_at":-1,"id":7,"name":"robot$live+live","secret":"R4p6ieacIogXE779eL1HXhCpA4bx4SA7"}
+40 -3
View File
@@ -2,10 +2,14 @@ package gift
import ( import (
"context" "context"
kratoserrors "github.com/go-kratos/kratos/v3/errors" "fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
kratoserrors "github.com/go-kratos/kratos/v3/errors"
bizlive "kra/internal/biz/live"
) )
var ( var (
@@ -17,6 +21,24 @@ var (
ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent") 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 { type Gift struct {
ID uint ID uint
Name, Icon, Description string Name, Icon, Description string
@@ -72,13 +94,21 @@ type Repo interface {
type Notifier interface { type Notifier interface {
GiftSent(context.Context, *Send) GiftSent(context.Context, *Send)
} }
type roomAvailability interface {
IsLive(context.Context, uint) (bool, error)
}
type Usecase struct { type Usecase struct {
repo Repo repo Repo
notifier Notifier notifier Notifier
rooms roomAvailability
} }
func NewUsecase(repo Repo, notifier Notifier) *Usecase { func NewUsecase(repo Repo, notifier Notifier, rooms *bizlive.RoomUsecase) *Usecase {
return &Usecase{repo: repo, notifier: notifier} 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) { func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) {
f.Name = strings.TrimSpace(f.Name) 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 { if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 {
return nil, ErrSendNotAllowed 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) result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity)
if err != nil { if err != nil {
return nil, err return nil, err
+49
View File
@@ -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)
}
}
+68
View File
@@ -2,6 +2,8 @@ package live
import ( import (
"context" "context"
"fmt"
"sort"
"strings" "strings"
"time" "time"
@@ -22,6 +24,7 @@ var (
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled") ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting") 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") 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") ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
) )
@@ -52,6 +55,9 @@ type Room struct {
Enabled bool Enabled bool
Status string Status string
ViewerCount int ViewerCount int
ViewerCountText string
LikeCount int
StartedAt *time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -67,6 +73,7 @@ type RoomFilter struct {
CategoryID uint CategoryID uint
Status string Status string
Enabled *bool Enabled *bool
CategoryEnabled *bool
CreatedAt []time.Time CreatedAt []time.Time
} }
@@ -174,6 +181,17 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
matched = append(matched, room) 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)) total := int64(len(matched))
if options.Offset >= len(matched) { if options.Offset >= len(matched) {
return []*Room{}, total, nil 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 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) { func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
if err := validateRoom(room); err != nil { if err := validateRoom(room); err != nil {
return nil, err return nil, err
@@ -258,17 +302,41 @@ func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room
for _, room := range rooms { for _, room := range rooms {
if !room.Enabled { if !room.Enabled {
room.Status = RoomStatusDisabled room.Status = RoomStatusDisabled
room.ViewerCountText = formatViewerCount(room.ViewerCount)
continue continue
} }
room.Status = RoomStatusOffline room.Status = RoomStatusOffline
if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline { if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline {
room.Status = RoomStatusLive room.Status = RoomStatusLive
room.ViewerCount = stream.SubscriberCount 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 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 roomStreamID(id uint) string { return "room-" + formatUint(id) }
func formatUint(value uint) string { func formatUint(value uint) string {
+160
View File
@@ -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 &copy, 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, &copy)
}
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 &copy, 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)
}
}
}
+58
View File
@@ -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)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire" import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase) var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase)
+11
View File
@@ -27,6 +27,8 @@ type Stream struct {
PublisherID uint PublisherID uint
PublisherOnline bool PublisherOnline bool
SubscriberCount int SubscriberCount int
LikeCount int
StartedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -37,6 +39,7 @@ type StreamRepo interface {
UnregisterPublisher(context.Context, string, uint) error UnregisterPublisher(context.Context, string, uint) error
RegisterSubscriber(context.Context, string, uint) (*Stream, error) RegisterSubscriber(context.Context, string, uint) (*Stream, error)
UnregisterSubscriber(context.Context, string, uint) error UnregisterSubscriber(context.Context, string, uint) error
Like(context.Context, string) (int, error)
} }
type StreamUsecase struct{ repo StreamRepo } 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) 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 { func normalizeStreamID(value string) string {
return strings.TrimSpace(value) return strings.TrimSpace(value)
} }
+10
View File
@@ -5,6 +5,15 @@ import (
"testing" "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 { type testRepo struct {
streams map[string]*Stream streams map[string]*Stream
} }
@@ -41,6 +50,7 @@ func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, e
return nil, nil return nil, nil
} }
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return 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) { func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
repo := &testRepo{streams: make(map[string]*Stream)} repo := &testRepo{streams: make(map[string]*Stream)}
+8
View File
@@ -53,6 +53,7 @@ type Repo interface {
ListRecent(context.Context, uint, int) ([]*Message, error) ListRecent(context.Context, uint, int) ([]*Message, error)
CreateChat(context.Context, uint, uint, string) (*Message, error) CreateChat(context.Context, uint, uint, string) (*Message, error)
CreateGift(context.Context, *GiftSnapshot) (*Message, error) CreateGift(context.Context, *GiftSnapshot) (*Message, error)
Clear(context.Context, uint) error
} }
type Usecase struct{ repo Repo } 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) 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)
}
+8 -2
View File
@@ -36,7 +36,7 @@ type Account struct {
} }
type RegisterInput struct { type RegisterInput struct {
Username, Password, ConfirmPassword string Username, Nickname, Password, ConfirmPassword string
} }
type LoginInput struct { 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 { if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword {
return nil, ErrInvalidAccount 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) config, err := uc.security.Current(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -95,7 +101,7 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+106
View File
@@ -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 = &copy
return &copy, 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(&registerSecurityRepoStub{}, nil, &registerSettingsStub{}, nil)
auth := system.NewAuthenticationUsecase(nil, security, &registerIssuerStub{}, nil)
return NewUsecase(repo, security, auth, nil)
}
func TestRegisterUsesTrimmedNickname(t *testing.T) {
repo := &registerRepoStub{}
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 := &registerRepoStub{}
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 := &registerRepoStub{}
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")
}
})
}
}
+1 -1
View File
@@ -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} 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 { 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 != "" { if x := m[v]; x != "" {
return x return x
} }
+9
View File
@@ -9,5 +9,14 @@ import (
func Migrations() []migration.Step { func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error { return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{}) 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
}}} }}}
} }
+5 -15
View File
@@ -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 { if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
return bizgift.ErrSendNotAllowed 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} wallet := ledger.WalletPO{UserID: userID}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil {
return err return err
} }
total := gift.Price * int64(quantity) total := gift.Price * int64(quantity)
if wallet.DiamondBalance < total { if wallet.DiamondBalance < total {
return bizgift.ErrInsufficientBalance return &bizgift.InsufficientBalanceError{Required: total, Balance: wallet.DiamondBalance, Missing: total - wallet.DiamondBalance}
} }
rate := int64(10) rate := int64(10)
var setting ledger.SettingPO var setting ledger.SettingPO
@@ -292,18 +282,18 @@ func mapError(err error) error {
return err return err
} }
func giftOrder(v string) string { 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 != "" { if x := m[strings.ToLower(v)]; x != "" {
return x return x
} }
return "sort ASC,created_at DESC" return "sort ASC,created_at DESC,id ASC"
} }
func sendOrder(v string) string { 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 != "" { if x := m[strings.ToLower(v)]; x != "" {
return x return x
} }
return "created_at DESC" return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
} }
func newNumber(prefix string) string { func newNumber(prefix string) string {
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000) return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
+36 -5
View File
@@ -67,12 +67,12 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) { if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil {
t.Fatalf("unsupported quantity error = %v", err) t.Fatalf("custom quantity should be accepted: %v", err)
} }
var before ledger.WalletPO var before ledger.WalletPO
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 { if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 {
t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err) 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 { if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -81,7 +81,38 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
var sends int64 var sends int64
db.Where("user_id = ?", 1).First(&after) db.Where("user_id = ?", 1).First(&after)
db.Model(&ledger.GiftSendPO{}).Count(&sends) 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) 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)
}
}
+55 -1
View File
@@ -19,5 +19,59 @@ func Migrations() []migration.Step {
return nil 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 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
} }
+5 -1
View File
@@ -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 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") Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
if filter.Name != "" { 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 != "" { if filter.AnchorName != "" {
value := "%" + filter.AnchorName + "%" value := "%" + filter.AnchorName + "%"
@@ -186,6 +187,9 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
if filter.Enabled != nil { if filter.Enabled != nil {
query = query.Where("live_rooms.enabled = ?", *filter.Enabled) 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 { if filter.Status == bizlive.RoomStatusDisabled {
query = query.Where("live_rooms.enabled = ?", false) query = query.Where("live_rooms.enabled = ?", false)
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive { } else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
+36
View File
@@ -74,3 +74,39 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
t.Fatalf("room get scan result=%+v", got) 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)
}
}
+76
View File
@@ -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}
}
+2
View File
@@ -18,5 +18,7 @@ func Migrations() []migration.Step {
return nil 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 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{})
}}} }}}
} }
+1
View File
@@ -9,5 +9,6 @@ import (
var ProviderSet = wire.NewSet( var ProviderSet = wire.NewSet(
NewStreamRepo, NewStreamRepo,
NewCatalogRepo, NewCatalogRepo,
NewFollowRepo,
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)), wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
) )
+19
View File
@@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI
} }
state.stream.PublisherID = userID state.stream.PublisherID = userID
state.stream.PublisherOnline = true 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() state.stream.UpdatedAt = time.Now()
return clone(state.stream), nil return clone(state.stream), nil
} }
@@ -75,6 +80,7 @@ func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, use
} }
state.stream.PublisherID = 0 state.stream.PublisherID = 0
state.stream.PublisherOnline = false state.stream.PublisherOnline = false
state.stream.StartedAt = time.Time{}
state.stream.UpdatedAt = time.Now() state.stream.UpdatedAt = time.Now()
if len(state.users) == 0 { if len(state.users) == 0 {
delete(r.streams, streamID) delete(r.streams, streamID)
@@ -121,6 +127,19 @@ func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, us
return nil 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 { func clone(stream *bizlive.Stream) *bizlive.Stream {
if stream == nil { if stream == nil {
return nil return nil
+43 -32
View File
@@ -2,7 +2,7 @@ package livechat
import ( import (
"context" "context"
"errors" "sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -12,9 +12,14 @@ import (
type Provider interface{ DB() *gorm.DB } 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 { type messagePO struct {
ID uint `gorm:"primaryKey"` 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 { if err := r.ensureRoom(ctx, roomID); err != nil {
return nil, err return nil, err
} }
var rows []messagePO r.mu.RLock()
if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { items := r.rooms[roomID]
return nil, err if len(items) > limit {
items = items[len(items)-limit:]
} }
out := make([]*biz.Message, 0, len(rows)) out := make([]*biz.Message, 0, len(items))
for i := len(rows) - 1; i >= 0; i-- { for _, item := range items {
out = append(out, toBiz(&rows[i])) out = append(out, cloneMessage(item))
} }
r.mu.RUnlock()
return out, nil return out, nil
} }
@@ -65,11 +72,7 @@ func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content stri
if err != nil { if err != nil {
return nil, err return nil, err
} }
row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content} 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
if err = r.createAndPrune(ctx, row); err != nil {
return nil, err
}
return toBiz(row), nil
} }
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) { 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 == "" { if nickname == "" {
nickname = user.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} 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
if err = r.createAndPrune(ctx, row); err != nil {
return nil, err
}
return toBiz(row), nil
} }
func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error { func (r *repo) append(item *biz.Message) *biz.Message {
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { r.mu.Lock()
if err := tx.Create(row).Error; err != nil { defer r.mu.Unlock()
return err 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:]...)
} }
var boundary messagePO r.rooms[item.RoomID] = items
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error return cloneMessage(item)
if errors.Is(err, gorm.ErrRecordNotFound) { }
func (r *repo) Clear(_ context.Context, roomID uint) error {
r.mu.Lock()
delete(r.rooms, roomID)
r.mu.Unlock()
return nil return nil
}
if err != nil {
return err
}
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
})
} }
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error { 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 { 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} 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 &copy
}
+34 -3
View File
@@ -21,9 +21,6 @@ func openChatTestDB(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = db.AutoMigrate(&messagePO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{ for _, statement := range []string{
`CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`, `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)`, `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 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) { func TestChatKeepsLatestHundredMessages(t *testing.T) {
db := openChatTestDB(t) db := openChatTestDB(t)
repo := NewRepo(testProvider{db: db}) repo := NewRepo(testProvider{db: db})
@@ -65,3 +78,21 @@ func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
t.Fatalf("gift message = %+v", item) 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)
}
}
+7
View File
@@ -105,11 +105,18 @@ func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash
func clientRoutes() []struct{ method, path string } { func clientRoutes() []struct{ method, path string } {
return []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/me"},
{method: "GET", path: "/live/client/balance"},
{method: "PUT", path: "/live/client/me"}, {method: "PUT", path: "/live/client/me"},
{method: "PUT", path: "/live/client/me/password"}, {method: "PUT", path: "/live/client/me/password"},
{method: "POST", path: "/live/rooms/:id/gifts"}, {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: "POST", path: "/live/recharge/orders"},
{method: "GET", path: "/live/recharge/orders/:orderNo"},
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
} }
} }
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if versions != 22 { if versions != 26 {
t.Fatalf("migration versions = %d, want 22", versions) t.Fatalf("migration versions = %d, want 26", versions)
} }
var communicationRows []dataintegration.ConfigPO var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
+1 -1
View File
@@ -179,7 +179,7 @@ func toBiz(v *row) *biz.Order {
} }
func orderBy(v string) string { 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 != "" { if x := m[strings.ToLower(v)]; x != "" {
return x return x
} }
+30
View File
@@ -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 // GrantMenuAPIs makes a visible administration menu usable by granting only
// its explicitly supplied API set to authorities that already own the menu. // its explicitly supplied API set to authorities that already own the menu.
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error { func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
+112 -35
View File
@@ -34,6 +34,7 @@ type Manager struct {
peers map[*melody.Session]*peer peers map[*melody.Session]*peer
observers map[*melody.Session]struct{} observers map[*melody.Session]struct{}
nextSub atomic.Uint64 nextSub atomic.Uint64
nextGuest atomic.Uint64
stop chan struct{} stop chan struct{}
stopOnce sync.Once stopOnce sync.Once
} }
@@ -59,8 +60,11 @@ type peer struct {
streamID string streamID string
userID uint userID uint
role string role string
authenticated bool
subscriberID string subscriberID string
pcMu sync.RWMutex
pc *webrtc.PeerConnection pc *webrtc.PeerConnection
closing bool
lastBeat atomic.Int64 lastBeat atomic.Int64
cleanup sync.Once cleanup sync.Once
} }
@@ -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"}) m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"})
return return
} }
userID, ok := sessionUserID(session) userID, authenticated := sessionUserID(session)
if !ok { if role == "publisher" && !authenticated {
m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"}) m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"})
return return
} }
if role == "subscriber" && !authenticated {
userID = m.anonymousViewerID()
}
if existing := m.peer(session); existing != nil { if existing := m.peer(session); existing != nil {
m.closePeer(existing, "rejoin") m.closePeer(existing, "rejoin")
} }
r := m.getOrCreateRoom(streamID)
if role == "publisher" { if role == "publisher" {
if err := m.authorizeRoomPublisher(streamID, userID); err != nil { if err := m.authorizeRoomPublisher(streamID, userID); err != nil {
m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()}) m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()})
return 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() r.mu.RLock()
occupied := r.publisher != nil occupied := r.publisher != nil
r.mu.RUnlock() 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()}) m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
return 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()) p.lastBeat.Store(time.Now().UnixMilli())
r.mu.Lock() r.mu.Lock()
r.publisher = p r.publisher = p
@@ -244,7 +259,6 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
m.peers[session] = p m.peers[session] = p
m.mu.Unlock() m.mu.Unlock()
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "mode": "pion-sfu"}) 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.sendPublisherStatus(streamID, false, "publisher_waiting_for_offer")
m.broadcastStreamList() m.broadcastStreamList()
return 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()}) m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
return 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()) p.lastBeat.Store(time.Now().UnixMilli())
r.mu.Lock() r.mu.Lock()
r.viewers[p] = struct{}{} r.viewers[p] = struct{}{}
@@ -278,14 +292,17 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
m.mu.Lock() m.mu.Lock()
m.peers[session] = p m.peers[session] = p
m.mu.Unlock() m.mu.Unlock()
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": userID, "mode": "pion-sfu"}) publicUserID := userID
m.sendChatHistory(session, streamID) 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") m.sendPublisherStatusTo(session, streamID, ready, "publisher_offline")
if ready { if ready {
m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true}) m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true})
} }
if publisher := m.publisher(r); publisher != nil { 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() 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"}) m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"})
return 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) roomID, ok := roomIDFromStream(p.streamID)
if !ok { if !ok {
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"}) 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)) 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 // GiftSent implements gift.Notifier. Gift persistence and wallet debit have
// already committed; chat persistence is therefore best-effort and must never // already committed; chat persistence is therefore best-effort and must never
// turn a successful debit into a failed gift response. // turn a successful debit into a failed gift response.
@@ -388,6 +392,23 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
return nil 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) { func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) {
p := m.peer(session) p := m.peer(session)
if p == nil || (p.role != "publisher" && p.role != "subscriber") { 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"}) m.send(session, "Error", map[string]any{"code": "INVALID_SDP", "message": "sdp is required"})
return return
} }
if p.pc != nil {
_ = p.pc.Close()
}
pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()}) pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()})
if err != nil { if err != nil {
m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()}) m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()})
return return
} }
p.pc = pc oldPC, installed := p.replacePeerConnection(pc)
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { if !installed {
if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { _ = pc.Close()
m.closePeer(p, "ice_"+state.String()) return
} }
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
m.handleICEConnectionStateChange(p, pc, state)
}) })
if oldPC != nil {
_ = oldPC.Close()
}
if p.role == "publisher" { if p.role == "publisher" {
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
m.onPublisherTrack(p, track) 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) { func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) {
codec := remote.Codec().RTPCodecCapability codec := remote.Codec().RTPCodecCapability
local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID) 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) { 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 { if p == nil {
return return
} }
pc, ok := p.beginClose(candidate)
if !ok {
return
}
p.cleanup.Do(func() { p.cleanup.Do(func() {
if p.pc != nil { if pc != nil {
_ = p.pc.Close() _ = pc.Close()
} }
if p.role == "publisher" { if p.role == "publisher" {
_ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID) _ = 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() p.room.mu.Lock()
tracks := make([]*publishedTrack, 0, len(p.room.tracks)) tracks := make([]*publishedTrack, 0, len(p.room.tracks))
for _, track := range 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 { func (m *Manager) getOrCreateRoom(streamID string) *room {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() 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 { func peerStreamID(m *Manager, session *melody.Session) string {
if p := m.peer(session); p != nil { if p := m.peer(session); p != nil {
return p.streamID return p.streamID
+201
View File
@@ -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 &copy, 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 &copy, 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, &copy)
}
}
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)
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name) t.Fatalf("definition[%d] = %q, want %q", index, got, name)
} }
} }
if got := catalog.MigrationSteps(); len(got) != 22 { if got := catalog.MigrationSteps(); len(got) != 26 {
t.Fatalf("module migrations = %d, want 22", len(got)) t.Fatalf("module migrations = %d, want 26", len(got))
} }
var menus, apis int var menus, apis int
for _, definition := range catalog.Definitions { for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus) menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs) apis += len(definition.Surface.APIs)
} }
if menus != 11 || apis != 50 { if menus != 11 || apis != 69 {
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis) t.Fatalf("admin surface = %d menus/%d APIs, want 11/69", menus, apis)
} }
if got := catalog.DefaultTimedTasks(); len(got) != 2 { if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got)) t.Fatalf("default timed tasks = %d, want 2", len(got))
+15 -1
View File
@@ -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 { steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error {
return datasystem.RemoveAdminMenus(db, "liveDashboard") 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}} return module.Definition{Name: "live", Migrations: steps, Surface: module.Surface{Menus: menus, APIs: apis}}
} }
func APIs() []module.API { 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)) out := make([]module.API, 0, len(defs))
for _, v := range defs { for _, v := range defs {
out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d}) out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d})
+71
View File
@@ -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)
}
}
+9 -1
View File
@@ -84,17 +84,24 @@ var routes = map[string]routeValue{
"GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"}, "GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"},
"GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"}, "GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"},
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"}, "GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
"GET /api/streams": {group: "直播", description: "获取在线直播列表", public: true},
"GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true}, "GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true},
"GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true}, "GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true},
"GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true}, "GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true},
"GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true}, "GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true},
"GET /live/rooms/:id": {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/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true},
"GET /live/gifts": {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/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/settings": {group: "宇宙直播", description: "获取直播设置", public: true},
"GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"}, "GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"},
"GET /live/client/balance": {group: "宇宙直播", description: "获取直播用户钻石余额"},
"GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"}, "GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"},
"GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"}, "GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"},
"GET /live/admin/rooms": {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/recharge/orders": {group: "宇宙直播", description: "创建充值订单", audit: true},
"POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true}, "POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true},
"POST /live/client/auth/register": {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/categories/:id": {group: "宇宙直播", description: "更新直播分类", audit: true},
"PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true}, "PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true},
"PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true}, "PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true},
@@ -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)
}
}
}
+11 -10
View File
@@ -79,20 +79,21 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
c.Status(http.StatusNotFound) c.Status(http.StatusNotFound)
return 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{ keys := map[string]any{
"request_id": contextString(c, "request_id"), "request_id": contextString(c, "request_id"),
"authenticated": false,
} }
if claims != nil { // 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["user_id"] = claims.ID
keys["authenticated"] = true
}
} }
if wsLogger != nil { if wsLogger != nil {
wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection", wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection",
+12
View File
@@ -1,7 +1,9 @@
package handler package handler
import ( import (
"errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
bizgift "kra/internal/biz/gift"
"kra/internal/service/dto" "kra/internal/service/dto"
service "kra/internal/service/gift" 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) v, e := h.s.Send(c.Request.Context(), claims.ID, id, in)
if e != nil { 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) businessFail(c, e)
return return
} }
+96 -15
View File
@@ -2,6 +2,7 @@ package handler
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
bizlive "kra/internal/biz/live" bizlive "kra/internal/biz/live"
@@ -14,10 +15,89 @@ import (
type Live struct { type Live struct {
service *liveservice.StreamService service *liveservice.StreamService
catalog *liveservice.CatalogService catalog *liveservice.CatalogService
follows *liveservice.FollowService
} }
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live { func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService, follows *liveservice.FollowService) *Live {
return &Live{service: service, catalog: catalog} 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) { func (h *Live) List(c *gin.Context) {
@@ -114,17 +194,27 @@ func (h *Live) PublicRooms(c *gin.Context) {
in.PageSize = 20 in.PageSize = 20
} }
if in.OrderBy == "" { if in.OrderBy == "" {
in.OrderBy = "sort_asc" in.OrderBy = "viewer_count_desc"
} }
enabled := true items, total, e := h.catalog.ListPublicRooms(c.Request.Context(), in)
in.Enabled = &enabled
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
if e != nil { if e != nil {
businessFail(c, e) businessFail(c, e)
return return
} }
OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize}) 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) { func (h *Live) GetRoom(c *gin.Context) {
id, ok := businessID(c) id, ok := businessID(c)
if !ok { if !ok {
@@ -203,15 +293,6 @@ func (h *Live) SaveRoomSort(c *gin.Context) {
OK(c) 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) { func (h *Live) Find(c *gin.Context) {
item, err := h.service.Get(c.Request.Context(), c.Param("streamId")) item, err := h.service.Get(c.Request.Context(), c.Param("streamId"))
if err != nil { if err != nil {
+6 -6
View File
@@ -15,14 +15,14 @@ func (h *LiveChat) History(c *gin.Context) {
if !ok { if !ok {
return return
} }
in, ok := bindQuery[dto.LiveChatHistoryRequest](c) // Chat is intentionally scoped to the current live connection. Do not
if !ok { // replay rows persisted by an earlier viewer/session through HTTP. Still
return // validate the room so callers receive a useful not-found response.
} items, err := h.service.History(c.Request.Context(), roomID, 1)
items, err := h.service.History(c.Request.Context(), roomID, in.Limit)
if err != nil { if err != nil {
businessFail(c, err) businessFail(c, err)
return return
} }
OKWithData(c, gin.H{"list": items}) _ = items
OKWithData(c, gin.H{"list": []*dto.LiveChatMessageResponse{}})
} }
+14
View File
@@ -58,6 +58,20 @@ func (h *LiveClient) Me(c *gin.Context) {
OKWithData(c, result) 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) { func (h *LiveClient) UpdateProfile(c *gin.Context) {
claims := Claims(c) claims := Claims(c)
if claims == nil { if claims == nil {
+36
View File
@@ -9,6 +9,14 @@ import (
type Recharge struct{ s *service.Service } type Recharge struct{ s *service.Service }
func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} } 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) { func (h *Recharge) Plans(c *gin.Context) {
v, e := h.s.Plans(c.Request.Context()) v, e := h.s.Plans(c.Request.Context())
if e != nil { if e != nil {
@@ -48,6 +56,34 @@ func (h *Recharge) Get(c *gin.Context) {
} }
OKWithData(c, v) 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) { func (h *Recharge) Create(c *gin.Context) {
in, ok := bindJSON[dto.RechargeOrderRequest](c) in, ok := bindJSON[dto.RechargeOrderRequest](c)
if !ok { if !ok {
+32 -1
View File
@@ -43,6 +43,33 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool {
return authenticate(c, auth, true) 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 { func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool {
token := RequestToken(c, allowQueryToken) token := RequestToken(c, allowQueryToken)
if token == "" { if token == "" {
@@ -74,13 +101,17 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
httpx.NoAuth(c, "无法处理此token") httpx.NoAuth(c, "无法处理此token")
return false return false
} }
applyTokenAuthentication(c, authentication)
return true
}
func applyTokenAuthentication(c *gin.Context, authentication *system.TokenAuthentication) {
if authentication.Refreshed != nil { if authentication.Refreshed != nil {
c.Header("new-token", authentication.Refreshed.Value) c.Header("new-token", authentication.Refreshed.Value)
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10)) 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())) httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
} }
c.Set(claimsKey, authentication.Claims) c.Set(claimsKey, authentication.Claims)
return true
} }
// RequestToken extracts the authentication token accepted by HTTP handlers. // RequestToken extracts the authentication token accepted by HTTP handlers.
+34
View File
@@ -6,6 +6,7 @@ import (
"kra/internal/biz/system" "kra/internal/biz/system"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "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) 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))
}
}
+5 -3
View File
@@ -8,11 +8,13 @@ import (
func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) { func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) {
public.GET("/live/streams", h.List) public.GET("/live/streams", h.List)
public.GET("/live/streams/:streamId", h.Find) 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/categories", h.PublicCategories)
public.GET("/live/rooms", h.PublicRooms) 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.GET("/live/admin/categories", h.ListCategories)
private.POST("/live/admin/categories", h.CreateCategory) private.POST("/live/admin/categories", h.CreateCategory)
private.PUT("/live/admin/categories/:id", h.UpdateCategory) private.PUT("/live/admin/categories/:id", h.UpdateCategory)
+3 -1
View File
@@ -5,10 +5,12 @@ import (
"kra/internal/server/handler" "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/login", h.Login)
public.POST("/live/client/auth/register", h.Register) 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/me", h.Me)
private.GET("/live/client/balance", h.Balance)
private.PUT("/live/client/me", h.UpdateProfile) private.PUT("/live/client/me", h.UpdateProfile)
private.PUT("/live/client/me/password", h.ChangePassword) private.PUT("/live/client/me/password", h.ChangePassword)
} }
+3
View File
@@ -6,7 +6,10 @@ import (
) )
func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) { func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) {
public.GET("/live/recharge/providers", h.Providers)
public.GET("/live/recharge/plans", h.Plans) 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.PUT("/live/admin/recharge/plans", h.SavePlans)
private.GET("/live/admin/orders", h.List) private.GET("/live/admin/orders", h.List)
private.GET("/live/admin/orders/:orderNo", h.Get) private.GET("/live/admin/orders/:orderNo", h.Get)
+1 -1
View File
@@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
func() { RegisterPayment(private, public, r.handlers.Payment) }, func() { RegisterPayment(private, public, r.handlers.Payment) },
func() { RegisterLive(public, private, r.handlers.Live) }, func() { RegisterLive(public, private, r.handlers.Live) },
func() { RegisterLiveChat(public, r.handlers.LiveChat) }, 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() { RegisterGift(public, private, r.handlers.Gift) },
func() { RegisterRecharge(public, private, r.handlers.Recharge) }, func() { RegisterRecharge(public, private, r.handlers.Recharge) },
func() { RegisterAsset(private, r.handlers.Asset) }, func() { RegisterAsset(private, r.handlers.Asset) },
+22 -13
View File
@@ -30,7 +30,7 @@ func (s *websocketAuthStub) AuthenticateToken(_ context.Context, token string) (
var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil) var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil)
func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) { func TestWebSocketRouteAcceptsAnonymousAndAuthenticatedViewers(t *testing.T) {
store := runtimeconfig.NewStore() store := runtimeconfig.NewStore()
raw, err := json.Marshal(map[string]any{"path": "/ws"}) raw, err := json.Marshal(map[string]any{"path": "/ws"})
if err != nil { if err != nil {
@@ -46,26 +46,35 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}} auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws) 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) httpServer := httptest.NewServer(engine)
defer httpServer.Close() defer httpServer.Close()
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws?token=login-token" baseURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws"
connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil) anonymous, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL, nil)
if response != nil && response.Body != nil { if response != nil && response.Body != nil {
_ = response.Body.Close() _ = response.Body.Close()
} }
if err != nil { if err != nil {
t.Fatalf("authorized websocket handshake failed: %v", err) t.Fatalf("anonymous websocket handshake failed: %v", err)
} }
if connection == nil { if anonymous == nil {
t.Fatal("authorized websocket handshake returned nil connection") 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" { if auth.got != "login-token" {
t.Fatalf("websocket auth token=%q, want login-token", auth.got) t.Fatalf("websocket auth token=%q, want login-token", auth.got)
} }
+3
View File
@@ -71,6 +71,9 @@ type LiveRoomResponse struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Status string `json:"status"` Status string `json:"status"`
ViewerCount int `json:"viewerCount"` ViewerCount int `json:"viewerCount"`
ViewerCountText string `json:"viewerCountText"`
LikeCount int `json:"likeCount"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
+5
View File
@@ -9,6 +9,7 @@ type LiveClientLoginRequest struct {
type LiveClientRegisterRequest struct { type LiveClientRegisterRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Nickname string `json:"nickname"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
ConfirmPassword string `json:"confirmPassword" binding:"required"` ConfirmPassword string `json:"confirmPassword" binding:"required"`
} }
@@ -34,6 +35,10 @@ type LiveClientAccountResponse struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type LiveClientBalanceResponse struct {
DiamondBalance int64 `json:"diamondBalance"`
}
type LiveClientAuthResponse struct { type LiveClientAuthResponse struct {
User *LiveClientAccountResponse `json:"user"` User *LiveClientAccountResponse `json:"user"`
Token string `json:"token"` Token string `json:"token"`
+16
View File
@@ -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)
}
}
+4
View File
@@ -9,6 +9,10 @@ type RechargePlan struct {
AmountCents int64 `json:"amountCents"` AmountCents int64 `json:"amountCents"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
type RechargeProvider struct {
ID string `json:"id"`
Name string `json:"name"`
}
type RechargePlansRequest struct { type RechargePlansRequest struct {
Plans []*RechargePlan `json:"plans" binding:"required"` Plans []*RechargePlan `json:"plans" binding:"required"`
} }
+20 -1
View File
@@ -76,6 +76,18 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
} }
return result, total, nil 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) { func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) {
item, err := s.rooms.Get(ctx, id) item, err := s.rooms.Get(ctx, id)
if err != nil { if err != nil {
@@ -83,6 +95,13 @@ func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomRes
} }
return roomResponse(item), nil 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 { 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} 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} 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 { 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}
} }
+32
View File
@@ -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)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire" import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService) var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService)
+4
View File
@@ -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 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)
}
+9 -1
View File
@@ -12,7 +12,7 @@ type Service struct{ uc *biz.Usecase }
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} } 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -35,6 +35,14 @@ func (s *Service) Me(ctx context.Context, userID uint) (*dto.LiveClientAccountRe
return accountResponse(account), nil 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) { 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}) account, err := s.uc.UpdateProfile(ctx, &biz.ProfileInput{UserID: userID, Nickname: in.Nickname, Avatar: in.Avatar})
if err != nil { if err != nil {
@@ -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)
}
}
+58 -2
View File
@@ -2,16 +2,48 @@ package recharge
import ( import (
"context" "context"
"sort"
"time" "time"
integrationbiz "kra/internal/biz/integration"
bizpayment "kra/internal/biz/payment" bizpayment "kra/internal/biz/payment"
biz "kra/internal/biz/recharge" biz "kra/internal/biz/recharge"
"kra/internal/service/dto" "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 { func dates(a, b string) []time.Time {
if a == "" || b == "" { if a == "" || b == "" {
@@ -76,6 +108,30 @@ func (s *Service) Get(ctx context.Context, no string) (*dto.RechargeOrderRespons
return order(v), nil 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) { 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}) 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 { if err != nil {
+255
View File
@@ -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 <seed|publisher|wscheck|inspect> ...")
}
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 <db-dsn> <user-id>")
}
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 <ws-url> <cookie-file> <room-id>")
}
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 <ws-url> <cookie-file> <room-id>")
}
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-dsn> <user-id> <room-id>")
}
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")
}
+1 -1
View File
@@ -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 deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
export const getRooms = (params) => get('/live/admin/rooms', params) 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 getRoom = (id) => get(`/live/rooms/${id}`)
export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params) export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params)
export const createRoom = (data) => post('/live/admin/rooms', data) export const createRoom = (data) => post('/live/admin/rooms', data)
+4 -3
View File
@@ -4,6 +4,7 @@
<el-form :inline="true" :model="query" class="live-search-form"> <el-form :inline="true" :model="query" class="live-search-form">
<el-form-item label="分类名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item> <el-form-item label="分类名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item> <el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item>
<el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="默认排序"><el-option label="展示排序" value="sort_asc" /><el-option label="最新创建" value="created_at_desc" /><el-option label="最早创建" value="created_at_asc" /></el-select></el-form-item>
<el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item> <el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form> </el-form>
</div> </div>
@@ -25,13 +26,13 @@ import { getUrl } from '@/utils/image'
import ImageUploader from '../components/ImageUploader.vue' import ImageUploader from '../components/ImageUploader.vue'
import { createCategory, deleteCategory, getCategories, updateCategory } from '../api/live' import { createCategory, deleteCategory, getCategories, updateCategory } from '../api/live'
defineOptions({ name: 'LiveCategories' }) 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 { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400) const compactTable = computed(() => width.value < 1400)
const form = reactive({ name: '', icon: '', sort: 0, enabled: true }), rules = { name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] } 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 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 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 }); search() } 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 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 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 ? '分类已启用' : '分类已停用') } } 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 ? '分类已启用' : '分类已停用') } }
+3 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="live-page"> <div class="live-page">
<div class="kra-search-box"> <div class="kra-search-box">
<el-form :inline="true" :model="query" class="live-search-form" @submit.prevent="search"><el-form-item label="礼物名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item><el-form-item label="最低钻石"><el-input-number v-model="query.minPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="最高钻石"><el-input-number v-model="query.maxPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="已上架" :value="true" /><el-option label="已下架" :value="false" /></el-select></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form> <el-form :inline="true" :model="query" class="live-search-form" @submit.prevent="search"><el-form-item label="礼物名称"><el-input v-model="query.name" clearable placeholder="输入名称" /></el-form-item><el-form-item label="最低钻石"><el-input-number v-model="query.minPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="最高钻石"><el-input-number v-model="query.maxPrice" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="已上架" :value="true" /><el-option label="已下架" :value="false" /></el-select></el-form-item><el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="默认排序"><el-option label="展示排序" value="sort_asc" /><el-option label="价格从低到高" value="price_asc" /><el-option label="价格从高到低" value="price_desc" /><el-option label="最新更新" value="updated_at_desc" /></el-select></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form>
</div> </div>
<div class="kra-table-box live-table-box"> <div class="kra-table-box live-table-box">
<div class="kra-btn-list"><el-button type="primary" :icon="Plus" @click="openCreate">新建礼物</el-button><el-button :icon="Sort" @click="persistSort">保存排序</el-button></div> <div class="kra-btn-list"><el-button type="primary" :icon="Plus" @click="openCreate">新建礼物</el-button><el-button :icon="Sort" @click="persistSort">保存排序</el-button></div>
@@ -44,7 +44,7 @@ import ImageUploader from '../components/ImageUploader.vue'
import { createGift, deleteGift, getGifts, getLiveSettings, saveGiftSort, setGiftStatus, updateGift } from '../api/live' import { createGift, deleteGift, getGifts, getLiveSettings, saveGiftSort, setGiftStatus, updateGift } from '../api/live'
defineOptions({ name: 'LiveGifts' }) defineOptions({ name: 'LiveGifts' })
const query = reactive({ name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined }) const query = reactive({ name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined, orderBy: 'sort_asc' })
const 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(), diamondRateCents = ref(10) const 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(), diamondRateCents = ref(10)
const { width } = useWindowSize() const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400) const compactTable = computed(() => width.value < 1400)
@@ -58,7 +58,7 @@ const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || '')) const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || ''))
const load = async () => { loading.value = true; try { const [res, settings] = await Promise.all([getGifts({ page: page.value, pageSize: pageSize.value, ...query }), getLiveSettings()]); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } if (settings.code === 0) diamondRateCents.value = Number(settings.data?.diamondRateCents || 10) } finally { loading.value = false } } const load = async () => { loading.value = true; try { const [res, settings] = await Promise.all([getGifts({ page: page.value, pageSize: pageSize.value, ...query }), getLiveSettings()]); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } if (settings.code === 0) diamondRateCents.value = Number(settings.data?.diamondRateCents || 10) } finally { loading.value = false } }
const search = () => { page.value = 1; load() } const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined }); search() } const reset = () => { Object.assign(query, { name: '', minPrice: undefined, maxPrice: undefined, enabled: undefined, orderBy: 'sort_asc' }); search() }
const openCreate = () => { editingId.value = 0; Object.assign(form, blank()); drawer.value = true } const openCreate = () => { editingId.value = 0; Object.assign(form, blank()); drawer.value = true }
const openEdit = (row) => { editingId.value = row.id; Object.assign(form, { name: row.name, icon: row.icon, price: row.price, description: row.description, tiers: [...row.tiers], sort: row.sort, enabled: row.enabled }); drawer.value = true } const openEdit = (row) => { editingId.value = row.id; Object.assign(form, { name: row.name, icon: row.icon, price: row.price, description: row.description, tiers: [...row.tiers], sort: row.sort, enabled: row.enabled }); drawer.value = true }
const normalizedForm = () => ({ ...form, tiers: [...new Set(form.tiers.map(Number).filter((item) => item > 0))].sort((a, b) => a - b) }) const normalizedForm = () => ({ ...form, tiers: [...new Set(form.tiers.map(Number).filter((item) => item > 0))].sort((a, b) => a - b) })
+3 -2
View File
@@ -21,6 +21,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="创建日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item> <el-form-item label="创建日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="最新优先"><el-option label="最新优先" value="created_at_desc" /><el-option label="最早优先" value="created_at_asc" /><el-option label="金额从高到低" value="amount_desc" /><el-option label="钻石从高到低" value="diamonds_desc" /></el-select></el-form-item>
<el-form-item class="live-search-actions"> <el-form-item class="live-search-actions">
<el-button type="primary" :icon="Search" @click="search">查询</el-button> <el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button> <el-button :icon="Refresh" @click="reset">重置</el-button>
@@ -89,7 +90,7 @@ const providers = [
{ label: 'PayPal', value: 'paypal' } { label: 'PayPal', value: 'paypal' }
] ]
const query = reactive({ orderNo: '', userKeyword: '', paymentStatus: '', provider: '' }) const query = reactive({ orderNo: '', userKeyword: '', paymentStatus: '', provider: '', orderBy: 'created_at_desc' })
const dateRange = ref([]) const dateRange = ref([])
const rows = ref([]) const rows = ref([])
const page = ref(1) const page = ref(1)
@@ -125,7 +126,7 @@ const load = async () => {
} }
} }
const search = () => { page.value = 1; load() } const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { orderNo: '', userKeyword: '', paymentStatus: '', provider: '' }); dateRange.value = []; search() } const reset = () => { Object.assign(query, { orderNo: '', userKeyword: '', paymentStatus: '', provider: '', orderBy: 'created_at_desc' }); dateRange.value = []; search() }
const showDetail = async (row) => { const showDetail = async (row) => {
const res = await getOrder(row.orderNo) const res = await getOrder(row.orderNo)
if (res.code === 0) { if (res.code === 0) {
+3 -2
View File
@@ -6,6 +6,7 @@
<el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item> <el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item>
<el-form-item label="分类"><el-select v-model="query.categoryId" clearable placeholder="全部分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item> <el-form-item label="分类"><el-select v-model="query.categoryId" clearable placeholder="全部分类"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="直播状态"><el-select v-model="query.status" clearable placeholder="全部状态"><el-option label="直播中" value="live" /><el-option label="未开播" value="offline" /><el-option label="已禁用" value="disabled" /></el-select></el-form-item> <el-form-item label="直播状态"><el-select v-model="query.status" clearable placeholder="全部状态"><el-option label="直播中" value="live" /><el-option label="未开播" value="offline" /><el-option label="已禁用" value="disabled" /></el-select></el-form-item>
<el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="默认排序"><el-option label="展示排序" value="sort_asc" /><el-option label="最新创建" value="created_at_desc" /><el-option label="最早创建" value="created_at_asc" /><el-option label="房间号升序" value="id_asc" /><el-option label="房间号降序" value="id_desc" /></el-select></el-form-item>
<el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item> <el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form> </el-form>
</div> </div>
@@ -59,7 +60,7 @@ import VideoUploader from '../components/VideoUploader.vue'
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live' import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
defineOptions({ name: 'LiveRooms' }) defineOptions({ name: 'LiveRooms' })
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '' }) const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' })
const rows = ref([]), categories = ref([]), users = ref([]), loading = ref(false), saving = ref(false), drawer = ref(false), formRef = ref() const rows = ref([]), categories = ref([]), users = ref([]), loading = ref(false), saving = ref(false), drawer = ref(false), formRef = ref()
const { width } = useWindowSize() const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1180) const compactTable = computed(() => width.value < 1180)
@@ -73,7 +74,7 @@ const format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getRooms({ 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 load = async () => { loading.value = true; try { const res = await getRooms({ 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 loadOptions = async () => { const [c, u] = await Promise.all([getCategories({ page: 1, pageSize: 100, orderBy: 'sort_asc' }), getAssetUsers({ page: 1, pageSize: 100, orderBy: 'created_desc' })]); if (c.code === 0) categories.value = c.data.list || []; if (u.code === 0) users.value = u.data.list || [] } const loadOptions = async () => { const [c, u] = await Promise.all([getCategories({ page: 1, pageSize: 100, orderBy: 'sort_asc' }), getAssetUsers({ page: 1, pageSize: 100, orderBy: 'created_desc' })]); if (c.code === 0) categories.value = c.data.list || []; if (u.code === 0) users.value = u.data.list || [] }
const search = () => { page.value = 1; load() } const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '' }); search() } const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() }
const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true } const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true }
const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true } const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true }
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form); if (res.code === 0) { ElMessage.success('直播间已保存'); drawer.value = false; load() } } finally { saving.value = false } } const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form); if (res.code === 0) { ElMessage.success('直播间已保存'); drawer.value = false; load() } } finally { saving.value = false } }
+3 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="live-page"> <div class="live-page">
<div class="kra-table-box live-summary-box"><div class="live-summary-item"><span>赠送次数</span><strong>{{ number(summary.count) }} <small>次</small></strong></div><div class="live-summary-item"><span>消费钻石</span><strong>{{ number(summary.diamonds) }}</strong></div><div class="live-summary-item"><span>折合金额</span><strong>¥{{ money(summary.amountCents) }}</strong></div></div> <div class="kra-table-box live-summary-box"><div class="live-summary-item"><span>赠送次数</span><strong>{{ number(summary.count) }} <small>次</small></strong></div><div class="live-summary-item"><span>消费钻石</span><strong>{{ number(summary.diamonds) }}</strong></div><div class="live-summary-item"><span>折合金额</span><strong>¥{{ money(summary.amountCents) }}</strong></div></div>
<div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="直播间"><el-input v-model="query.roomName" clearable placeholder="直播间名称" /></el-form-item><el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item><el-form-item label="赠送用户"><el-input v-model="query.userKeyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="礼物"><el-select v-model="query.giftId" clearable filterable placeholder="全部礼物"><el-option v-for="item in gifts" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="赠送日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div> <div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="直播间"><el-input v-model="query.roomName" clearable placeholder="直播间名称" /></el-form-item><el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item><el-form-item label="赠送用户"><el-input v-model="query.userKeyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="礼物"><el-select v-model="query.giftId" clearable filterable placeholder="全部礼物"><el-option v-for="item in gifts" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="赠送日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="最新优先"><el-option label="最新优先" value="created_at_desc" /><el-option label="最早优先" value="created_at_asc" /><el-option label="钻石从高到低" value="total_desc" /><el-option label="数量从高到低" value="quantity_desc" /></el-select></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div>
<div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="serialNo"> <div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="serialNo">
<el-table-column label="流水号" prop="serialNo" min-width="160" show-overflow-tooltip /> <el-table-column label="流水号" prop="serialNo" min-width="160" show-overflow-tooltip />
<el-table-column label="赠送时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column> <el-table-column label="赠送时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
@@ -23,12 +23,12 @@ import { Refresh, Search } from '@element-plus/icons-vue'
import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live' import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
defineOptions({ name: 'LiveSends' }) defineOptions({ name: 'LiveSends' })
const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined }), dateRange = ref([]), gifts = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), summary = ref({ count: 0, diamonds: 0, amountCents: 0 }) const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }), dateRange = ref([]), gifts = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), summary = ref({ count: 0, diamonds: 0, amountCents: 0 })
const drawer = ref(false), linkedUser = ref(null), linkedRoom = ref(null) const drawer = ref(false), linkedUser = ref(null), linkedRoom = ref(null)
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-' const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getGiftSends({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0; summary.value = res.data.summary || summary.value } } finally { loading.value = false } } const load = async () => { loading.value = true; try { const res = await getGiftSends({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0; summary.value = res.data.summary || summary.value } } finally { loading.value = false } }
const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize: 100 }); if (res.code === 0) gifts.value = res.data.list || [] } const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize: 100 }); if (res.code === 0) gifts.value = res.data.list || [] }
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined }); dateRange.value = []; search() } const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }); dateRange.value = []; search() }
onMounted(() => { loadGifts(); load() }) onMounted(() => { loadGifts(); load() })
const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } } const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } }
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoom(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') } const openRoom = async (id) => { linkedUser.value = null; const res = await getRoom(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
+3 -3
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="live-page"> <div class="live-page">
<div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="用户搜索"><el-input v-model="query.keyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="用户 ID"><el-input-number v-model="query.userId" :min="1" controls-position="right" /></el-form-item><el-form-item label="用户状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="正常" :value="true" /><el-option label="已禁用" :value="false" /></el-select></el-form-item><el-form-item label="注册日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div> <div class="kra-search-box"><el-form :inline="true" :model="query" class="live-search-form"><el-form-item label="用户搜索"><el-input v-model="query.keyword" clearable placeholder="用户名或昵称" /></el-form-item><el-form-item label="用户 ID"><el-input-number v-model="query.userId" :min="1" controls-position="right" /></el-form-item><el-form-item label="用户状态"><el-select v-model="query.enabled" clearable placeholder="全部状态"><el-option label="正常" :value="true" /><el-option label="已禁用" :value="false" /></el-select></el-form-item><el-form-item label="注册日期"><el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item><el-form-item label="排序"><el-select v-model="query.orderBy" placeholder="注册时间倒序"><el-option label="注册时间倒序" value="created_desc" /><el-option label="余额从高到低" value="balance_desc" /><el-option label="消费从高到低" value="spent_desc" /><el-option label="最近登录倒序" value="last_login_desc" /></el-select></el-form-item><el-form-item class="live-search-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item></el-form></div>
<div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="userId"><el-table-column label="用户编号" prop="userId" min-width="82" align="right" /><el-table-column label="账号" prop="username" min-width="120" show-overflow-tooltip /><el-table-column label="昵称" prop="nickname" min-width="110" show-overflow-tooltip /><el-table-column label="头像" min-width="70" align="center"><template #default="{ row }"><el-avatar :size="30" :src="getUrl(row.avatar)">{{ (row.nickname || row.username)?.slice(0, 1) }}</el-avatar></template></el-table-column><el-table-column label="钻石余额" min-width="100" align="right"><template #default="{ row }"><b class="asset-balance">{{ number(row.diamondBalance) }}</b></template></el-table-column><el-table-column label="累计充值" min-width="105" align="right"><template #default="{ row }">¥{{ money(row.totalRechargeCents) }}</template></el-table-column><el-table-column label="累计消费钻石" min-width="120" align="right"><template #default="{ row }">{{ number(row.totalSpentDiamonds) }}</template></el-table-column><el-table-column v-if="!compactTable" label="注册时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column><el-table-column v-if="!compactTable" label="最近登录" min-width="150"><template #default="{ row }">{{ format(row.lastLoginAt) }}</template></el-table-column><el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '正常' : '禁用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="View" @click="openDetail(row)">详情</el-button><el-button link type="primary" :icon="Coin" @click="openAdjust(row)">调账</el-button><el-button link :type="row.enabled ? 'danger' : 'primary'" :disabled="row.userId === currentUserId" @click="toggle(row, !row.enabled)">{{ row.enabled ? '禁用' : '启用' }}</el-button></template></el-table-column></el-table><div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div></div> <div class="kra-table-box live-table-box"><div class="kra-btn-list"><el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" row-key="userId"><el-table-column label="用户编号" prop="userId" min-width="82" align="right" /><el-table-column label="账号" prop="username" min-width="120" show-overflow-tooltip /><el-table-column label="昵称" prop="nickname" min-width="110" show-overflow-tooltip /><el-table-column label="头像" min-width="70" align="center"><template #default="{ row }"><el-avatar :size="30" :src="getUrl(row.avatar)">{{ (row.nickname || row.username)?.slice(0, 1) }}</el-avatar></template></el-table-column><el-table-column label="钻石余额" min-width="100" align="right"><template #default="{ row }"><b class="asset-balance">{{ number(row.diamondBalance) }}</b></template></el-table-column><el-table-column label="累计充值" min-width="105" align="right"><template #default="{ row }">¥{{ money(row.totalRechargeCents) }}</template></el-table-column><el-table-column label="累计消费钻石" min-width="120" align="right"><template #default="{ row }">{{ number(row.totalSpentDiamonds) }}</template></el-table-column><el-table-column v-if="!compactTable" label="注册时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column><el-table-column v-if="!compactTable" label="最近登录" min-width="150"><template #default="{ row }">{{ format(row.lastLoginAt) }}</template></el-table-column><el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '正常' : '禁用' }}</el-tag></template></el-table-column><el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="View" @click="openDetail(row)">详情</el-button><el-button link type="primary" :icon="Coin" @click="openAdjust(row)">调账</el-button><el-button link :type="row.enabled ? 'danger' : 'primary'" :disabled="row.userId === currentUserId" @click="toggle(row, !row.enabled)">{{ row.enabled ? '禁用' : '启用' }}</el-button></template></el-table-column></el-table><div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div></div>
<el-drawer v-model="detailDrawer" size="720px" title="用户资产详情"><template v-if="detail"><div class="asset-detail-head"><div class="asset-user"><el-avatar :size="52" :src="getUrl(detail.avatar)">{{ (detail.nickname || detail.username)?.slice(0, 1) }}</el-avatar><div><strong>{{ detail.nickname || detail.username }}</strong><span>{{ detail.username }} · 用户 ID {{ detail.userId }}</span><small>注册于 {{ format(detail.createdAt) }} · 最近登录 {{ format(detail.lastLoginAt) }}</small></div></div><div class="asset-detail-balance"><span>钻石余额</span><strong>{{ number(detail.diamondBalance) }}</strong><div><el-button link type="primary" :icon="Coin" @click="openAdjust(detail)">调账</el-button><el-switch :model-value="detail.enabled" :disabled="detail.userId === currentUserId" @change="toggle(detail, $event)" /></div></div></div><div class="asset-detail-stats"><div><span>累计充值</span><strong>¥{{ money(detail.totalRechargeCents) }}</strong></div><div><span>累计消费</span><strong>{{ number(detail.totalSpentDiamonds) }} 钻石</strong></div><div><span>账号状态</span><strong>{{ detail.enabled ? '正常' : '已禁用' }}</strong></div></div><el-tabs><el-tab-pane label="最近充值"><el-table :data="detail.recentOrders || []" table-layout="auto"><el-table-column prop="orderNo" label="订单号" width="180" show-overflow-tooltip /><el-table-column label="钻石" prop="diamonds" width="90" align="right" /><el-table-column label="金额" width="100" align="right"><template #default="{ row }">¥{{ money(row.amountCents) }}</template></el-table-column><el-table-column label="状态" prop="status" width="90" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane><el-tab-pane label="最近送礼"><el-table :data="detail.recentSends || []" table-layout="auto"><el-table-column prop="serialNo" label="流水号" width="160" show-overflow-tooltip /><el-table-column prop="roomName" label="直播间" show-overflow-tooltip /><el-table-column label="礼物"><template #default="{ row }">{{ row.giftIcon }} {{ row.giftName }}</template></el-table-column><el-table-column prop="diamondTotal" label="钻石" width="90" align="right" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane></el-tabs></template></el-drawer> <el-drawer v-model="detailDrawer" size="720px" title="用户资产详情"><template v-if="detail"><div class="asset-detail-head"><div class="asset-user"><el-avatar :size="52" :src="getUrl(detail.avatar)">{{ (detail.nickname || detail.username)?.slice(0, 1) }}</el-avatar><div><strong>{{ detail.nickname || detail.username }}</strong><span>{{ detail.username }} · 用户 ID {{ detail.userId }}</span><small>注册于 {{ format(detail.createdAt) }} · 最近登录 {{ format(detail.lastLoginAt) }}</small></div></div><div class="asset-detail-balance"><span>钻石余额</span><strong>{{ number(detail.diamondBalance) }}</strong><div><el-button link type="primary" :icon="Coin" @click="openAdjust(detail)">调账</el-button><el-switch :model-value="detail.enabled" :disabled="detail.userId === currentUserId" @change="toggle(detail, $event)" /></div></div></div><div class="asset-detail-stats"><div><span>累计充值</span><strong>¥{{ money(detail.totalRechargeCents) }}</strong></div><div><span>累计消费</span><strong>{{ number(detail.totalSpentDiamonds) }} 钻石</strong></div><div><span>账号状态</span><strong>{{ detail.enabled ? '正常' : '已禁用' }}</strong></div></div><el-tabs><el-tab-pane label="最近充值"><el-table :data="detail.recentOrders || []" table-layout="auto"><el-table-column prop="orderNo" label="订单号" width="180" show-overflow-tooltip /><el-table-column label="钻石" prop="diamonds" width="90" align="right" /><el-table-column label="金额" width="100" align="right"><template #default="{ row }">¥{{ money(row.amountCents) }}</template></el-table-column><el-table-column label="状态" prop="status" width="90" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane><el-tab-pane label="最近送礼"><el-table :data="detail.recentSends || []" table-layout="auto"><el-table-column prop="serialNo" label="流水号" width="160" show-overflow-tooltip /><el-table-column prop="roomName" label="直播间" show-overflow-tooltip /><el-table-column label="礼物"><template #default="{ row }">{{ row.giftIcon }} {{ row.giftName }}</template></el-table-column><el-table-column prop="diamondTotal" label="钻石" width="90" align="right" /><el-table-column label="时间" width="155"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column></el-table></el-tab-pane></el-tabs></template></el-drawer>
<el-dialog v-model="adjustDialog" title="人工调整钻石" width="460px"><el-form ref="adjustRef" :model="adjust" :rules="adjustRules" class="live-dialog-form" label-width="90px"><el-alert :title="`当前余额:${number(adjustUser?.diamondBalance)} 钻石`" type="info" :closable="false" class="adjust-alert" /><el-form-item label="调整方式" prop="type"><el-segmented v-model="adjust.type" :options="[{ label: '增加', value: 'increase' }, { label: '扣减', value: 'decrease' }]" /></el-form-item><el-form-item label="调整数量" prop="amount"><el-input-number v-model="adjust.amount" :min="1" :max="99999" /></el-form-item><el-form-item label="调整原因" prop="reason"><el-input v-model="adjust.reason" type="textarea" :rows="3" minlength="5" maxlength="50" show-word-limit placeholder="请输入 5-50 个字的操作原因" /></el-form-item></el-form><template #footer><el-button @click="adjustDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitAdjust">确认调整</el-button></template></el-dialog> <el-dialog v-model="adjustDialog" title="人工调整钻石" width="460px"><el-form ref="adjustRef" :model="adjust" :rules="adjustRules" class="live-dialog-form" label-width="90px"><el-alert :title="`当前余额:${number(adjustUser?.diamondBalance)} 钻石`" type="info" :closable="false" class="adjust-alert" /><el-form-item label="调整方式" prop="type"><el-segmented v-model="adjust.type" :options="[{ label: '增加', value: 'increase' }, { label: '扣减', value: 'decrease' }]" /></el-form-item><el-form-item label="调整数量" prop="amount"><el-input-number v-model="adjust.amount" :min="1" :max="99999" /></el-form-item><el-form-item label="调整原因" prop="reason"><el-input v-model="adjust.reason" type="textarea" :rows="3" minlength="5" maxlength="50" show-word-limit placeholder="请输入 5-50 个字的操作原因" /></el-form-item></el-form><template #footer><el-button @click="adjustDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitAdjust">确认调整</el-button></template></el-dialog>
@@ -18,11 +18,11 @@ defineOptions({ name: 'LiveUsers' })
const userStore = useUserStore(), currentUserId = computed(() => Number(userStore.userInfo?.ID || 0)) const userStore = useUserStore(), currentUserId = computed(() => Number(userStore.userInfo?.ID || 0))
const { width } = useWindowSize() const { width } = useWindowSize()
const compactTable = computed(() => width.value < 1400) const compactTable = computed(() => width.value < 1400)
const query = reactive({ keyword: '', userId: undefined, enabled: undefined }), dateRange = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), detailDrawer = ref(false), detail = ref(null), adjustDialog = ref(false), adjustUser = ref(null), adjustRef = ref(), saving = ref(false) const query = reactive({ keyword: '', userId: undefined, enabled: undefined, orderBy: 'created_desc' }), dateRange = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), detailDrawer = ref(false), detail = ref(null), adjustDialog = ref(false), adjustUser = ref(null), adjustRef = ref(), saving = ref(false)
const adjust = reactive({ type: 'increase', amount: 1, reason: '' }), adjustRules = { type: [{ required: true }], amount: [{ required: true, message: '请输入调整数量', trigger: 'change' }], reason: [{ required: true, min: 5, max: 50, message: '请输入 5-50 个字的操作原因', trigger: 'blur' }] } const adjust = reactive({ type: 'increase', amount: 1, reason: '' }), adjustRules = { type: [{ required: true }], amount: [{ required: true, message: '请输入调整数量', trigger: 'change' }], reason: [{ required: true, min: 5, max: 50, message: '请输入 5-50 个字的操作原因', trigger: 'blur' }] }
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-' const number = (v) => Number(v || 0).toLocaleString('zh-CN'), money = (v) => (Number(v || 0) / 100).toFixed(2), format = (v) => v ? new Date(v).toLocaleString('zh-CN') : '-'
const load = async () => { loading.value = true; try { const res = await getAssetUsers({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } } const load = async () => { loading.value = true; try { const res = await getAssetUsers({ page: page.value, pageSize: pageSize.value, ...query, startedAt: dateRange.value?.[0], endedAt: dateRange.value?.[1] }); 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, { keyword: '', userId: undefined, enabled: undefined }); dateRange.value = []; search() } const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { keyword: '', userId: undefined, enabled: undefined, orderBy: 'created_desc' }); dateRange.value = []; search() }
const toggle = async (row, enabled) => { if (!enabled) await ElMessageBox.confirm(`禁用后“${row.nickname || row.username}”将无法使用直播业务,确认继续吗?`, '禁用用户', { type: 'warning' }); const res = await setAssetUserStatus(row.userId, enabled); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '用户已启用' : '用户已禁用') } } const toggle = async (row, enabled) => { if (!enabled) await ElMessageBox.confirm(`禁用后“${row.nickname || row.username}”将无法使用直播业务,确认继续吗?`, '禁用用户', { type: 'warning' }); const res = await setAssetUserStatus(row.userId, enabled); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '用户已启用' : '用户已禁用') } }
const openDetail = async (row) => { const res = await getAssetUser(row.userId); if (res.code === 0) { detail.value = res.data; detailDrawer.value = true } } const openDetail = async (row) => { const res = await getAssetUser(row.userId); if (res.code === 0) { detail.value = res.data; detailDrawer.value = true } }
const openAdjust = (row) => { adjustUser.value = row; Object.assign(adjust, { type: 'increase', amount: 1, reason: '' }); adjustDialog.value = true } const openAdjust = (row) => { adjustUser.value = row; Object.assign(adjust, { type: 'increase', amount: 1, reason: '' }); adjustDialog.value = true }