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)
}
}
+91 -23
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")
) )
@@ -37,23 +40,26 @@ type Category struct {
} }
type Room struct { type Room struct {
ID uint ID uint
Name string Name string
CategoryID uint CategoryID uint
CategoryName string CategoryName string
CoverURL string CoverURL string
Intro string Intro string
Sort int Sort int
AnchorUserID uint AnchorUserID uint
AnchorName string AnchorName string
AnchorAvatar string AnchorAvatar string
StreamURL string StreamURL string
ReplayURL string ReplayURL string
Enabled bool Enabled bool
Status string Status string
ViewerCount int ViewerCount int
CreatedAt time.Time ViewerCountText string
UpdatedAt time.Time LikeCount int
StartedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }
type CategoryFilter struct { type CategoryFilter struct {
@@ -62,12 +68,13 @@ type CategoryFilter struct {
} }
type RoomFilter struct { type RoomFilter struct {
Name string Name string
AnchorName string AnchorName string
CategoryID uint CategoryID uint
Status string Status string
Enabled *bool Enabled *bool
CreatedAt []time.Time CategoryEnabled *bool
CreatedAt []time.Time
} }
type ListOptions struct { type ListOptions struct {
@@ -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
+45 -34
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
var boundary messagePO item.CreatedAt = time.Now()
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error items := append(r.rooms[item.RoomID], cloneMessage(item))
if errors.Is(err, gorm.ErrRecordNotFound) { if len(items) > 100 {
return nil items = append([]*biz.Message(nil), items[len(items)-100:]...)
} }
if err != nil { r.rooms[item.RoomID] = items
return err return cloneMessage(item)
} }
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
}) func (r *repo) Clear(_ context.Context, roomID uint) error {
r.mu.Lock()
delete(r.rooms, roomID)
r.mu.Unlock()
return nil
} }
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error { 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 {
+122 -45
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
} }
@@ -53,16 +54,19 @@ type publishedTrack struct {
} }
type peer struct { type peer struct {
session *melody.Session session *melody.Session
manager *Manager manager *Manager
room *room room *room
streamID string streamID string
userID uint userID uint
role string role string
subscriberID string authenticated bool
pc *webrtc.PeerConnection subscriberID string
lastBeat atomic.Int64 pcMu sync.RWMutex
cleanup sync.Once pc *webrtc.PeerConnection
closing bool
lastBeat atomic.Int64
cleanup sync.Once
} }
type message struct { type message struct {
@@ -210,20 +214,28 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"}) 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)
if !installed {
_ = pc.Close()
return
}
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { m.handleICEConnectionStateChange(p, pc, state)
m.closePeer(p, "ice_"+state.String())
}
}) })
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)
}
}
}
+13 -12
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
keys["user_id"] = claims.ID // token is cleared and continues as anonymous so it cannot block video.
if hasToken {
if authenticated := middleware.AuthenticateWebSocketOptional(c, auth); !authenticated && wsLogger != nil {
wsLogger.InfoContext(c.Request.Context(), "websocket token unavailable; continuing anonymously",
"mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"))
}
if claims := middleware.Claims(c); claims != nil {
keys["user_id"] = claims.ID
keys["authenticated"] = true
}
} }
if wsLogger != nil { 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)
} }
+21 -18
View File
@@ -55,24 +55,27 @@ type LiveRoomRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
type LiveRoomResponse struct { type LiveRoomResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
StreamID string `json:"streamId"` StreamID string `json:"streamId"`
Name string `json:"name"` Name string `json:"name"`
CategoryID uint `json:"categoryId"` CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName"` CategoryName string `json:"categoryName"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
Intro string `json:"intro"` Intro string `json:"intro"`
Sort int `json:"sort"` Sort int `json:"sort"`
AnchorUserID uint `json:"anchorUserId"` AnchorUserID uint `json:"anchorUserId"`
AnchorName string `json:"anchorName"` AnchorName string `json:"anchorName"`
AnchorAvatar string `json:"anchorAvatar"` AnchorAvatar string `json:"anchorAvatar"`
StreamURL string `json:"streamUrl"` StreamURL string `json:"streamUrl"`
ReplayURL string `json:"replayUrl"` ReplayURL string `json:"replayUrl"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Status string `json:"status"` Status string `json:"status"`
ViewerCount int `json:"viewerCount"` ViewerCount int `json:"viewerCount"`
CreatedAt time.Time `json:"createdAt"` ViewerCountText string `json:"viewerCountText"`
UpdatedAt time.Time `json:"updatedAt"` LikeCount int `json:"likeCount"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type LiveStatusRequest struct { type LiveStatusRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
+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 }