对接
This commit is contained in:
+10
@@ -46,3 +46,13 @@ web/node_modules/
|
||||
# Local reference sources and desktop-only tooling state
|
||||
/gva/
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Local runtime data
|
||||
yzyzlive.db
|
||||
.tmp-*
|
||||
|
||||
# Nested git repo (live-webrtc-frontend, own repo: desktop/live-webrtc-frontend)
|
||||
frontend/
|
||||
|
||||
# Old reference sources
|
||||
old/
|
||||
|
||||
Generated
+6
-3
@@ -202,7 +202,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
categoryUsecase := live2.NewCategoryUsecase(catalogRepo)
|
||||
roomUsecase := live2.NewRoomUsecase(catalogRepo, streamUsecase)
|
||||
catalogService := live3.NewCatalogService(categoryUsecase, roomUsecase)
|
||||
handlerLive := handler.NewLive(streamService, catalogService)
|
||||
followRepo := live.NewFollowRepo(dataData)
|
||||
followUsecase := live2.NewFollowUsecase(followRepo)
|
||||
followService := live3.NewFollowService(followUsecase)
|
||||
handlerLive := handler.NewLive(streamService, catalogService, followService)
|
||||
repo := livechat.NewRepo(dataData)
|
||||
usecase := livechat2.NewUsecase(repo)
|
||||
service := livechat3.NewService(usecase)
|
||||
@@ -223,7 +226,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
giftUsecase := gift2.NewUsecase(giftRepo, manager)
|
||||
giftUsecase := gift2.NewUsecase(giftRepo, manager, roomUsecase)
|
||||
giftService := gift3.NewService(giftUsecase)
|
||||
handlerGift := handler.NewGift(giftService)
|
||||
rechargeRepo := recharge.NewRepo(dataData)
|
||||
@@ -234,7 +237,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
rechargeService := recharge3.NewService(rechargeUsecase)
|
||||
rechargeService := recharge3.NewService(rechargeUsecase, integrationConfigUsecase)
|
||||
handlerRecharge := handler.NewRecharge(rechargeService)
|
||||
assetRepo := asset.NewRepo(dataData)
|
||||
assetUsecase := asset2.NewUsecase(assetRepo)
|
||||
|
||||
+4
-3
@@ -6,11 +6,12 @@ server:
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/yzyzlive?charset=utf8mb4&parseTime=True&loc=Local
|
||||
# Supply production credentials through KRA_DATA_DATABASE_*.
|
||||
source: ""
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
user: root
|
||||
password: Xu950329.
|
||||
password: ""
|
||||
name: yzyzlive
|
||||
config: charset=utf8mb4&parseTime=True&loc=Local
|
||||
path: ""
|
||||
@@ -88,7 +89,7 @@ admin:
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
# Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally.
|
||||
signing_key: e8e37061-b137-4438-ad48-30f85bf8c68b
|
||||
signing_key: ""
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
|
||||
Vendored
+26
-1
@@ -35,6 +35,7 @@ pipeline {
|
||||
REGISTRY_NAMESPACE = 'live'
|
||||
BACKEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/backend"
|
||||
FRONTEND_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/frontend"
|
||||
LIVEWEB_IMAGE = "${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/live-web"
|
||||
DEPLOY_HOST = 'root@192.168.2.226'
|
||||
BIN_NAME = 'kratos-admin'
|
||||
APP_BASE = 'live' // 前端 vite base 与 nginx 服务名前缀
|
||||
@@ -67,6 +68,7 @@ pipeline {
|
||||
echo "Deploy mode: ${params.DEPLOY_MODE}"
|
||||
echo "Backend : ${env.BACKEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
|
||||
echo "Frontend: ${env.FRONTEND_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
|
||||
echo "LiveWeb : ${env.LIVEWEB_IMAGE}:${env.DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +105,23 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Live Web') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
}
|
||||
steps {
|
||||
// PC 直播用户端与后端同仓维护,确保当前提交中的接口与页面一起发布。
|
||||
// HTTP/WS 均请求同源 /api,由站点 nginx 去前缀转发到后端。
|
||||
sh '''
|
||||
docker run --rm \
|
||||
-v "$PWD/frontend:/app" -w /app \
|
||||
-e npm_config_registry=https://registry.npmmirror.com \
|
||||
docker.m.daocloud.io/node:20-slim \
|
||||
bash -lc "npm ci --no-audit --no-fund && npm run build"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Images') {
|
||||
when {
|
||||
expression { params.DEPLOY_MODE == 'BUILD_AND_DEPLOY' }
|
||||
@@ -119,6 +138,10 @@ pipeline {
|
||||
--tag ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} \
|
||||
--tag ${FRONTEND_IMAGE}:latest \
|
||||
web
|
||||
docker build \
|
||||
--tag ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG} \
|
||||
--tag ${LIVEWEB_IMAGE}:latest \
|
||||
frontend
|
||||
'''
|
||||
}
|
||||
}
|
||||
@@ -135,6 +158,8 @@ pipeline {
|
||||
docker push ${BACKEND_IMAGE}:latest
|
||||
docker push ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}
|
||||
docker push ${FRONTEND_IMAGE}:latest
|
||||
docker push ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG}
|
||||
docker push ${LIVEWEB_IMAGE}:latest
|
||||
docker logout "${REGISTRY_HOST}"
|
||||
'''
|
||||
}
|
||||
@@ -154,7 +179,7 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG}"
|
||||
echo "Deployed ${BACKEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${FRONTEND_IMAGE}:${DEPLOY_IMAGE_TAG} / ${LIVEWEB_IMAGE}:${DEPLOY_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# live.fuhua91.com 宝塔站点配置
|
||||
# live.fuhua91.com 宝塔站点配置(与 2026-09-21 服务器生效版本一致)
|
||||
# 路径规划:
|
||||
# / -> live 用户端前端(静态目录,或替换为反代)
|
||||
# /admin/ -> 管理后台(frontend 容器 9501,nginx 去前缀转发)
|
||||
# /api -> 后端 API/WebSocket(backend 容器 9502)
|
||||
# / -> live 用户端前端(PC 直播页,live-web 容器 9503)
|
||||
# /admin/ -> 管理后台(frontend 容器 9501,rewrite 去前缀转发)
|
||||
# /api -> 后端 API/WebSocket(backend 容器 9502,rewrite 去前缀转发)
|
||||
# 注意: 本文件顶部的 map 位于 http 上下文(宝塔 include 位置),合法。
|
||||
|
||||
map $http_upgrade $live_connection_upgrade {
|
||||
@@ -63,8 +63,9 @@ server
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 后端 API / WebSocket(容器内后端无 /api 前缀,proxy_pass 尾斜杠自动去掉)
|
||||
location ^~ /api {
|
||||
# 后端 API / WebSocket(rewrite 去掉 /api 前缀,后端路由无 /api)
|
||||
location ^~ /api/ {
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:9502;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -76,6 +77,14 @@ server
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
location = /api {
|
||||
proxy_pass http://127.0.0.1:9502/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 管理后台(frontend 容器;rewrite 去掉 /admin 前缀,前端已按 /admin/ base 构建)
|
||||
location = /admin {
|
||||
@@ -91,17 +100,19 @@ server
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# live 用户端前端(根路径)
|
||||
# 方式一: 静态文件放 /www/wwwroot/live.fuhua91.com/live-web 后使用当前配置
|
||||
# 方式二: 若 live 前端也是 docker 容器,把下面两行换成:
|
||||
# proxy_pass http://127.0.0.1:<live前端端口>;
|
||||
# proxy_set_header Host $http_host; 以及上面 /admin/ 块中的其余 proxy_set_header
|
||||
# live 用户端前端(PC 直播页,live-web 容器,主域名根路径)
|
||||
location / {
|
||||
root /www/wwwroot/live.fuhua91.com/live-web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
proxy_pass http://127.0.0.1:9503;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $live_connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
#禁止在证书验证目录放入敏感文件
|
||||
# 禁止在证书验证目录放入敏感文件
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
+29
-11
@@ -14,25 +14,27 @@ set -euo pipefail
|
||||
# ---------- 可按需调整的参数 ----------
|
||||
REGISTRY="harbor.fuhua91.com"
|
||||
PROJECT="live"
|
||||
# Harbor 机器人账号(也可通过环境变量 HARBOR_USER / HARBOR_PASSWORD 覆盖)
|
||||
# Harbor 机器人账号(密码只通过 HARBOR_PASSWORD 环境变量提供)
|
||||
HARBOR_USER_DEFAULT='robot$live+live'
|
||||
HARBOR_PASSWORD_DEFAULT='R4p6ieacIogXE779eL1HXhCpA4bx4SA7'
|
||||
|
||||
BASE_DIR="/www/wwwroot/live.fuhua91.com"
|
||||
CONFIG_DIR="$BASE_DIR/config" # 后端配置目录 -> 容器内 /data/conf
|
||||
LOG_DIR="$BASE_DIR/log" # 后端日志目录 -> 容器内 /app/logs
|
||||
UPLOADS_DIR="$BASE_DIR/uploads" # 上传文件目录 -> 容器内 /app/uploads
|
||||
|
||||
FRONTEND_PORT=9501 # 前端 nginx 容器
|
||||
FRONTEND_PORT=9501 # admin 管理后台 nginx 容器
|
||||
BACKEND_PORT=9502 # 后端 API/WebSocket 容器
|
||||
LIVEWEB_PORT=9503 # live 用户端(PC 直播页) nginx 容器
|
||||
NETWORK="live-net"
|
||||
BACKEND_NAME="live-backend"
|
||||
FRONTEND_NAME="live-frontend"
|
||||
LIVEWEB_NAME="live-web"
|
||||
# --------------------------------------
|
||||
|
||||
TAG="${1:-latest}"
|
||||
BACKEND_IMAGE="$REGISTRY/$PROJECT/backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/$PROJECT/frontend:$TAG"
|
||||
LIVEWEB_IMAGE="$REGISTRY/$PROJECT/live-web:$TAG"
|
||||
|
||||
echo "==> 部署版本: $TAG"
|
||||
|
||||
@@ -47,21 +49,27 @@ if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 登录 Harbor(优先使用环境变量 HARBOR_USER / HARBOR_PASSWORD)
|
||||
# 3. 登录 Harbor;未传密码时复用服务器现有的 Docker 登录态
|
||||
HARBOR_USER="${HARBOR_USER:-$HARBOR_USER_DEFAULT}"
|
||||
HARBOR_PASSWORD="${HARBOR_PASSWORD:-$HARBOR_PASSWORD_DEFAULT}"
|
||||
echo "==> 登录 $REGISTRY"
|
||||
echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
HARBOR_LOGGED_IN=false
|
||||
if [ -n "${HARBOR_PASSWORD:-}" ]; then
|
||||
echo "==> 登录 $REGISTRY"
|
||||
echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||
HARBOR_LOGGED_IN=true
|
||||
else
|
||||
echo "==> 未提供 HARBOR_PASSWORD,复用服务器现有 Docker 登录态"
|
||||
fi
|
||||
|
||||
# 4. 拉取最新镜像
|
||||
echo "==> 拉取镜像"
|
||||
docker pull "$BACKEND_IMAGE"
|
||||
docker pull "$FRONTEND_IMAGE"
|
||||
docker pull "$LIVEWEB_IMAGE"
|
||||
|
||||
# 5. 重建容器
|
||||
echo "==> 重启容器"
|
||||
docker network create "$NETWORK" >/dev/null 2>&1 || true
|
||||
docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" 2>/dev/null || true
|
||||
docker rm -f "$BACKEND_NAME" "$FRONTEND_NAME" "$LIVEWEB_NAME" 2>/dev/null || true
|
||||
|
||||
# 容器内进程以 uid 1000 (kra) 运行,日志/上传目录需要写权限
|
||||
chown -R 1000:1000 "$LOG_DIR" "$UPLOADS_DIR" 2>/dev/null || \
|
||||
@@ -84,11 +92,21 @@ docker run -d --name "$FRONTEND_NAME" \
|
||||
-p "$FRONTEND_PORT:80" \
|
||||
"$FRONTEND_IMAGE"
|
||||
|
||||
docker logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
# live 用户端(PC 直播页): nginx 静态站点,主域名 / 反代到该容器
|
||||
docker run -d --name "$LIVEWEB_NAME" \
|
||||
--restart always \
|
||||
--network "$NETWORK" \
|
||||
-p "$LIVEWEB_PORT:80" \
|
||||
"$LIVEWEB_IMAGE"
|
||||
|
||||
if [ "$HARBOR_LOGGED_IN" = true ]; then
|
||||
docker logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "==> 部署完成"
|
||||
docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME"
|
||||
docker ps --filter "name=$BACKEND_NAME" --filter "name=$FRONTEND_NAME" --filter "name=$LIVEWEB_NAME"
|
||||
echo ""
|
||||
echo "前端容器: http://<服务器IP>:$FRONTEND_PORT"
|
||||
echo "admin 前端容器: http://<服务器IP>:$FRONTEND_PORT"
|
||||
echo "后端容器: http://<服务器IP>:$BACKEND_PORT"
|
||||
echo "live 前端容器: http://<服务器IP>:$LIVEWEB_PORT"
|
||||
echo "提示: 网站正常访问走宝塔 nginx 反代(80/443),9501/9502 为直连调试端口,记得放行防火墙。"
|
||||
|
||||
+22
-9
@@ -4,7 +4,7 @@
|
||||
|
||||
## WebSocket
|
||||
|
||||
连接现有 WebSocket 地址(默认 `/ws`),握手必须携带 GVA JWT:
|
||||
连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力:
|
||||
|
||||
```text
|
||||
wss://host/ws?token=<x-token>
|
||||
@@ -16,7 +16,7 @@ wss://host/ws?token=<x-token>
|
||||
{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}}
|
||||
```
|
||||
|
||||
角色支持 `publisher`、`subscriber`、`observer`。主播和观众完成 Join 后,浏览器发送 SDP offer:
|
||||
角色支持 `publisher`、`subscriber`、`observer`。`subscriber` 可匿名加入,`publisher` 必须登录且只能发布自己拥有的启用直播间。主播和观众完成 Join 后,浏览器发送 SDP offer:
|
||||
|
||||
```json
|
||||
{"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}}
|
||||
@@ -34,7 +34,6 @@ wss://host/ws?token=<x-token>
|
||||
- `SubscriberLeft`
|
||||
- `StreamListUpdate`
|
||||
- `Kicked`
|
||||
- `ChatHistory`:加入房间后返回最近 100 条聊天和礼物消息
|
||||
- `ChatMessage`:普通聊天广播
|
||||
- `GiftMessage`:送礼成功后的礼物广播
|
||||
|
||||
@@ -44,7 +43,7 @@ wss://host/ws?token=<x-token>
|
||||
{"type":"ChatSend","payload":{"content":"大家好"}}
|
||||
```
|
||||
|
||||
聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。
|
||||
聊天只允许已经以主播或观众身份加入该房间的登录用户发送,内容最多 50 个字符。消息只保存在当前服务进程内,每个房间最多 100 条;加入房间不会回放进入前的消息,开播、关播或服务重启都会清空该房间聊天。
|
||||
|
||||
主播和观众 45 秒没有心跳会被清理,直播房间最多 20 个;同一用户在同一房间重复观看时,旧连接会收到 `Kicked`。
|
||||
|
||||
@@ -55,34 +54,48 @@ wss://host/ws?token=<x-token>
|
||||
```text
|
||||
GET /live/streams
|
||||
GET /live/streams/:streamId
|
||||
GET /api/streams # 旧客户端兼容别名
|
||||
GET /live/categories
|
||||
GET /live/rooms?page=1&pageSize=20&categoryId=1&status=live
|
||||
GET /live/rooms?page=1&pageSize=20&categoryId=1&status=live&orderBy=viewer_count_desc
|
||||
GET /live/rooms/:id
|
||||
POST /live/rooms/:id/like
|
||||
GET /live/rooms/:id/messages?limit=100
|
||||
GET /live/gifts
|
||||
GET /live/recharge/providers
|
||||
GET /live/recharge/plans
|
||||
```
|
||||
|
||||
`/live/rooms` 默认只返回当前直播中的启用房间,并按在线人数倒序排列;支持 `categoryId`、`page`、`pageSize` 和 `orderBy` 筛选/排序。房间响应同时返回 `viewerCountText`(如 `5.1k`、`1.0w`)以及直播中的 `startedAt`。
|
||||
|
||||
聊天历史 HTTP 接口保留用于客户端契约兼容,但验证房间后始终返回空列表,不会向新进入用户泄露此前聊天。
|
||||
|
||||
PC 客户端账号接口:
|
||||
|
||||
```text
|
||||
POST /live/client/auth/register
|
||||
POST /live/client/auth/login
|
||||
POST /live/client/auth/logout
|
||||
GET /live/client/me
|
||||
GET /live/client/balance
|
||||
PUT /live/client/me
|
||||
PUT /live/client/me/password
|
||||
```
|
||||
|
||||
客户端账号使用系统的 `sys_users` 表,注册时创建普通直播角色并初始化 100 钻石。注册和修改密码都会返回新的 JWT 登录态。
|
||||
客户端账号使用系统的 `sys_users` 表。注册昵称可选,未填写时使用用户名,填写时限制为 2–16 个字符;新账号创建普通直播角色并初始化 100 钻石,历史账号缺少钱包时也补建 100 钻石。注册和修改密码都会返回新的 JWT 登录态。
|
||||
|
||||
登录后可调用:
|
||||
|
||||
```text
|
||||
GET /live/rooms/:id/follow
|
||||
POST /live/rooms/:id/follow
|
||||
DELETE /live/rooms/:id/follow
|
||||
POST /live/rooms/:id/gifts
|
||||
GET /live/recharge/plans
|
||||
POST /live/recharge/orders
|
||||
GET /live/recharge/orders/:orderNo
|
||||
GET /live/recharge/orders/:orderNo/status
|
||||
```
|
||||
|
||||
点赞不要求登录,点赞数只在当前进程的直播运行态中累加;关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。
|
||||
|
||||
创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识:
|
||||
|
||||
```json
|
||||
@@ -96,7 +109,7 @@ POST /live/recharge/orders
|
||||
|
||||
充值档位、用户和钻石数量保存在直播业务订单中;渠道交易号、支付状态、回调、退款状态和事件流水统一保存在 KRA 的 `pay_orders` / `pay_events`。支付确认后由通用支付发货流程幂等增加钻石余额。
|
||||
|
||||
礼物数量必须使用礼物接口返回的 `tiers`,扣款、礼物流水和钱包余额在同一事务内完成;成功后通过 `GiftMessage` 广播给房间内在线用户。
|
||||
礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。
|
||||
|
||||
全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"creation_time":"2026-09-21T06:30:55.339Z","expires_at":-1,"id":7,"name":"robot$live+live","secret":"R4p6ieacIogXE779eL1HXhCpA4bx4SA7"}
|
||||
@@ -2,10 +2,14 @@ package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,6 +21,24 @@ var (
|
||||
ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent")
|
||||
)
|
||||
|
||||
// InsufficientBalanceError preserves the wallet snapshot needed by clients
|
||||
// to decide whether to open the recharge flow. It unwraps to the stable
|
||||
// ErrInsufficientBalance reason so existing errors.Is checks keep working.
|
||||
type InsufficientBalanceError struct {
|
||||
Required int64
|
||||
Balance int64
|
||||
Missing int64
|
||||
}
|
||||
|
||||
func (e *InsufficientBalanceError) Error() string {
|
||||
if e == nil {
|
||||
return ErrInsufficientBalance.Error()
|
||||
}
|
||||
return fmt.Sprintf("insufficient diamond balance: need %d, have %d, missing %d", e.Required, e.Balance, e.Missing)
|
||||
}
|
||||
|
||||
func (e *InsufficientBalanceError) Unwrap() error { return ErrInsufficientBalance }
|
||||
|
||||
type Gift struct {
|
||||
ID uint
|
||||
Name, Icon, Description string
|
||||
@@ -72,13 +94,21 @@ type Repo interface {
|
||||
type Notifier interface {
|
||||
GiftSent(context.Context, *Send)
|
||||
}
|
||||
type roomAvailability interface {
|
||||
IsLive(context.Context, uint) (bool, error)
|
||||
}
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
notifier Notifier
|
||||
rooms roomAvailability
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, notifier Notifier) *Usecase {
|
||||
return &Usecase{repo: repo, notifier: notifier}
|
||||
func NewUsecase(repo Repo, notifier Notifier, rooms *bizlive.RoomUsecase) *Usecase {
|
||||
return newUsecase(repo, notifier, rooms)
|
||||
}
|
||||
|
||||
func newUsecase(repo Repo, notifier Notifier, rooms roomAvailability) *Usecase {
|
||||
return &Usecase{repo: repo, notifier: notifier, rooms: rooms}
|
||||
}
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) {
|
||||
f.Name = strings.TrimSpace(f.Name)
|
||||
@@ -115,6 +145,13 @@ func (uc *Usecase) Send(ctx context.Context, userID, roomID, giftID uint, quanti
|
||||
if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
if uc.rooms == nil {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
live, err := uc.rooms.IsLive(ctx, roomID)
|
||||
if err != nil || !live {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +24,7 @@ var (
|
||||
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
|
||||
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting")
|
||||
ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data")
|
||||
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
|
||||
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
|
||||
)
|
||||
|
||||
@@ -37,23 +40,26 @@ type Category struct {
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
ID uint
|
||||
Name string
|
||||
CategoryID uint
|
||||
CategoryName string
|
||||
CoverURL string
|
||||
Intro string
|
||||
Sort int
|
||||
AnchorUserID uint
|
||||
AnchorName string
|
||||
AnchorAvatar string
|
||||
StreamURL string
|
||||
ReplayURL string
|
||||
Enabled bool
|
||||
Status string
|
||||
ViewerCount int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uint
|
||||
Name string
|
||||
CategoryID uint
|
||||
CategoryName string
|
||||
CoverURL string
|
||||
Intro string
|
||||
Sort int
|
||||
AnchorUserID uint
|
||||
AnchorName string
|
||||
AnchorAvatar string
|
||||
StreamURL string
|
||||
ReplayURL string
|
||||
Enabled bool
|
||||
Status string
|
||||
ViewerCount int
|
||||
ViewerCountText string
|
||||
LikeCount int
|
||||
StartedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CategoryFilter struct {
|
||||
@@ -62,12 +68,13 @@ type CategoryFilter struct {
|
||||
}
|
||||
|
||||
type RoomFilter struct {
|
||||
Name string
|
||||
AnchorName string
|
||||
CategoryID uint
|
||||
Status string
|
||||
Enabled *bool
|
||||
CreatedAt []time.Time
|
||||
Name string
|
||||
AnchorName string
|
||||
CategoryID uint
|
||||
Status string
|
||||
Enabled *bool
|
||||
CategoryEnabled *bool
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
@@ -174,6 +181,17 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
|
||||
matched = append(matched, room)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") {
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ViewerCount == matched[j].ViewerCount {
|
||||
return matched[i].ID < matched[j].ID
|
||||
}
|
||||
if strings.EqualFold(options.OrderBy, "viewer_count_asc") {
|
||||
return matched[i].ViewerCount < matched[j].ViewerCount
|
||||
}
|
||||
return matched[i].ViewerCount > matched[j].ViewerCount
|
||||
})
|
||||
}
|
||||
total := int64(len(matched))
|
||||
if options.Offset >= len(matched) {
|
||||
return []*Room{}, total, nil
|
||||
@@ -196,6 +214,32 @@ func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
|
||||
return uc.attachRuntime(ctx, []*Room{room})[0], nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
|
||||
room, err := uc.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !room.Enabled || room.Status != RoomStatusLive {
|
||||
return nil, ErrRoomNotLive
|
||||
}
|
||||
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !category.Enabled {
|
||||
return nil, ErrRoomNotLive
|
||||
}
|
||||
return room, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
|
||||
room, err := uc.Get(ctx, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return room.Enabled && room.Status == RoomStatusLive, nil
|
||||
}
|
||||
|
||||
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
|
||||
if err := validateRoom(room); err != nil {
|
||||
return nil, err
|
||||
@@ -258,17 +302,41 @@ func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room
|
||||
for _, room := range rooms {
|
||||
if !room.Enabled {
|
||||
room.Status = RoomStatusDisabled
|
||||
room.ViewerCountText = formatViewerCount(room.ViewerCount)
|
||||
continue
|
||||
}
|
||||
room.Status = RoomStatusOffline
|
||||
if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline {
|
||||
room.Status = RoomStatusLive
|
||||
room.ViewerCount = stream.SubscriberCount
|
||||
room.LikeCount = stream.LikeCount
|
||||
if !stream.StartedAt.IsZero() {
|
||||
startedAt := stream.StartedAt
|
||||
room.StartedAt = &startedAt
|
||||
}
|
||||
}
|
||||
room.ViewerCountText = formatViewerCount(room.ViewerCount)
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
func formatViewerCount(value int) string {
|
||||
if value < 1000 {
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
return formatUint(uint(value))
|
||||
}
|
||||
if value >= 10000 {
|
||||
return formatDecimal(float64(value)/10000, "w")
|
||||
}
|
||||
return formatDecimal(float64(value)/1000, "k")
|
||||
}
|
||||
|
||||
func formatDecimal(value float64, suffix string) string {
|
||||
return fmt.Sprintf("%.1f%s", value, suffix)
|
||||
}
|
||||
|
||||
func roomStreamID(id uint) string { return "room-" + formatUint(id) }
|
||||
|
||||
func formatUint(value uint) string {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type catalogRepoStub struct {
|
||||
categories []*Category
|
||||
rooms []*Room
|
||||
}
|
||||
|
||||
func (*catalogRepoStub) ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *catalogRepoStub) GetCategory(_ context.Context, id uint) (*Category, error) {
|
||||
for _, category := range r.categories {
|
||||
if category.ID == id {
|
||||
copy := *category
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
func (*catalogRepoStub) CreateCategory(context.Context, *Category) (*Category, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*catalogRepoStub) UpdateCategory(context.Context, *Category) (*Category, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*catalogRepoStub) DeleteCategory(context.Context, uint) error { return nil }
|
||||
|
||||
func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
filtered := make([]*Room, 0, len(r.rooms))
|
||||
for _, room := range r.rooms {
|
||||
if filter.CategoryID > 0 && room.CategoryID != filter.CategoryID {
|
||||
continue
|
||||
}
|
||||
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
|
||||
continue
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
category, err := r.GetCategory(context.Background(), room.CategoryID)
|
||||
if err != nil || category.Enabled != *filter.CategoryEnabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
copy := *room
|
||||
filtered = append(filtered, ©)
|
||||
}
|
||||
total := int64(len(filtered))
|
||||
if options.Offset >= len(filtered) {
|
||||
return []*Room{}, total, nil
|
||||
}
|
||||
end := options.Offset + options.Limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
return filtered[options.Offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetRoom(_ context.Context, id uint) (*Room, error) {
|
||||
for _, room := range r.rooms {
|
||||
if room.ID == id {
|
||||
copy := *room
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
|
||||
func (*catalogRepoStub) CreateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
|
||||
func (*catalogRepoStub) UpdateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
|
||||
func (*catalogRepoStub) DeleteRoom(context.Context, uint) error { return nil }
|
||||
func (*catalogRepoStub) UpdateRoomEnabled(context.Context, uint, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (*catalogRepoStub) SaveRoomSort(context.Context, map[uint]int) error { return nil }
|
||||
|
||||
func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
|
||||
enabled := true
|
||||
catalog := &catalogRepoStub{rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 3, CategoryID: 1, Enabled: true},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
}}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true, SubscriberCount: 100},
|
||||
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 5600},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
items, total, err := rooms.List(context.Background(), RoomFilter{CategoryID: 1, Status: RoomStatusLive, Enabled: &enabled}, ListOptions{Offset: 1, Limit: 1, OrderBy: "viewer_count_desc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 1 || items[0].ID != 1 {
|
||||
t.Fatalf("live page total=%d items=%+v", total, items)
|
||||
}
|
||||
if items[0].ViewerCount != 100 || items[0].ViewerCountText != "100" || items[0].Status != RoomStatusLive {
|
||||
t.Fatalf("runtime fields = %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
|
||||
catalog := &catalogRepoStub{rooms: []*Room{
|
||||
{ID: 1, Enabled: true},
|
||||
{ID: 2, Enabled: true},
|
||||
{ID: 3, Enabled: false},
|
||||
}}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
|
||||
got, err := rooms.IsLive(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLive(%d) error = %v", roomID, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
|
||||
catalog := &catalogRepoStub{
|
||||
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
|
||||
rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 3, CategoryID: 1, Enabled: false},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
},
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
item, err := rooms.GetPublic(context.Background(), 1)
|
||||
if err != nil || item.ID != 1 || item.Status != RoomStatusLive {
|
||||
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err)
|
||||
}
|
||||
for _, roomID := range []uint{2, 3, 4} {
|
||||
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) {
|
||||
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive)
|
||||
}
|
||||
if _, err = rooms.Get(context.Background(), roomID); err != nil {
|
||||
t.Errorf("admin Get(%d) error=%v", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase)
|
||||
|
||||
@@ -27,6 +27,8 @@ type Stream struct {
|
||||
PublisherID uint
|
||||
PublisherOnline bool
|
||||
SubscriberCount int
|
||||
LikeCount int
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -37,6 +39,7 @@ type StreamRepo interface {
|
||||
UnregisterPublisher(context.Context, string, uint) error
|
||||
RegisterSubscriber(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterSubscriber(context.Context, string, uint) error
|
||||
Like(context.Context, string) (int, error)
|
||||
}
|
||||
|
||||
type StreamUsecase struct{ repo StreamRepo }
|
||||
@@ -79,6 +82,14 @@ func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID stri
|
||||
return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) Like(ctx context.Context, streamID string) (int, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" {
|
||||
return 0, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.Like(ctx, streamID)
|
||||
}
|
||||
|
||||
func normalizeStreamID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatViewerCount(t *testing.T) {
|
||||
tests := map[int]string{0: "0", 999: "999", 1000: "1.0k", 5600: "5.6k", 10000: "1.0w", 12500: "1.2w"}
|
||||
for input, want := range tests {
|
||||
if got := formatViewerCount(input); got != want {
|
||||
t.Fatalf("formatViewerCount(%d) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testRepo struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
@@ -41,6 +50,7 @@ func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, e
|
||||
return nil, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil }
|
||||
func (r *testRepo) Like(context.Context, string) (int, error) { return 0, nil }
|
||||
|
||||
func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
|
||||
repo := &testRepo{streams: make(map[string]*Stream)}
|
||||
|
||||
@@ -53,6 +53,7 @@ type Repo interface {
|
||||
ListRecent(context.Context, uint, int) ([]*Message, error)
|
||||
CreateChat(context.Context, uint, uint, string) (*Message, error)
|
||||
CreateGift(context.Context, *GiftSnapshot) (*Message, error)
|
||||
Clear(context.Context, uint) error
|
||||
}
|
||||
|
||||
type Usecase struct{ repo Repo }
|
||||
@@ -83,3 +84,10 @@ func (uc *Usecase) RecordGift(ctx context.Context, gift *GiftSnapshot) (*Message
|
||||
}
|
||||
return uc.repo.CreateGift(ctx, gift)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Clear(ctx context.Context, roomID uint) error {
|
||||
if roomID == 0 {
|
||||
return ErrRoomNotFound
|
||||
}
|
||||
return uc.repo.Clear(ctx, roomID)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type Account struct {
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username, Password, ConfirmPassword string
|
||||
Username, Nickname, Password, ConfirmPassword string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
@@ -84,6 +84,12 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st
|
||||
if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
in.Nickname = strings.TrimSpace(in.Nickname)
|
||||
if in.Nickname == "" {
|
||||
in.Nickname = in.Username
|
||||
} else if n := len([]rune(in.Nickname)); n < 2 || n > 16 {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,7 +101,7 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Username, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds)
|
||||
account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Nickname, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package liveclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz/system"
|
||||
)
|
||||
|
||||
type registerRepoStub struct {
|
||||
Repo
|
||||
account *Account
|
||||
}
|
||||
|
||||
func (r *registerRepoStub) Register(_ context.Context, account *Account, _ string, _ int64) (*Account, error) {
|
||||
copy := *account
|
||||
copy.UserID = 1
|
||||
copy.UUID = "client-uuid"
|
||||
copy.CreatedAt = time.Now()
|
||||
r.account = ©
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
type registerSecurityRepoStub struct{ system.SecurityRepo }
|
||||
|
||||
func (*registerSecurityRepoStub) SecurityConfig(context.Context) (*system.SecurityConfig, error) {
|
||||
return &system.SecurityConfig{PwdMinLength: 8}, nil
|
||||
}
|
||||
|
||||
type registerSettingsStub struct{ system.RuntimeSettings }
|
||||
|
||||
func (*registerSettingsStub) UseMultipoint() bool { return false }
|
||||
|
||||
type registerIssuerStub struct{ system.TokenIssuer }
|
||||
|
||||
func (*registerIssuerStub) IssueToken(*system.User, uint, bool, time.Duration) (*system.IssuedToken, error) {
|
||||
return &system.IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
|
||||
}
|
||||
|
||||
func (*registerIssuerStub) ReissueToken(claims *system.AuthClaims, _ uint) (*system.IssuedToken, error) {
|
||||
return &system.IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
|
||||
}
|
||||
|
||||
func (*registerIssuerStub) ParseToken(string) (*system.AuthClaims, error) { return nil, nil }
|
||||
|
||||
func newRegisterUsecase(repo *registerRepoStub) *Usecase {
|
||||
security := system.NewSecurityUsecase(®isterSecurityRepoStub{}, nil, ®isterSettingsStub{}, nil)
|
||||
auth := system.NewAuthenticationUsecase(nil, security, ®isterIssuerStub{}, nil)
|
||||
return NewUsecase(repo, security, auth, nil)
|
||||
}
|
||||
|
||||
func TestRegisterUsesTrimmedNickname(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
result, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Nickname: " 直播用户 ",
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Account.Nickname != "直播用户" || repo.account.Nickname != "直播用户" {
|
||||
t.Fatalf("nickname = %q, repo nickname = %q", result.Account.Nickname, repo.account.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDefaultsNicknameToUsername(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
result, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Account.Nickname != "viewer" {
|
||||
t.Fatalf("nickname = %q, want username fallback", result.Account.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsNicknameOutsideRange(t *testing.T) {
|
||||
for _, nickname := range []string{"A", "12345678901234567"} {
|
||||
t.Run(nickname, func(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
_, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Nickname: nickname,
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if !errors.Is(err, ErrInvalidAccount) {
|
||||
t.Fatalf("error = %v, want ErrInvalidAccount", err)
|
||||
}
|
||||
if repo.account != nil {
|
||||
t.Fatal("repository called for invalid nickname")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func toBiz(v *row) *biz.User {
|
||||
return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
|
||||
}
|
||||
func userOrder(v string) string {
|
||||
m := map[string]string{"id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
m := map[string]string{"id_asc": "sys_users.id ASC", "id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_asc": "sys_users.created_at ASC", "created_desc": "sys_users.created_at DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
if x := m[v]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -9,5 +9,14 @@ import (
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{})
|
||||
}}, {ID: "202609220003_live_wallet_backfill", Migrate: func(db *gorm.DB) error {
|
||||
// Historical client accounts predate live_wallets. Backfill only missing
|
||||
// rows with the same 100-diamond welcome balance as a new registration;
|
||||
// existing balances and administrative adjustments remain untouched.
|
||||
return db.Exec(`INSERT INTO live_wallets (user_id, diamond_balance, total_recharge_cents, total_spent_diamonds, created_at, updated_at)
|
||||
SELECT u.id, 100, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM sys_users u
|
||||
WHERE u.deleted_at IS NULL AND u.authority_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM live_wallets w WHERE w.user_id = u.id)`, 1000).Error
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -228,23 +228,13 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
|
||||
if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
allowedQuantity := false
|
||||
for _, tier := range decode(gift.Tiers) {
|
||||
if tier == quantity {
|
||||
allowedQuantity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowedQuantity {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: userID}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total := gift.Price * int64(quantity)
|
||||
if wallet.DiamondBalance < total {
|
||||
return bizgift.ErrInsufficientBalance
|
||||
return &bizgift.InsufficientBalanceError{Required: total, Balance: wallet.DiamondBalance, Missing: total - wallet.DiamondBalance}
|
||||
}
|
||||
rate := int64(10)
|
||||
var setting ledger.SettingPO
|
||||
@@ -292,18 +282,18 @@ func mapError(err error) error {
|
||||
return err
|
||||
}
|
||||
func giftOrder(v string) string {
|
||||
m := map[string]string{"sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC"}
|
||||
m := map[string]string{"sort_asc": "sort ASC", "sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC", "created_at_asc": "created_at ASC", "created_at_desc": "created_at DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "sort ASC,created_at DESC"
|
||||
return "sort ASC,created_at DESC,id ASC"
|
||||
}
|
||||
func sendOrder(v string) string {
|
||||
m := map[string]string{"created_at_asc": "created_at ASC", "price_asc": "gift_price ASC", "price_desc": "gift_price DESC", "quantity_asc": "quantity ASC", "quantity_desc": "quantity DESC", "total_asc": "diamond_total ASC", "total_desc": "diamond_total DESC"}
|
||||
m := map[string]string{"created_at_asc": "live_gift_sends.created_at ASC", "created_at_desc": "live_gift_sends.created_at DESC", "price_asc": "live_gift_sends.gift_price ASC", "price_desc": "live_gift_sends.gift_price DESC", "quantity_asc": "live_gift_sends.quantity ASC", "quantity_desc": "live_gift_sends.quantity DESC", "total_asc": "live_gift_sends.diamond_total ASC", "total_desc": "live_gift_sends.diamond_total DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "created_at DESC"
|
||||
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
|
||||
@@ -67,12 +67,12 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) {
|
||||
t.Fatalf("unsupported quantity error = %v", err)
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil {
|
||||
t.Fatalf("custom quantity should be accepted: %v", err)
|
||||
}
|
||||
var before ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 {
|
||||
t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err)
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 {
|
||||
t.Fatalf("wallet did not debit custom quantity: wallet=%+v err=%v", before, err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -81,7 +81,38 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
var sends int64
|
||||
db.Where("user_id = ?", 1).First(&after)
|
||||
db.Model(&ledger.GiftSendPO{}).Count(&sends)
|
||||
if after.DiamondBalance != 50 || after.TotalSpentDiamonds != 50 || sends != 1 {
|
||||
if after.DiamondBalance != 40 || after.TotalSpentDiamonds != 60 || sends != 2 {
|
||||
t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
custom := giftPO{Name: "玫瑰", Icon: "custom", Price: 99, Description: "admin configured", Tiers: "[1]", Sort: 88, Enabled: false}
|
||||
if err := db.Create(&custom).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&giftPO{}).Where("id = ?", custom.ID).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&giftPO{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 24 {
|
||||
t.Fatalf("gift count = %d, want 24", count)
|
||||
}
|
||||
var rose giftPO
|
||||
if err := db.Where("name = ?", "玫瑰").First(&rose).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rose.Icon != "custom" || rose.Price != 99 || rose.Sort != 88 || rose.Enabled {
|
||||
t.Fatalf("existing gift was overwritten: %+v", rose)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,59 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]giftPO{{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true}, {Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true}, {Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true}, {Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true}}).Error
|
||||
}}}
|
||||
}}, {ID: "202609220001_live_gift_catalog_seed", Migrate: seedDefaultCatalog}}
|
||||
}
|
||||
|
||||
// seedDefaultCatalog is intentionally additive. Existing installations may
|
||||
// have edited names, prices, icons, tiers, sort, or enabled state in the
|
||||
// administration UI; migration must never overwrite those choices. We only
|
||||
// insert a missing named gift so the client can rely on the six quick gifts
|
||||
// and the 24-item "more gifts" grid being available after an upgrade.
|
||||
func seedDefaultCatalog(db *gorm.DB) error {
|
||||
defaults := []giftPO{
|
||||
{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true},
|
||||
{Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true},
|
||||
{Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true},
|
||||
{Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true},
|
||||
{Name: "城堡", Icon: "🏰", Price: 1314, Description: "城堡降临,点亮直播间", Tiers: "[1,10,66,1314]", Sort: 5, Enabled: true},
|
||||
{Name: "皇冠", Icon: "👑", Price: 5200, Description: "皇冠加冕,彰显特别支持", Tiers: "[1,10,66,1314]", Sort: 6, Enabled: true},
|
||||
{Name: "爱心", Icon: "❤️", Price: 5, Description: "把喜欢送到主播身边", Tiers: "[1,10,66,1314]", Sort: 7, Enabled: true},
|
||||
{Name: "棒棒糖", Icon: "🍭", Price: 8, Description: "甜甜的支持送给你", Tiers: "[1,10,66,1314]", Sort: 8, Enabled: true},
|
||||
{Name: "咖啡", Icon: "☕", Price: 15, Description: "为主播续上一杯咖啡", Tiers: "[1,10,66,1314]", Sort: 9, Enabled: true},
|
||||
{Name: "蛋糕", Icon: "🍰", Price: 20, Description: "分享一份甜蜜祝福", Tiers: "[1,10,66,1314]", Sort: 10, Enabled: true},
|
||||
{Name: "气球", Icon: "🎈", Price: 30, Description: "让快乐在直播间起飞", Tiers: "[1,10,66,1314]", Sort: 11, Enabled: true},
|
||||
{Name: "钻戒", Icon: "💍", Price: 88, Description: "闪耀的心意送给你", Tiers: "[1,10,66,1314]", Sort: 12, Enabled: true},
|
||||
{Name: "麦克风", Icon: "🎤", Price: 99, Description: "为精彩歌声喝彩", Tiers: "[1,10,66,1314]", Sort: 13, Enabled: true},
|
||||
{Name: "吉他", Icon: "🎸", Price: 120, Description: "伴着旋律一起摇摆", Tiers: "[1,10,66,1314]", Sort: 14, Enabled: true},
|
||||
{Name: "花束", Icon: "💐", Price: 166, Description: "把鲜花和掌声送给主播", Tiers: "[1,10,66,1314]", Sort: 15, Enabled: true},
|
||||
{Name: "甜甜圈", Icon: "🍩", Price: 188, Description: "甜甜圈住每一份快乐", Tiers: "[1,10,66,1314]", Sort: 16, Enabled: true},
|
||||
{Name: "烟花", Icon: "🎆", Price: 233, Description: "烟花为精彩时刻喝彩", Tiers: "[1,10,66,1314]", Sort: 17, Enabled: true},
|
||||
{Name: "豪华游艇", Icon: "🛥️", Price: 520, Description: "乘风破浪驶入直播间", Tiers: "[1,10,66,520]", Sort: 18, Enabled: true},
|
||||
{Name: "直升机", Icon: "🚁", Price: 666, Description: "高调登场,气氛拉满", Tiers: "[1,10,66,666]", Sort: 19, Enabled: true},
|
||||
{Name: "热气球", Icon: "🎈", Price: 888, Description: "带着祝福飞向天空", Tiers: "[1,10,66,888]", Sort: 20, Enabled: true},
|
||||
{Name: "月亮", Icon: "🌙", Price: 999, Description: "月光陪伴每一个夜晚", Tiers: "[1,10,66,999]", Sort: 21, Enabled: true},
|
||||
{Name: "太阳", Icon: "☀️", Price: 1024, Description: "把热烈的能量送给主播", Tiers: "[1,10,66,1024]", Sort: 22, Enabled: true},
|
||||
{Name: "彩虹", Icon: "🌈", Price: 1888, Description: "愿每一次相遇都如彩虹", Tiers: "[1,10,66,1314]", Sort: 23, Enabled: true},
|
||||
{Name: "宝箱", Icon: "🎁", Price: 2888, Description: "打开宝箱收获惊喜", Tiers: "[1,10,66,1314]", Sort: 24, Enabled: true},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
var existing giftPO
|
||||
err := db.Unscoped().Where("name = ?", item.Name).First(&existing).Error
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
// A concurrent migration/admin write may win the unique race. The
|
||||
// named gift is present then, so leave its configuration untouched.
|
||||
var raced giftPO
|
||||
if db.Unscoped().Where("name = ?", item.Name).First(&raced).Error == nil {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,7 +174,8 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
query = query.Where("live_rooms.name LIKE ?", "%"+filter.Name+"%")
|
||||
value := "%" + filter.Name + "%"
|
||||
query = query.Where("live_rooms.name LIKE ? OR CAST(live_rooms.id AS CHAR) LIKE ?", value, value)
|
||||
}
|
||||
if filter.AnchorName != "" {
|
||||
value := "%" + filter.AnchorName + "%"
|
||||
@@ -186,6 +187,9 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
if filter.Enabled != nil {
|
||||
query = query.Where("live_rooms.enabled = ?", *filter.Enabled)
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
query = query.Where("live_categories.enabled = ?", *filter.CategoryEnabled)
|
||||
}
|
||||
if filter.Status == bizlive.RoomStatusDisabled {
|
||||
query = query.Where("live_rooms.enabled = ?", false)
|
||||
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
|
||||
|
||||
@@ -74,3 +74,39 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabledCategory := categoryPO{Name: "Games", Enabled: true}
|
||||
disabledCategory := categoryPO{Name: "Hidden", Enabled: false}
|
||||
if err := db.Create(&enabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&disabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&disabledCategory).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, room := range []*roomPO{
|
||||
{Name: "Visible", CategoryID: enabledCategory.ID, CoverURL: "visible.png", AnchorUserID: 2, Enabled: true},
|
||||
{Name: "Hidden", CategoryID: disabledCategory.ID, CoverURL: "hidden.png", AnchorUserID: 2, Enabled: true},
|
||||
} {
|
||||
if err := db.Create(room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
enabled := true
|
||||
rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(rooms) != 1 || rooms[0].Name != "Visible" {
|
||||
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -18,5 +18,7 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error
|
||||
}}, {ID: "202609220002_live_follow_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &followPO{})
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewStreamRepo,
|
||||
NewCatalogRepo,
|
||||
NewFollowRepo,
|
||||
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
|
||||
)
|
||||
|
||||
@@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI
|
||||
}
|
||||
state.stream.PublisherID = userID
|
||||
state.stream.PublisherOnline = true
|
||||
// A new broadcast starts with a fresh in-room like counter. Keeping the
|
||||
// stream state object is useful when viewers connected before the publisher,
|
||||
// but likes must never leak into the next broadcast session.
|
||||
state.stream.LikeCount = 0
|
||||
state.stream.StartedAt = time.Now()
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
@@ -75,6 +80,7 @@ func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, use
|
||||
}
|
||||
state.stream.PublisherID = 0
|
||||
state.stream.PublisherOnline = false
|
||||
state.stream.StartedAt = time.Time{}
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
if len(state.users) == 0 {
|
||||
delete(r.streams, streamID)
|
||||
@@ -121,6 +127,19 @@ func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, us
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) Like(_ context.Context, streamID string) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
|
||||
r.streams[streamID] = state
|
||||
}
|
||||
state.stream.LikeCount++
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return state.stream.LikeCount, nil
|
||||
}
|
||||
|
||||
func clone(stream *bizlive.Stream) *bizlive.Stream {
|
||||
if stream == nil {
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,7 @@ package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,9 +12,14 @@ import (
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
type repo struct {
|
||||
data Provider
|
||||
mu sync.RWMutex
|
||||
nextID uint
|
||||
rooms map[uint][]*biz.Message
|
||||
}
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data, rooms: make(map[uint][]*biz.Message)} }
|
||||
|
||||
type messagePO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
@@ -46,14 +51,16 @@ func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.M
|
||||
if err := r.ensureRoom(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []messagePO
|
||||
if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
r.mu.RLock()
|
||||
items := r.rooms[roomID]
|
||||
if len(items) > limit {
|
||||
items = items[len(items)-limit:]
|
||||
}
|
||||
out := make([]*biz.Message, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
out := make([]*biz.Message, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, cloneMessage(item))
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -65,11 +72,7 @@ func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}), nil
|
||||
}
|
||||
|
||||
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) {
|
||||
@@ -87,28 +90,28 @@ func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Mes
|
||||
if nickname == "" {
|
||||
nickname = user.Nickname
|
||||
}
|
||||
row := &messagePO{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}), nil
|
||||
}
|
||||
|
||||
func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error {
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var boundary messagePO
|
||||
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
|
||||
})
|
||||
func (r *repo) append(item *biz.Message) *biz.Message {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.nextID++
|
||||
item.ID = r.nextID
|
||||
item.CreatedAt = time.Now()
|
||||
items := append(r.rooms[item.RoomID], cloneMessage(item))
|
||||
if len(items) > 100 {
|
||||
items = append([]*biz.Message(nil), items[len(items)-100:]...)
|
||||
}
|
||||
r.rooms[item.RoomID] = items
|
||||
return cloneMessage(item)
|
||||
}
|
||||
|
||||
func (r *repo) Clear(_ context.Context, roomID uint) error {
|
||||
r.mu.Lock()
|
||||
delete(r.rooms, roomID)
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error {
|
||||
@@ -136,3 +139,11 @@ func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) {
|
||||
func toBiz(row *messagePO) *biz.Message {
|
||||
return &biz.Message{ID: row.ID, RoomID: row.RoomID, Type: row.Type, UserID: row.UserID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, Content: row.Content, GiftID: row.GiftID, GiftName: row.GiftName, GiftIcon: row.GiftIcon, Quantity: row.Quantity, DiamondTotal: row.DiamondTotal, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func cloneMessage(item *biz.Message) *biz.Message {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *item
|
||||
return ©
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&messagePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`,
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, deleted_at datetime)`,
|
||||
@@ -37,6 +34,22 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestChatHistoryIsProcessLocalNotPersisted(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
first := NewRepo(testProvider{db: db})
|
||||
if _, err := first.CreateChat(context.Background(), 7, 3, "current session"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewRepo(testProvider{db: db})
|
||||
items, err := second.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("chat leaked across runtime sessions: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatKeepsLatestHundredMessages(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
@@ -65,3 +78,21 @@ func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
|
||||
t.Fatalf("gift message = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearDropsRoomMessagesWhenBroadcastEnds(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err := repo.CreateChat(context.Background(), 7, 3, "before close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Clear(context.Background(), 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("messages remained after room close: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,18 @@ func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash
|
||||
|
||||
func clientRoutes() []struct{ method, path string } {
|
||||
return []struct{ method, path string }{
|
||||
{method: "POST", path: "/live/client/auth/logout"},
|
||||
{method: "GET", path: "/live/client/me"},
|
||||
{method: "GET", path: "/live/client/balance"},
|
||||
{method: "PUT", path: "/live/client/me"},
|
||||
{method: "PUT", path: "/live/client/me/password"},
|
||||
{method: "POST", path: "/live/rooms/:id/gifts"},
|
||||
{method: "GET", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/rooms/:id/follow"},
|
||||
{method: "DELETE", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/recharge/orders"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 22 {
|
||||
t.Fatalf("migration versions = %d, want 22", versions)
|
||||
if versions != 26 {
|
||||
t.Fatalf("migration versions = %d, want 26", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -179,7 +179,7 @@ func toBiz(v *row) *biz.Order {
|
||||
}
|
||||
|
||||
func orderBy(v string) string {
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"}
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC", "created_at_desc": "live_recharge_orders.created_at DESC", "id_asc": "live_recharge_orders.id ASC", "id_desc": "live_recharge_orders.id DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -206,6 +206,36 @@ func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetM
|
||||
})
|
||||
}
|
||||
|
||||
// GrantAuthorityAPIs idempotently grants an explicit API set to one authority.
|
||||
// Keeping this helper in data/system preserves ownership of the Casbin storage
|
||||
// shape while allowing module migrations to add narrowly scoped client routes.
|
||||
func GrantAuthorityAPIs(db *gorm.DB, authorityID uint, apis []platformmodule.API) error {
|
||||
if db == nil || authorityID == 0 || len(apis) == 0 || !db.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, api := range apis {
|
||||
method := strings.ToUpper(strings.TrimSpace(api.Method))
|
||||
path := strings.TrimSpace(api.Path)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
exists, err := policyExists(tx, authorityID, path, method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
rule := newPolicyRule(authorityID, path, method)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GrantMenuAPIs makes a visible administration menu usable by granting only
|
||||
// its explicitly supplied API set to authorities that already own the menu.
|
||||
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
|
||||
|
||||
@@ -34,6 +34,7 @@ type Manager struct {
|
||||
peers map[*melody.Session]*peer
|
||||
observers map[*melody.Session]struct{}
|
||||
nextSub atomic.Uint64
|
||||
nextGuest atomic.Uint64
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -53,16 +54,19 @@ type publishedTrack struct {
|
||||
}
|
||||
|
||||
type peer struct {
|
||||
session *melody.Session
|
||||
manager *Manager
|
||||
room *room
|
||||
streamID string
|
||||
userID uint
|
||||
role string
|
||||
subscriberID string
|
||||
pc *webrtc.PeerConnection
|
||||
lastBeat atomic.Int64
|
||||
cleanup sync.Once
|
||||
session *melody.Session
|
||||
manager *Manager
|
||||
room *room
|
||||
streamID string
|
||||
userID uint
|
||||
role string
|
||||
authenticated bool
|
||||
subscriberID string
|
||||
pcMu sync.RWMutex
|
||||
pc *webrtc.PeerConnection
|
||||
closing bool
|
||||
lastBeat atomic.Int64
|
||||
cleanup sync.Once
|
||||
}
|
||||
|
||||
type message struct {
|
||||
@@ -210,20 +214,28 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"})
|
||||
return
|
||||
}
|
||||
userID, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
userID, authenticated := sessionUserID(session)
|
||||
if role == "publisher" && !authenticated {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"})
|
||||
return
|
||||
}
|
||||
if role == "subscriber" && !authenticated {
|
||||
userID = m.anonymousViewerID()
|
||||
}
|
||||
if existing := m.peer(session); existing != nil {
|
||||
m.closePeer(existing, "rejoin")
|
||||
}
|
||||
r := m.getOrCreateRoom(streamID)
|
||||
if role == "publisher" {
|
||||
if err := m.authorizeRoomPublisher(streamID, userID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if err := m.authorizeRoomSubscriber(streamID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "ROOM_UNAVAILABLE", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
r := m.getOrCreateRoom(streamID)
|
||||
if role == "publisher" {
|
||||
r.mu.RLock()
|
||||
occupied := r.publisher != nil
|
||||
r.mu.RUnlock()
|
||||
@@ -235,7 +247,10 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role}
|
||||
if roomID, ok := roomIDFromStream(streamID); ok {
|
||||
_ = m.chat.Clear(context.Background(), roomID)
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: true}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.publisher = p
|
||||
@@ -244,7 +259,6 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
m.sendPublisherStatus(streamID, false, "publisher_waiting_for_offer")
|
||||
m.broadcastStreamList()
|
||||
return
|
||||
@@ -269,7 +283,7 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: authenticated, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.viewers[p] = struct{}{}
|
||||
@@ -278,14 +292,17 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.mu.Lock()
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": userID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
publicUserID := userID
|
||||
if !authenticated {
|
||||
publicUserID = 0
|
||||
}
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID, "mode": "pion-sfu"})
|
||||
m.sendPublisherStatusTo(session, streamID, ready, "publisher_offline")
|
||||
if ready {
|
||||
m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true})
|
||||
}
|
||||
if publisher := m.publisher(r); publisher != nil {
|
||||
m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": userID})
|
||||
m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID})
|
||||
}
|
||||
m.broadcastStreamList()
|
||||
}
|
||||
@@ -296,6 +313,10 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"})
|
||||
return
|
||||
}
|
||||
if !p.authenticated {
|
||||
m.send(session, "Error", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required for chat"})
|
||||
return
|
||||
}
|
||||
roomID, ok := roomIDFromStream(p.streamID)
|
||||
if !ok {
|
||||
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
|
||||
@@ -309,23 +330,6 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
m.broadcastRoom(p.streamID, "ChatMessage", chatMessagePayload(item))
|
||||
}
|
||||
|
||||
func (m *Manager) sendChatHistory(session *melody.Session, streamID string) {
|
||||
roomID, ok := roomIDFromStream(streamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := m.chat.ListRecent(context.Background(), roomID, 100)
|
||||
if err != nil {
|
||||
m.log("load live chat history failed", "room_id", roomID, "error", err)
|
||||
return
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
messages = append(messages, chatMessagePayload(item))
|
||||
}
|
||||
m.send(session, "ChatHistory", map[string]any{"roomId": roomID, "messages": messages})
|
||||
}
|
||||
|
||||
// GiftSent implements gift.Notifier. Gift persistence and wallet debit have
|
||||
// already committed; chat persistence is therefore best-effort and must never
|
||||
// turn a successful debit into a failed gift response.
|
||||
@@ -388,6 +392,23 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) authorizeRoomSubscriber(streamID string) error {
|
||||
if strings.HasPrefix(streamID, "room-") {
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New("invalid live room stream id")
|
||||
}
|
||||
if _, err = m.roomCatalog.GetPublic(context.Background(), uint(id)); err != nil {
|
||||
return errors.New("live room is unavailable")
|
||||
}
|
||||
}
|
||||
stream, err := m.streams.Get(context.Background(), streamID)
|
||||
if err != nil || stream == nil || !stream.PublisherOnline {
|
||||
return errors.New("publisher is offline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) {
|
||||
p := m.peer(session)
|
||||
if p == nil || (p.role != "publisher" && p.role != "subscriber") {
|
||||
@@ -398,20 +419,22 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_SDP", "message": "sdp is required"})
|
||||
return
|
||||
}
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
}
|
||||
pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()})
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p.pc = pc
|
||||
oldPC, installed := p.replacePeerConnection(pc)
|
||||
if !installed {
|
||||
_ = pc.Close()
|
||||
return
|
||||
}
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed {
|
||||
m.closePeer(p, "ice_"+state.String())
|
||||
}
|
||||
m.handleICEConnectionStateChange(p, pc, state)
|
||||
})
|
||||
if oldPC != nil {
|
||||
_ = oldPC.Close()
|
||||
}
|
||||
if p.role == "publisher" {
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
m.onPublisherTrack(p, track)
|
||||
@@ -448,6 +471,13 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleICEConnectionStateChange(p *peer, pc *webrtc.PeerConnection, state webrtc.ICEConnectionState) {
|
||||
if state != webrtc.ICEConnectionStateFailed && state != webrtc.ICEConnectionStateClosed {
|
||||
return
|
||||
}
|
||||
m.closePeerConnection(p, pc, "ice_"+state.String())
|
||||
}
|
||||
|
||||
func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) {
|
||||
codec := remote.Codec().RTPCodecCapability
|
||||
local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID)
|
||||
@@ -514,15 +544,26 @@ func (m *Manager) heartbeatLoop() {
|
||||
}
|
||||
|
||||
func (m *Manager) closePeer(p *peer, reason string) {
|
||||
m.closePeerConnection(p, nil, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) closePeerConnection(p *peer, candidate *webrtc.PeerConnection, reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
pc, ok := p.beginClose(candidate)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.cleanup.Do(func() {
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
if pc != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
if p.role == "publisher" {
|
||||
_ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID)
|
||||
if roomID, ok := roomIDFromStream(p.streamID); ok {
|
||||
_ = m.chat.Clear(context.Background(), roomID)
|
||||
}
|
||||
p.room.mu.Lock()
|
||||
tracks := make([]*publishedTrack, 0, len(p.room.tracks))
|
||||
for _, track := range p.room.tracks {
|
||||
@@ -566,6 +607,36 @@ func (m *Manager) closePeer(p *peer, reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *peer) replacePeerConnection(next *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) {
|
||||
p.pcMu.Lock()
|
||||
defer p.pcMu.Unlock()
|
||||
if p.closing {
|
||||
return nil, false
|
||||
}
|
||||
previous := p.pc
|
||||
p.pc = next
|
||||
return previous, true
|
||||
}
|
||||
|
||||
func (p *peer) hasPeerConnection(candidate *webrtc.PeerConnection) bool {
|
||||
p.pcMu.RLock()
|
||||
current := p.pc
|
||||
p.pcMu.RUnlock()
|
||||
return candidate != nil && current == candidate
|
||||
}
|
||||
|
||||
func (p *peer) beginClose(candidate *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) {
|
||||
p.pcMu.Lock()
|
||||
defer p.pcMu.Unlock()
|
||||
if p.closing || candidate != nil && p.pc != candidate {
|
||||
return nil, false
|
||||
}
|
||||
p.closing = true
|
||||
current := p.pc
|
||||
p.pc = nil
|
||||
return current, true
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreateRoom(streamID string) *room {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -685,6 +756,12 @@ func sessionUserID(session *melody.Session) (uint, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) anonymousViewerID() uint {
|
||||
// Allocate downward from the maximum uint so anonymous connections never
|
||||
// share the zero ID and cannot collide with ordinary auto-increment users.
|
||||
return ^uint(0) - uint(m.nextGuest.Add(1))
|
||||
}
|
||||
|
||||
func peerStreamID(m *Manager, session *melody.Session) string {
|
||||
if p := m.peer(session); p != nil {
|
||||
return p.streamID
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/olahol/melody"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
type liveHubStub struct {
|
||||
mu sync.Mutex
|
||||
messages map[*melody.Session][][]byte
|
||||
}
|
||||
|
||||
func (h *liveHubStub) Enabled() bool { return true }
|
||||
func (h *liveHubStub) Broadcast([]byte) error { return nil }
|
||||
func (h *liveHubStub) BroadcastBinary([]byte) error { return nil }
|
||||
func (h *liveHubStub) Sessions() ([]*platformws.Session, error) { return nil, nil }
|
||||
func (h *liveHubStub) Len() int { return 0 }
|
||||
func (h *liveHubStub) SendBinary(*platformws.Session, []byte) error { return nil }
|
||||
func (h *liveHubStub) OnMessage(func(*platformws.Session, []byte)) {}
|
||||
func (h *liveHubStub) OnBinaryMessage(func(*platformws.Session, []byte)) {}
|
||||
func (h *liveHubStub) OnConnect(func(*platformws.Session)) {}
|
||||
func (h *liveHubStub) OnDisconnect(func(*platformws.Session)) {}
|
||||
func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.messages == nil {
|
||||
h.messages = make(map[*melody.Session][][]byte)
|
||||
}
|
||||
h.messages[session] = append(h.messages[session], append([]byte(nil), message...))
|
||||
return nil
|
||||
}
|
||||
|
||||
type liveCatalogStub struct {
|
||||
bizlive.CatalogRepo
|
||||
room *bizlive.Room
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
copy := *s.room
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
|
||||
return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil
|
||||
}
|
||||
|
||||
type liveStreamStub struct {
|
||||
bizlive.StreamRepo
|
||||
mu sync.Mutex
|
||||
streams map[string]*bizlive.Stream
|
||||
users map[uint]struct{}
|
||||
registerCalls int
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stream := s.streams[streamID]
|
||||
if stream == nil {
|
||||
return &bizlive.Stream{StreamID: streamID}, nil
|
||||
}
|
||||
copy := *stream
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]*bizlive.Stream, 0, len(s.streams))
|
||||
for _, stream := range s.streams {
|
||||
if stream.PublisherOnline {
|
||||
copy := *stream
|
||||
items = append(items, ©)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registerCalls++
|
||||
if s.users == nil {
|
||||
s.users = make(map[uint]struct{})
|
||||
}
|
||||
if _, exists := s.users[userID]; exists {
|
||||
return nil, bizlive.ErrSubscriberExists
|
||||
}
|
||||
s.users[userID] = struct{}{}
|
||||
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
|
||||
}
|
||||
|
||||
func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager {
|
||||
streamUC := bizlive.NewStreamUsecase(streams)
|
||||
return &Manager{
|
||||
hub: &liveHubStub{},
|
||||
streams: streamUC,
|
||||
roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC),
|
||||
rooms: make(map[string]*room),
|
||||
peers: make(map[*melody.Session]*peer),
|
||||
observers: make(map[*melody.Session]struct{}),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
|
||||
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
|
||||
first := &melody.Session{Keys: map[string]any{}}
|
||||
second := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(first, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
manager.join(second, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
|
||||
p1, p2 := manager.peer(first), manager.peer(second)
|
||||
if p1 == nil || p2 == nil {
|
||||
t.Fatalf("anonymous subscribers did not join: first=%+v second=%+v", p1, p2)
|
||||
}
|
||||
if p1.authenticated || p2.authenticated || p1.userID == 0 || p2.userID == 0 || p1.userID == p2.userID {
|
||||
t.Fatalf("anonymous identities are not unique: first=%d second=%d", p1.userID, p2.userID)
|
||||
}
|
||||
if len(streams.users) != 2 {
|
||||
t.Fatalf("subscriber count = %d, want 2", len(streams.users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{err: bizlive.ErrRoomNotFound}, &liveStreamStub{})
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-999999"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 {
|
||||
t.Fatalf("unknown room created runtime state: peers=%d rooms=%d", len(manager.peers), len(manager.rooms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
|
||||
streams := &liveStreamStub{}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 {
|
||||
t.Fatalf("offline room created runtime state: peers=%d rooms=%d users=%d registrations=%d", len(manager.peers), len(manager.rooms), len(streams.users), streams.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousPublisherIsRejected(t *testing.T) {
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, Enabled: true}}, &liveStreamStub{})
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil {
|
||||
t.Fatal("anonymous publisher joined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
|
||||
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer oldPC.Close()
|
||||
newPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer newPC.Close()
|
||||
|
||||
p := &peer{pc: oldPC}
|
||||
replaced, installed := p.replacePeerConnection(newPC)
|
||||
if !installed || replaced != oldPC {
|
||||
t.Fatalf("replaced peer connection = %p, want %p", replaced, oldPC)
|
||||
}
|
||||
manager := &Manager{}
|
||||
manager.handleICEConnectionStateChange(p, oldPC, webrtc.ICEConnectionStateClosed)
|
||||
|
||||
cleanupAvailable := false
|
||||
p.cleanup.Do(func() { cleanupAvailable = true })
|
||||
if !cleanupAvailable {
|
||||
t.Fatal("superseded peer connection consumed peer cleanup")
|
||||
}
|
||||
if !p.hasPeerConnection(newPC) {
|
||||
t.Fatal("replacement peer connection is no longer active")
|
||||
}
|
||||
}
|
||||
|
||||
var _ platformws.Hub = (*liveHubStub)(nil)
|
||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 22 {
|
||||
t.Fatalf("module migrations = %d, want 22", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 26 {
|
||||
t.Fatalf("module migrations = %d, want 26", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 11 || apis != 50 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
|
||||
if menus != 11 || apis != 69 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/69", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -39,10 +39,24 @@ func Definition() module.Definition {
|
||||
steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.RemoveAdminMenus(db, "liveDashboard")
|
||||
}})
|
||||
steps = append(steps, migration.Step{ID: "202609220004_live_client_policies", Migrate: func(db *gorm.DB) error {
|
||||
// Existing live-client accounts do not pass through the registration
|
||||
// path after an upgrade, so add newly introduced client endpoints to
|
||||
// their role without replacing any administrator-managed policies.
|
||||
return datasystem.GrantAuthorityAPIs(db, 1000, []module.API{
|
||||
{Method: "POST", Path: "/live/client/auth/logout"},
|
||||
{Method: "GET", Path: "/live/client/balance"},
|
||||
{Method: "GET", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "POST", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "DELETE", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "GET", Path: "/live/recharge/orders/:orderNo"},
|
||||
{Method: "GET", Path: "/live/recharge/orders/:orderNo/status"},
|
||||
})
|
||||
}})
|
||||
return module.Definition{Name: "live", Migrations: steps, Surface: module.Surface{Menus: menus, APIs: apis}}
|
||||
}
|
||||
func APIs() []module.API {
|
||||
defs := []struct{ m, p, d string }{{"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}}
|
||||
defs := []struct{ m, p, d string }{{"GET", "/live/streams", "获取在线直播列表"}, {"GET", "/live/streams/:streamId", "获取直播间状态"}, {"GET", "/live/categories", "获取直播分类"}, {"GET", "/live/rooms", "分页获取热门直播间"}, {"GET", "/live/rooms/:id", "获取直播间详情"}, {"POST", "/live/rooms/:id/like", "直播间点赞"}, {"GET", "/live/rooms/:id/follow", "查询关注状态"}, {"POST", "/live/rooms/:id/follow", "关注直播间"}, {"DELETE", "/live/rooms/:id/follow", "取消关注"}, {"GET", "/live/rooms/:id/messages", "获取直播间聊天记录"}, {"GET", "/live/gifts", "获取可用礼物"}, {"GET", "/live/recharge/providers", "获取可用充值支付渠道"}, {"GET", "/live/recharge/plans", "获取充值档位"}, {"POST", "/live/client/auth/login", "直播用户登录"}, {"POST", "/live/client/auth/register", "直播用户注册"}, {"POST", "/live/client/auth/logout", "直播用户退出"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"GET", "/live/client/balance", "获取钻石余额"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"GET", "/live/recharge/orders/:orderNo", "查询我的充值订单"}, {"GET", "/live/recharge/orders/:orderNo/status", "查询我的充值支付状态"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}, {"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}}
|
||||
out := make([]module.API, 0, len(defs))
|
||||
for _, v := range defs {
|
||||
out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -84,17 +84,24 @@ var routes = map[string]routeValue{
|
||||
"GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"},
|
||||
"GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"},
|
||||
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
|
||||
"GET /api/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true},
|
||||
"GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true},
|
||||
"GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true},
|
||||
"GET /live/rooms/:id": {group: "宇宙直播", description: "获取直播间详情", public: true},
|
||||
"POST /live/rooms/:id/like": {group: "宇宙直播", description: "直播间点赞", public: true},
|
||||
"GET /live/rooms/:id/follow": {group: "宇宙直播", description: "查询关注状态"},
|
||||
"POST /live/rooms/:id/follow": {group: "宇宙直播", description: "关注主播"},
|
||||
"DELETE /live/rooms/:id/follow": {group: "宇宙直播", description: "取消关注"},
|
||||
"GET /live/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true},
|
||||
"GET /live/gifts": {group: "宇宙直播", description: "获取礼物", public: true},
|
||||
"GET /live/recharge/providers": {group: "宇宙直播", description: "获取可用充值支付渠道", public: true},
|
||||
"GET /live/recharge/plans": {group: "宇宙直播", description: "获取充值档位", public: true},
|
||||
"GET /live/recharge/orders/:orderNo": {group: "宇宙直播", description: "查询我的充值订单"},
|
||||
"GET /live/recharge/orders/:orderNo/status": {group: "宇宙直播", description: "查询我的充值支付状态"},
|
||||
"GET /live/settings": {group: "宇宙直播", description: "获取直播设置", public: true},
|
||||
"GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"},
|
||||
"GET /live/client/balance": {group: "宇宙直播", description: "获取直播用户钻石余额"},
|
||||
"GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"},
|
||||
"GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"},
|
||||
"GET /live/admin/rooms": {group: "宇宙直播", description: "查询直播间"},
|
||||
@@ -114,6 +121,7 @@ var routes = map[string]routeValue{
|
||||
"POST /live/recharge/orders": {group: "宇宙直播", description: "创建充值订单", audit: true},
|
||||
"POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true},
|
||||
"POST /live/client/auth/register": {group: "宇宙直播", description: "直播用户注册", public: true},
|
||||
"POST /live/client/auth/logout": {group: "宇宙直播", description: "直播用户退出", audit: true},
|
||||
"PUT /live/admin/categories/:id": {group: "宇宙直播", description: "更新直播分类", audit: true},
|
||||
"PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true},
|
||||
"PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true},
|
||||
|
||||
@@ -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
@@ -79,20 +79,21 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !middleware.AuthenticateWebSocket(c, auth) {
|
||||
if wsLogger != nil {
|
||||
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed",
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(),
|
||||
"has_token", hasToken, "origin", c.GetHeader("Origin"))
|
||||
}
|
||||
return
|
||||
}
|
||||
claims := middleware.Claims(c)
|
||||
keys := map[string]any{
|
||||
"request_id": contextString(c, "request_id"),
|
||||
"request_id": contextString(c, "request_id"),
|
||||
"authenticated": false,
|
||||
}
|
||||
if claims != nil {
|
||||
keys["user_id"] = claims.ID
|
||||
// Watching is public. A valid token enriches the connection; a stale
|
||||
// token is cleared and continues as anonymous so it cannot block video.
|
||||
if hasToken {
|
||||
if authenticated := middleware.AuthenticateWebSocketOptional(c, auth); !authenticated && wsLogger != nil {
|
||||
wsLogger.InfoContext(c.Request.Context(), "websocket token unavailable; continuing anonymously",
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"))
|
||||
}
|
||||
if claims := middleware.Claims(c); claims != nil {
|
||||
keys["user_id"] = claims.ID
|
||||
keys["authenticated"] = true
|
||||
}
|
||||
}
|
||||
if wsLogger != nil {
|
||||
wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/gift"
|
||||
)
|
||||
@@ -136,6 +138,16 @@ func (h *Gift) Send(c *gin.Context) {
|
||||
}
|
||||
v, e := h.s.Send(c.Request.Context(), claims.ID, id, in)
|
||||
if e != nil {
|
||||
var insufficient *bizgift.InsufficientBalanceError
|
||||
if errors.As(e, &insufficient) {
|
||||
Write(c, CodeError, gin.H{
|
||||
"reason": "insufficient_balance",
|
||||
"required": insufficient.Required,
|
||||
"balance": insufficient.Balance,
|
||||
"missing": insufficient.Missing,
|
||||
}, e.Error())
|
||||
return
|
||||
}
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
@@ -14,10 +15,89 @@ import (
|
||||
type Live struct {
|
||||
service *liveservice.StreamService
|
||||
catalog *liveservice.CatalogService
|
||||
follows *liveservice.FollowService
|
||||
}
|
||||
|
||||
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live {
|
||||
return &Live{service: service, catalog: catalog}
|
||||
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService, follows *liveservice.FollowService) *Live {
|
||||
return &Live{service: service, catalog: catalog, follows: follows}
|
||||
}
|
||||
|
||||
func (h *Live) FollowStatus(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
followed, err := h.follows.Status(c.Request.Context(), claims.ID, id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": followed})
|
||||
}
|
||||
|
||||
func (h *Live) Follow(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
followed, err := h.follows.Follow(c.Request.Context(), claims.ID, id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": followed})
|
||||
}
|
||||
|
||||
func (h *Live) Unfollow(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
if err := h.follows.Unfollow(c.Request.Context(), claims.ID, id); err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": false})
|
||||
}
|
||||
|
||||
func (h *Live) Like(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Do not create runtime stream state for arbitrary room IDs. The stream
|
||||
// repository is process-local, so validating the catalog first also keeps
|
||||
// bogus likes from accumulating forever.
|
||||
room, err := h.catalog.GetRoom(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
if !room.Enabled || room.Status != bizlive.RoomStatusLive {
|
||||
businessFail(c, bizlive.ErrRoomNotLive)
|
||||
return
|
||||
}
|
||||
count, err := h.service.Like(c.Request.Context(), fmt.Sprintf("room-%d", id))
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"likeCount": count})
|
||||
}
|
||||
|
||||
func (h *Live) List(c *gin.Context) {
|
||||
@@ -114,17 +194,27 @@ func (h *Live) PublicRooms(c *gin.Context) {
|
||||
in.PageSize = 20
|
||||
}
|
||||
if in.OrderBy == "" {
|
||||
in.OrderBy = "sort_asc"
|
||||
in.OrderBy = "viewer_count_desc"
|
||||
}
|
||||
enabled := true
|
||||
in.Enabled = &enabled
|
||||
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
|
||||
items, total, e := h.catalog.ListPublicRooms(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize})
|
||||
}
|
||||
func (h *Live) GetPublicRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.GetPublicRoom(c.Request.Context(), id)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) GetRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
@@ -203,15 +293,6 @@ func (h *Live) SaveRoomSort(c *gin.Context) {
|
||||
OK(c)
|
||||
}
|
||||
|
||||
func (h *Live) LegacyList(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "直播列表获取失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "streams": items})
|
||||
}
|
||||
|
||||
func (h *Live) Find(c *gin.Context) {
|
||||
item, err := h.service.Get(c.Request.Context(), c.Param("streamId"))
|
||||
if err != nil {
|
||||
|
||||
@@ -15,14 +15,14 @@ func (h *LiveChat) History(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindQuery[dto.LiveChatHistoryRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.History(c.Request.Context(), roomID, in.Limit)
|
||||
// Chat is intentionally scoped to the current live connection. Do not
|
||||
// replay rows persisted by an earlier viewer/session through HTTP. Still
|
||||
// validate the room so callers receive a useful not-found response.
|
||||
items, err := h.service.History(c.Request.Context(), roomID, 1)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items})
|
||||
_ = items
|
||||
OKWithData(c, gin.H{"list": []*dto.LiveChatMessageResponse{}})
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ func (h *LiveClient) Me(c *gin.Context) {
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) Balance(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Balance(c.Request.Context(), claims.ID)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) UpdateProfile(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
|
||||
@@ -9,6 +9,14 @@ import (
|
||||
type Recharge struct{ s *service.Service }
|
||||
|
||||
func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} }
|
||||
func (h *Recharge) Providers(c *gin.Context) {
|
||||
v, e := h.s.Providers(c.Request.Context())
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v})
|
||||
}
|
||||
func (h *Recharge) Plans(c *gin.Context) {
|
||||
v, e := h.s.Plans(c.Request.Context())
|
||||
if e != nil {
|
||||
@@ -48,6 +56,34 @@ func (h *Recharge) Get(c *gin.Context) {
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
|
||||
func (h *Recharge) GetMine(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
v, e := h.s.GetForUser(c.Request.Context(), claims.ID, c.Param("orderNo"))
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
|
||||
func (h *Recharge) PaymentStatus(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
v, e := h.s.PaymentStatus(c.Request.Context(), claims.ID, c.Param("orderNo"))
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Recharge) Create(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.RechargeOrderRequest](c)
|
||||
if !ok {
|
||||
|
||||
@@ -43,6 +43,33 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool {
|
||||
return authenticate(c, auth, true)
|
||||
}
|
||||
|
||||
// AuthenticateWebSocketOptional enriches a public WebSocket connection when
|
||||
// a valid token is available. Missing, expired, malformed, or revoked tokens
|
||||
// fall back to an anonymous session; invalid cookie state is cleared so a
|
||||
// stale login cannot prevent public live viewing.
|
||||
func AuthenticateWebSocketOptional(c *gin.Context, auth TokenAuthenticator) bool {
|
||||
token := RequestToken(c, true)
|
||||
if token == "" || auth == nil {
|
||||
return false
|
||||
}
|
||||
value, err, _ := refreshTokens.Do(token, func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), tokenAuthTimeout)
|
||||
defer cancel()
|
||||
return auth.AuthenticateToken(ctx, token)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.SetCookie(c, "x-token", "", -1)
|
||||
return false
|
||||
}
|
||||
authentication, ok := value.(*system.TokenAuthentication)
|
||||
if !ok || authentication == nil || authentication.Claims == nil {
|
||||
httpx.SetCookie(c, "x-token", "", -1)
|
||||
return false
|
||||
}
|
||||
applyTokenAuthentication(c, authentication)
|
||||
return true
|
||||
}
|
||||
|
||||
func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool {
|
||||
token := RequestToken(c, allowQueryToken)
|
||||
if token == "" {
|
||||
@@ -74,13 +101,17 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
|
||||
httpx.NoAuth(c, "无法处理此token")
|
||||
return false
|
||||
}
|
||||
applyTokenAuthentication(c, authentication)
|
||||
return true
|
||||
}
|
||||
|
||||
func applyTokenAuthentication(c *gin.Context, authentication *system.TokenAuthentication) {
|
||||
if authentication.Refreshed != nil {
|
||||
c.Header("new-token", authentication.Refreshed.Value)
|
||||
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10))
|
||||
httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
|
||||
}
|
||||
c.Set(claimsKey, authentication.Claims)
|
||||
return true
|
||||
}
|
||||
|
||||
// RequestToken extracts the authentication token accepted by HTTP handlers.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -79,3 +80,36 @@ func TestAuthenticateWebSocketSupportsBearerHeaderAndReportsInvalidToken(t *test
|
||||
t.Fatalf("invalid token status=%d, want %d", invalidRecorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateWebSocketOptionalFallsBackToAnonymous(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &tokenAuthenticatorStub{err: system.ErrTokenExpired}
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/ws", nil)
|
||||
context.Request.AddCookie(&http.Cookie{Name: "x-token", Value: "expired-token"})
|
||||
|
||||
if AuthenticateWebSocketOptional(context, stub) {
|
||||
t.Fatal("expired token was treated as authenticated")
|
||||
}
|
||||
if Claims(context) != nil {
|
||||
t.Fatalf("anonymous fallback retained claims: %#v", Claims(context))
|
||||
}
|
||||
if recorder.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("optional authentication interrupted public handshake: body=%s", recorder.Body.String())
|
||||
}
|
||||
if cookie := recorder.Header().Get("Set-Cookie"); !strings.Contains(cookie, "x-token=") || !strings.Contains(cookie, "Max-Age=0") {
|
||||
t.Fatalf("expired token cookie was not cleared: %q", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateWebSocketOptionalStoresValidClaims(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &tokenAuthenticatorStub{claims: &system.AuthClaims{ID: 9}}
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/ws?token=valid-token", nil)
|
||||
|
||||
if !AuthenticateWebSocketOptional(context, stub) || Claims(context) == nil || Claims(context).ID != 9 {
|
||||
t.Fatalf("valid optional token was not applied: claims=%#v", Claims(context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) {
|
||||
public.GET("/live/streams", h.List)
|
||||
public.GET("/live/streams/:streamId", h.Find)
|
||||
// Keep the old list endpoint as a compatibility alias for existing clients.
|
||||
public.GET("/api/streams", h.LegacyList)
|
||||
public.GET("/live/categories", h.PublicCategories)
|
||||
public.GET("/live/rooms", h.PublicRooms)
|
||||
public.GET("/live/rooms/:id", h.GetRoom)
|
||||
public.GET("/live/rooms/:id", h.GetPublicRoom)
|
||||
public.POST("/live/rooms/:id/like", h.Like)
|
||||
private.GET("/live/rooms/:id/follow", h.FollowStatus)
|
||||
private.POST("/live/rooms/:id/follow", h.Follow)
|
||||
private.DELETE("/live/rooms/:id/follow", h.Unfollow)
|
||||
private.GET("/live/admin/categories", h.ListCategories)
|
||||
private.POST("/live/admin/categories", h.CreateCategory)
|
||||
private.PUT("/live/admin/categories/:id", h.UpdateCategory)
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) {
|
||||
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient, session *handler.Session) {
|
||||
public.POST("/live/client/auth/login", h.Login)
|
||||
public.POST("/live/client/auth/register", h.Register)
|
||||
private.POST("/live/client/auth/logout", session.Logout)
|
||||
private.GET("/live/client/me", h.Me)
|
||||
private.GET("/live/client/balance", h.Balance)
|
||||
private.PUT("/live/client/me", h.UpdateProfile)
|
||||
private.PUT("/live/client/me/password", h.ChangePassword)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
)
|
||||
|
||||
func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) {
|
||||
public.GET("/live/recharge/providers", h.Providers)
|
||||
public.GET("/live/recharge/plans", h.Plans)
|
||||
private.GET("/live/recharge/orders/:orderNo", h.GetMine)
|
||||
private.GET("/live/recharge/orders/:orderNo/status", h.PaymentStatus)
|
||||
private.PUT("/live/admin/recharge/plans", h.SavePlans)
|
||||
private.GET("/live/admin/orders", h.List)
|
||||
private.GET("/live/admin/orders/:orderNo", h.Get)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
func() { RegisterPayment(private, public, r.handlers.Payment) },
|
||||
func() { RegisterLive(public, private, r.handlers.Live) },
|
||||
func() { RegisterLiveChat(public, r.handlers.LiveChat) },
|
||||
func() { RegisterLiveClient(public, private, r.handlers.LiveClient) },
|
||||
func() { RegisterLiveClient(public, private, r.handlers.LiveClient, r.handlers.Session) },
|
||||
func() { RegisterGift(public, private, r.handlers.Gift) },
|
||||
func() { RegisterRecharge(public, private, r.handlers.Recharge) },
|
||||
func() { RegisterAsset(private, r.handlers.Asset) },
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s *websocketAuthStub) AuthenticateToken(_ context.Context, token string) (
|
||||
|
||||
var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil)
|
||||
|
||||
func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
func TestWebSocketRouteAcceptsAnonymousAndAuthenticatedViewers(t *testing.T) {
|
||||
store := runtimeconfig.NewStore()
|
||||
raw, err := json.Marshal(map[string]any{"path": "/ws"})
|
||||
if err != nil {
|
||||
@@ -46,26 +46,35 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
|
||||
engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws)
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil))
|
||||
if unauthorized.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized websocket status=%d body=%s", unauthorized.Code, unauthorized.Body.String())
|
||||
}
|
||||
|
||||
httpServer := httptest.NewServer(engine)
|
||||
defer httpServer.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws?token=login-token"
|
||||
connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil)
|
||||
baseURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws"
|
||||
anonymous, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL, nil)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("authorized websocket handshake failed: %v", err)
|
||||
t.Fatalf("anonymous websocket handshake failed: %v", err)
|
||||
}
|
||||
if connection == nil {
|
||||
t.Fatal("authorized websocket handshake returned nil connection")
|
||||
if anonymous == nil {
|
||||
t.Fatal("anonymous websocket handshake returned nil connection")
|
||||
}
|
||||
_ = connection.Close()
|
||||
_ = anonymous.Close()
|
||||
if auth.got != "" {
|
||||
t.Fatalf("anonymous websocket unexpectedly authenticated token=%q", auth.got)
|
||||
}
|
||||
|
||||
authenticated, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL+"?token=login-token", nil)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("authenticated websocket handshake failed: %v", err)
|
||||
}
|
||||
if authenticated == nil {
|
||||
t.Fatal("authenticated websocket handshake returned nil connection")
|
||||
}
|
||||
_ = authenticated.Close()
|
||||
if auth.got != "login-token" {
|
||||
t.Fatalf("websocket auth token=%q, want login-token", auth.got)
|
||||
}
|
||||
|
||||
@@ -55,24 +55,27 @@ type LiveRoomRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type LiveRoomResponse struct {
|
||||
ID uint `json:"id"`
|
||||
StreamID string `json:"streamId"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AnchorUserID uint `json:"anchorUserId"`
|
||||
AnchorName string `json:"anchorName"`
|
||||
AnchorAvatar string `json:"anchorAvatar"`
|
||||
StreamURL string `json:"streamUrl"`
|
||||
ReplayURL string `json:"replayUrl"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
StreamID string `json:"streamId"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AnchorUserID uint `json:"anchorUserId"`
|
||||
AnchorName string `json:"anchorName"`
|
||||
AnchorAvatar string `json:"anchorAvatar"`
|
||||
StreamURL string `json:"streamUrl"`
|
||||
ReplayURL string `json:"replayUrl"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
ViewerCountText string `json:"viewerCountText"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
type LiveStatusRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
@@ -9,6 +9,7 @@ type LiveClientLoginRequest struct {
|
||||
|
||||
type LiveClientRegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
ConfirmPassword string `json:"confirmPassword" binding:"required"`
|
||||
}
|
||||
@@ -34,6 +35,10 @@ type LiveClientAccountResponse struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LiveClientBalanceResponse struct {
|
||||
DiamondBalance int64 `json:"diamondBalance"`
|
||||
}
|
||||
|
||||
type LiveClientAuthResponse struct {
|
||||
User *LiveClientAccountResponse `json:"user"`
|
||||
Token string `json:"token"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ type RechargePlan struct {
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type RechargeProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type RechargePlansRequest struct {
|
||||
Plans []*RechargePlan `json:"plans" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) {
|
||||
enabled := true
|
||||
items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: bizlive.RoomStatusLive, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
result := make([]*dto.LiveRoomResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, roomResponse(item))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) {
|
||||
item, err := s.rooms.Get(ctx, id)
|
||||
if err != nil {
|
||||
@@ -83,6 +95,13 @@ func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomRes
|
||||
}
|
||||
return roomResponse(item), nil
|
||||
}
|
||||
func (s *CatalogService) GetPublicRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) {
|
||||
item, err := s.rooms.GetPublic(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return roomResponse(item), nil
|
||||
}
|
||||
func liveRoomDO(id uint, input *dto.LiveRoomRequest) *bizlive.Room {
|
||||
return &bizlive.Room{ID: id, Name: input.Name, CategoryID: input.CategoryID, CoverURL: input.CoverURL, Intro: input.Intro, Sort: input.Sort, AnchorUserID: input.AnchorUserID, StreamURL: input.StreamURL, ReplayURL: input.ReplayURL, Enabled: input.Enabled}
|
||||
}
|
||||
@@ -113,5 +132,5 @@ func categoryResponse(item *bizlive.Category) *dto.LiveCategoryResponse {
|
||||
return &dto.LiveCategoryResponse{ID: item.ID, Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled, RoomCount: item.RoomCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
}
|
||||
func roomResponse(item *bizlive.Room) *dto.LiveRoomResponse {
|
||||
return &dto.LiveRoomResponse{ID: item.ID, StreamID: fmt.Sprintf("room-%d", item.ID), Name: item.Name, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, AnchorName: item.AnchorName, AnchorAvatar: item.AnchorAvatar, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled, Status: item.Status, ViewerCount: item.ViewerCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
return &dto.LiveRoomResponse{ID: item.ID, StreamID: fmt.Sprintf("room-%d", item.ID), Name: item.Name, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, AnchorName: item.AnchorName, AnchorAvatar: item.AnchorAvatar, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled, Status: item.Status, ViewerCount: item.ViewerCount, ViewerCountText: item.ViewerCountText, LikeCount: item.LikeCount, StartedAt: item.StartedAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService)
|
||||
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService)
|
||||
|
||||
@@ -30,3 +30,7 @@ func (s *StreamService) Get(ctx context.Context, streamID string) (*dto.LiveStre
|
||||
}
|
||||
return &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}, nil
|
||||
}
|
||||
|
||||
func (s *StreamService) Like(ctx context.Context, streamID string) (int, error) {
|
||||
return s.uc.Like(ctx, streamID)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Service struct{ uc *biz.Usecase }
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
|
||||
func (s *Service) Register(ctx context.Context, in *dto.LiveClientRegisterRequest, ip, agent string) (*dto.LiveClientAuthResponse, error) {
|
||||
result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent)
|
||||
result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Nickname: in.Nickname, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -35,6 +35,14 @@ func (s *Service) Me(ctx context.Context, userID uint) (*dto.LiveClientAccountRe
|
||||
return accountResponse(account), nil
|
||||
}
|
||||
|
||||
func (s *Service) Balance(ctx context.Context, userID uint) (*dto.LiveClientBalanceResponse, error) {
|
||||
account, err := s.uc.Get(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.LiveClientBalanceResponse{DiamondBalance: account.DiamondBalance}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uint, in *dto.LiveClientProfileRequest) (*dto.LiveClientAccountResponse, error) {
|
||||
account, err := s.uc.UpdateProfile(ctx, &biz.ProfileInput{UserID: userID, Nickname: in.Nickname, Avatar: in.Avatar})
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,48 @@ package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Service struct{ uc *biz.Usecase }
|
||||
type Service struct {
|
||||
uc *biz.Usecase
|
||||
integrations *integrationbiz.IntegrationConfigUsecase
|
||||
}
|
||||
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
func NewService(uc *biz.Usecase, integrations *integrationbiz.IntegrationConfigUsecase) *Service {
|
||||
return &Service{uc: uc, integrations: integrations}
|
||||
}
|
||||
|
||||
func (s *Service) Providers(ctx context.Context) ([]*dto.RechargeProvider, error) {
|
||||
configs, err := s.integrations.List(ctx, integrationbiz.IntegrationKindPayment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers := make([]*dto.RechargeProvider, 0, len(configs))
|
||||
seen := make(map[string]struct{}, len(configs))
|
||||
for _, config := range configs {
|
||||
if config == nil || !config.Enabled || !bizpayment.IsSupportedPaymentProvider(config.Provider) {
|
||||
continue
|
||||
}
|
||||
definition, ok := integrationbiz.IntegrationDefinition(integrationbiz.IntegrationKindPayment, config.Provider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[definition.Provider]; exists {
|
||||
continue
|
||||
}
|
||||
seen[definition.Provider] = struct{}{}
|
||||
providers = append(providers, &dto.RechargeProvider{ID: definition.Provider, Name: definition.Name})
|
||||
}
|
||||
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func dates(a, b string) []time.Time {
|
||||
if a == "" || b == "" {
|
||||
@@ -76,6 +108,30 @@ func (s *Service) Get(ctx context.Context, no string) (*dto.RechargeOrderRespons
|
||||
return order(v), nil
|
||||
}
|
||||
|
||||
// GetForUser is the client-facing order lookup. It deliberately maps an
|
||||
// order owned by another account to not-found so order numbers cannot be used
|
||||
// to enumerate another user's payment or diamond balance details.
|
||||
func (s *Service) GetForUser(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) {
|
||||
if userID == 0 {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
item, err := s.uc.Get(ctx, no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.UserID != userID {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
return order(item), nil
|
||||
}
|
||||
|
||||
// PaymentStatus is an explicit alias for client polling while a provider
|
||||
// payment is pending. The response includes both provider and fulfillment
|
||||
// state through the normal recharge order DTO.
|
||||
func (s *Service) PaymentStatus(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) {
|
||||
return s.GetForUser(ctx, userID, no)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID uint, in *dto.RechargeOrderRequest) (*dto.RechargeCheckoutResponse, error) {
|
||||
v, result, err := s.uc.Create(ctx, userID, in.PlanID, biz.Checkout{Provider: in.Provider, NotifyURL: in.NotifyURL, ReturnURL: in.ReturnURL, ClientIP: in.ClientIP, UserAgent: in.UserAgent, DeviceID: in.DeviceID, Extra: in.Extra})
|
||||
if err != nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`,
|
||||
export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
|
||||
|
||||
export const getRooms = (params) => get('/live/admin/rooms', params)
|
||||
export const getPublicRooms = () => get('/live/rooms')
|
||||
export const getPublicRooms = (params) => get('/live/rooms', params)
|
||||
export const getRoom = (id) => get(`/live/rooms/${id}`)
|
||||
export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params)
|
||||
export const createRoom = (data) => post('/live/admin/rooms', data)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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-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>
|
||||
</div>
|
||||
@@ -25,13 +26,13 @@ import { getUrl } from '@/utils/image'
|
||||
import ImageUploader from '../components/ImageUploader.vue'
|
||||
import { createCategory, deleteCategory, getCategories, updateCategory } from '../api/live'
|
||||
defineOptions({ name: 'LiveCategories' })
|
||||
const query = reactive({ name: '', enabled: undefined }), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref()
|
||||
const query = reactive({ name: '', enabled: undefined, orderBy: 'sort_asc' }), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), drawer = ref(false), saving = ref(false), editingId = ref(0), formRef = ref()
|
||||
const { width } = useWindowSize()
|
||||
const compactTable = computed(() => width.value < 1400)
|
||||
const form = reactive({ name: '', icon: '', sort: 0, enabled: true }), rules = { name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] }
|
||||
const isImage = (value) => /^(https?:|\/|data:)/.test(String(value || '')), format = (value) => value ? new Date(value).toLocaleString('zh-CN') : '-'
|
||||
const load = async () => { loading.value = true; try { const res = await getCategories({ page: page.value, pageSize: pageSize.value, orderBy: 'sort_asc', ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
|
||||
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { name: '', enabled: undefined }); search() }
|
||||
const load = async () => { loading.value = true; try { const res = await getCategories({ page: page.value, pageSize: pageSize.value, ...query }); if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0 } } finally { loading.value = false } }
|
||||
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { name: '', enabled: undefined, orderBy: 'sort_asc' }); search() }
|
||||
const openCreate = () => { editingId.value = 0; Object.assign(form, { name: '', icon: '', sort: 0, enabled: true }); drawer.value = true }, openEdit = (row) => { editingId.value = row.id; Object.assign(form, row); drawer.value = true }
|
||||
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateCategory(editingId.value, form) : await createCategory(form); if (res.code === 0) { ElMessage.success('分类已保存'); drawer.value = false; load() } } finally { saving.value = false } }
|
||||
const toggle = async (row, enabled) => { const res = await updateCategory(row.id, { name: row.name, icon: row.icon, sort: row.sort, enabled }); if (res.code === 0) { row.enabled = enabled; ElMessage.success(enabled ? '分类已启用' : '分类已停用') } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="live-page">
|
||||
<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 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>
|
||||
@@ -44,7 +44,7 @@ import ImageUploader from '../components/ImageUploader.vue'
|
||||
import { createGift, deleteGift, getGifts, getLiveSettings, saveGiftSort, setGiftStatus, updateGift } from '../api/live'
|
||||
|
||||
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 { width } = useWindowSize()
|
||||
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 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 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 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) })
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
</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="amount_desc" /><el-option label="钻石从高到低" value="diamonds_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>
|
||||
@@ -89,7 +90,7 @@ const providers = [
|
||||
{ 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 rows = ref([])
|
||||
const page = ref(1)
|
||||
@@ -125,7 +126,7 @@ const load = async () => {
|
||||
}
|
||||
}
|
||||
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 res = await getOrder(row.orderNo)
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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.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>
|
||||
</div>
|
||||
@@ -59,7 +60,7 @@ import VideoUploader from '../components/VideoUploader.vue'
|
||||
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
|
||||
|
||||
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 { width } = useWindowSize()
|
||||
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 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 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 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 } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<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-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">
|
||||
<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>
|
||||
@@ -23,12 +23,12 @@ import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live'
|
||||
import { ElMessage } from 'element-plus'
|
||||
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 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 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() })
|
||||
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 || '直播间不存在') }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
@@ -18,11 +18,11 @@ defineOptions({ name: 'LiveUsers' })
|
||||
const userStore = useUserStore(), currentUserId = computed(() => Number(userStore.userInfo?.ID || 0))
|
||||
const { width } = useWindowSize()
|
||||
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 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 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 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 }
|
||||
|
||||
Reference in New Issue
Block a user