This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 53e8599bdd
commit 0a8ecbe09b
75 changed files with 2299 additions and 276 deletions
+10
View File
@@ -46,3 +46,13 @@ web/node_modules/
# Local reference sources and desktop-only tooling state
/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/
+6 -3
View File
@@ -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
View File
@@ -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
+26 -1
View File
@@ -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}"
}
}
}
+25 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。
+1
View File
@@ -0,0 +1 @@
{"creation_time":"2026-09-21T06:30:55.339Z","expires_at":-1,"id":7,"name":"robot$live+live","secret":"R4p6ieacIogXE779eL1HXhCpA4bx4SA7"}
+40 -3
View File
@@ -2,10 +2,14 @@ package gift
import (
"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
+49
View File
@@ -0,0 +1,49 @@
package gift
import (
"context"
"errors"
"testing"
)
type sendRepoStub struct {
Repo
called bool
}
func (r *sendRepoStub) Send(_ context.Context, userID, roomID, giftID uint, quantity int) (*Send, error) {
r.called = true
return &Send{UserID: userID, RoomID: &roomID, GiftID: &giftID, Quantity: quantity}, nil
}
type availabilityStub struct {
live bool
err error
}
func (s availabilityStub) IsLive(context.Context, uint) (bool, error) { return s.live, s.err }
func TestSendRejectsOfflineRoomBeforeWalletMutation(t *testing.T) {
repo := &sendRepoStub{}
uc := newUsecase(repo, nil, availabilityStub{live: false})
if _, err := uc.Send(context.Background(), 1, 7, 3, 2); !errors.Is(err, ErrSendNotAllowed) {
t.Fatalf("offline send error = %v", err)
}
if repo.called {
t.Fatal("offline room reached gift repository")
}
}
func TestSendAcceptsCustomQuantityForLiveRoom(t *testing.T) {
repo := &sendRepoStub{}
uc := newUsecase(repo, nil, availabilityStub{live: true})
item, err := uc.Send(context.Background(), 1, 7, 3, 37)
if err != nil {
t.Fatal(err)
}
if !repo.called || item.Quantity != 37 {
t.Fatalf("custom quantity result = %+v called=%v", item, repo.called)
}
}
+91 -23
View File
@@ -2,6 +2,8 @@ package live
import (
"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 {
+160
View File
@@ -0,0 +1,160 @@
package live
import (
"context"
"errors"
"testing"
)
type catalogRepoStub struct {
categories []*Category
rooms []*Room
}
func (*catalogRepoStub) ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) {
return nil, 0, nil
}
func (r *catalogRepoStub) GetCategory(_ context.Context, id uint) (*Category, error) {
for _, category := range r.categories {
if category.ID == id {
copy := *category
return &copy, nil
}
}
return nil, ErrCategoryNotFound
}
func (*catalogRepoStub) CreateCategory(context.Context, *Category) (*Category, error) {
return nil, nil
}
func (*catalogRepoStub) UpdateCategory(context.Context, *Category) (*Category, error) {
return nil, nil
}
func (*catalogRepoStub) DeleteCategory(context.Context, uint) error { return nil }
func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
filtered := make([]*Room, 0, len(r.rooms))
for _, room := range r.rooms {
if filter.CategoryID > 0 && room.CategoryID != filter.CategoryID {
continue
}
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
continue
}
if filter.CategoryEnabled != nil {
category, err := r.GetCategory(context.Background(), room.CategoryID)
if err != nil || category.Enabled != *filter.CategoryEnabled {
continue
}
}
copy := *room
filtered = append(filtered, &copy)
}
total := int64(len(filtered))
if options.Offset >= len(filtered) {
return []*Room{}, total, nil
}
end := options.Offset + options.Limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[options.Offset:end], total, nil
}
func (r *catalogRepoStub) GetRoom(_ context.Context, id uint) (*Room, error) {
for _, room := range r.rooms {
if room.ID == id {
copy := *room
return &copy, nil
}
}
return nil, ErrRoomNotFound
}
func (*catalogRepoStub) CreateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
func (*catalogRepoStub) UpdateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
func (*catalogRepoStub) DeleteRoom(context.Context, uint) error { return nil }
func (*catalogRepoStub) UpdateRoomEnabled(context.Context, uint, bool) error {
return nil
}
func (*catalogRepoStub) SaveRoomSort(context.Context, map[uint]int) error { return nil }
func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
enabled := true
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: true},
{ID: 4, CategoryID: 2, Enabled: true},
}}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true, SubscriberCount: 100},
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 5600},
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
}})
rooms := NewRoomUsecase(catalog, streams)
items, total, err := rooms.List(context.Background(), RoomFilter{CategoryID: 1, Status: RoomStatusLive, Enabled: &enabled}, ListOptions{Offset: 1, Limit: 1, OrderBy: "viewer_count_desc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 1 || items[0].ID != 1 {
t.Fatalf("live page total=%d items=%+v", total, items)
}
if items[0].ViewerCount != 100 || items[0].ViewerCountText != "100" || items[0].Status != RoomStatusLive {
t.Fatalf("runtime fields = %+v", items[0])
}
}
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, Enabled: true},
{ID: 2, Enabled: true},
{ID: 3, Enabled: false},
}}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true},
"room-3": {StreamID: "room-3", PublisherOnline: true},
}})
rooms := NewRoomUsecase(catalog, streams)
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
got, err := rooms.IsLive(context.Background(), roomID)
if err != nil {
t.Fatalf("IsLive(%d) error = %v", roomID, err)
}
if got != want {
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
}
}
}
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true},
"room-3": {StreamID: "room-3", PublisherOnline: true},
"room-4": {StreamID: "room-4", PublisherOnline: true},
}})
rooms := NewRoomUsecase(catalog, streams)
item, err := rooms.GetPublic(context.Background(), 1)
if err != nil || item.ID != 1 || item.Status != RoomStatusLive {
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err)
}
for _, roomID := range []uint{2, 3, 4} {
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) {
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive)
}
if _, err = rooms.Get(context.Background(), roomID); err != nil {
t.Errorf("admin Get(%d) error=%v", roomID, err)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package live
import (
"context"
"errors"
"time"
kratoserrors "github.com/go-kratos/kratos/v3/errors"
)
var (
ErrFollowNotFound = kratoserrors.NotFound("LIVE_FOLLOW_NOT_FOUND", "follow relation not found")
ErrFollowInvalid = kratoserrors.BadRequest("LIVE_FOLLOW_INVALID_ARGUMENT", "invalid follow argument")
)
type Follow struct {
ID uint
UserID uint
RoomID uint
AnchorUserID uint
CreatedAt time.Time
}
type FollowRepo interface {
Get(context.Context, uint, uint) (*Follow, error)
Create(context.Context, *Follow) (*Follow, error)
Delete(context.Context, uint, uint) error
}
type FollowUsecase struct{ repo FollowRepo }
func NewFollowUsecase(repo FollowRepo) *FollowUsecase { return &FollowUsecase{repo: repo} }
func (uc *FollowUsecase) Get(ctx context.Context, userID, roomID uint) (*Follow, error) {
if userID == 0 || roomID == 0 {
return nil, ErrFollowInvalid
}
return uc.repo.Get(ctx, userID, roomID)
}
func (uc *FollowUsecase) Follow(ctx context.Context, userID, roomID uint) (*Follow, error) {
if userID == 0 || roomID == 0 {
return nil, ErrFollowInvalid
}
if existing, err := uc.repo.Get(ctx, userID, roomID); err == nil {
return existing, nil
} else if !errors.Is(err, ErrFollowNotFound) {
return nil, err
}
return uc.repo.Create(ctx, &Follow{UserID: userID, RoomID: roomID})
}
func (uc *FollowUsecase) Unfollow(ctx context.Context, userID, roomID uint) error {
if userID == 0 || roomID == 0 {
return ErrFollowInvalid
}
return uc.repo.Delete(ctx, userID, roomID)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase)
+11
View File
@@ -27,6 +27,8 @@ type Stream struct {
PublisherID uint
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)
}
+10
View File
@@ -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)}
+8
View File
@@ -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)
}
+8 -2
View File
@@ -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
}
+106
View File
@@ -0,0 +1,106 @@
package liveclient
import (
"context"
"errors"
"testing"
"time"
"kra/internal/biz/system"
)
type registerRepoStub struct {
Repo
account *Account
}
func (r *registerRepoStub) Register(_ context.Context, account *Account, _ string, _ int64) (*Account, error) {
copy := *account
copy.UserID = 1
copy.UUID = "client-uuid"
copy.CreatedAt = time.Now()
r.account = &copy
return &copy, nil
}
type registerSecurityRepoStub struct{ system.SecurityRepo }
func (*registerSecurityRepoStub) SecurityConfig(context.Context) (*system.SecurityConfig, error) {
return &system.SecurityConfig{PwdMinLength: 8}, nil
}
type registerSettingsStub struct{ system.RuntimeSettings }
func (*registerSettingsStub) UseMultipoint() bool { return false }
type registerIssuerStub struct{ system.TokenIssuer }
func (*registerIssuerStub) IssueToken(*system.User, uint, bool, time.Duration) (*system.IssuedToken, error) {
return &system.IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
}
func (*registerIssuerStub) ReissueToken(claims *system.AuthClaims, _ uint) (*system.IssuedToken, error) {
return &system.IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
func (*registerIssuerStub) ParseToken(string) (*system.AuthClaims, error) { return nil, nil }
func newRegisterUsecase(repo *registerRepoStub) *Usecase {
security := system.NewSecurityUsecase(&registerSecurityRepoStub{}, nil, &registerSettingsStub{}, nil)
auth := system.NewAuthenticationUsecase(nil, security, &registerIssuerStub{}, nil)
return NewUsecase(repo, security, auth, nil)
}
func TestRegisterUsesTrimmedNickname(t *testing.T) {
repo := &registerRepoStub{}
uc := newRegisterUsecase(repo)
result, err := uc.Register(context.Background(), &RegisterInput{
Username: "viewer",
Nickname: " 直播用户 ",
Password: "password",
ConfirmPassword: "password",
}, "", "")
if err != nil {
t.Fatal(err)
}
if result.Account.Nickname != "直播用户" || repo.account.Nickname != "直播用户" {
t.Fatalf("nickname = %q, repo nickname = %q", result.Account.Nickname, repo.account.Nickname)
}
}
func TestRegisterDefaultsNicknameToUsername(t *testing.T) {
repo := &registerRepoStub{}
uc := newRegisterUsecase(repo)
result, err := uc.Register(context.Background(), &RegisterInput{
Username: "viewer",
Password: "password",
ConfirmPassword: "password",
}, "", "")
if err != nil {
t.Fatal(err)
}
if result.Account.Nickname != "viewer" {
t.Fatalf("nickname = %q, want username fallback", result.Account.Nickname)
}
}
func TestRegisterRejectsNicknameOutsideRange(t *testing.T) {
for _, nickname := range []string{"A", "12345678901234567"} {
t.Run(nickname, func(t *testing.T) {
repo := &registerRepoStub{}
uc := newRegisterUsecase(repo)
_, err := uc.Register(context.Background(), &RegisterInput{
Username: "viewer",
Nickname: nickname,
Password: "password",
ConfirmPassword: "password",
}, "", "")
if !errors.Is(err, ErrInvalidAccount) {
t.Fatalf("error = %v, want ErrInvalidAccount", err)
}
if repo.account != nil {
t.Fatal("repository called for invalid nickname")
}
})
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ func toBiz(v *row) *biz.User {
return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
}
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
View File
@@ -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
}}}
}
+5 -15
View File
@@ -228,23 +228,13 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
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)
+36 -5
View File
@@ -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)
}
}
+55 -1
View File
@@ -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
}
+5 -1
View File
@@ -174,7 +174,8 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
Joins("LEFT JOIN 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 {
+36
View File
@@ -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)
}
}
+76
View File
@@ -0,0 +1,76 @@
package live
import (
"context"
"errors"
"gorm.io/gorm"
bizlive "kra/internal/biz/live"
)
type followPO struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
RoomID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
AnchorUserID uint `gorm:"not null;index"`
CreatedAt int64 `gorm:"autoCreateTime:milli"`
}
func (followPO) TableName() string { return "live_room_follows" }
type followRepo struct{ data Provider }
func NewFollowRepo(data Provider) bizlive.FollowRepo { return &followRepo{data: data} }
func (r *followRepo) Get(ctx context.Context, userID, roomID uint) (*bizlive.Follow, error) {
var row followPO
err := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, bizlive.ErrFollowNotFound
}
if err != nil {
return nil, err
}
return followToBiz(&row), nil
}
func (r *followRepo) Create(ctx context.Context, item *bizlive.Follow) (*bizlive.Follow, error) {
var room roomPO
if err := r.db(ctx).First(&room, item.RoomID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, bizlive.ErrRoomNotFound
}
return nil, err
}
item.AnchorUserID = room.AnchorUserID
po := &followPO{UserID: item.UserID, RoomID: item.RoomID, AnchorUserID: item.AnchorUserID}
if err := r.db(ctx).Create(po).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return r.Get(ctx, item.UserID, item.RoomID)
}
return nil, err
}
return followToBiz(po), nil
}
func (r *followRepo) Delete(ctx context.Context, userID, roomID uint) error {
result := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).Delete(&followPO{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return bizlive.ErrFollowNotFound
}
return nil
}
func (r *followRepo) db(ctx context.Context) *gorm.DB {
if r == nil || r.data == nil || r.data.DB() == nil {
return nil
}
return r.data.DB().WithContext(ctx)
}
func followToBiz(row *followPO) *bizlive.Follow {
return &bizlive.Follow{ID: row.ID, UserID: row.UserID, RoomID: row.RoomID, AnchorUserID: row.AnchorUserID}
}
+2
View File
@@ -18,5 +18,7 @@ func Migrations() []migration.Step {
return nil
}
return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error
}}, {ID: "202609220002_live_follow_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &followPO{})
}}}
}
+1
View File
@@ -9,5 +9,6 @@ import (
var ProviderSet = wire.NewSet(
NewStreamRepo,
NewCatalogRepo,
NewFollowRepo,
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
)
+19
View File
@@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI
}
state.stream.PublisherID = userID
state.stream.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
+45 -34
View File
@@ -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 &copy
}
+34 -3
View File
@@ -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)
}
}
+7
View File
@@ -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"},
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
}
+30
View File
@@ -206,6 +206,36 @@ func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetM
})
}
// GrantAuthorityAPIs idempotently grants an explicit API set to one authority.
// Keeping this helper in data/system preserves ownership of the Casbin storage
// shape while allowing module migrations to add narrowly scoped client routes.
func GrantAuthorityAPIs(db *gorm.DB, authorityID uint, apis []platformmodule.API) error {
if db == nil || authorityID == 0 || len(apis) == 0 || !db.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
for _, api := range apis {
method := strings.ToUpper(strings.TrimSpace(api.Method))
path := strings.TrimSpace(api.Path)
if method == "" || path == "" {
continue
}
exists, err := policyExists(tx, authorityID, path, method)
if err != nil {
return err
}
if exists {
continue
}
rule := newPolicyRule(authorityID, path, method)
if err := tx.Create(&rule).Error; err != nil {
return err
}
}
return nil
})
}
// GrantMenuAPIs makes a visible administration menu usable by granting only
// its explicitly supplied API set to authorities that already own the menu.
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
+122 -45
View File
@@ -34,6 +34,7 @@ type Manager struct {
peers map[*melody.Session]*peer
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
+201
View File
@@ -0,0 +1,201 @@
package live
import (
"context"
"sync"
"testing"
"github.com/olahol/melody"
"github.com/pion/webrtc/v4"
bizlive "kra/internal/biz/live"
platformws "kra/pkg/websocket"
)
type liveHubStub struct {
mu sync.Mutex
messages map[*melody.Session][][]byte
}
func (h *liveHubStub) Enabled() bool { return true }
func (h *liveHubStub) Broadcast([]byte) error { return nil }
func (h *liveHubStub) BroadcastBinary([]byte) error { return nil }
func (h *liveHubStub) Sessions() ([]*platformws.Session, error) { return nil, nil }
func (h *liveHubStub) Len() int { return 0 }
func (h *liveHubStub) SendBinary(*platformws.Session, []byte) error { return nil }
func (h *liveHubStub) OnMessage(func(*platformws.Session, []byte)) {}
func (h *liveHubStub) OnBinaryMessage(func(*platformws.Session, []byte)) {}
func (h *liveHubStub) OnConnect(func(*platformws.Session)) {}
func (h *liveHubStub) OnDisconnect(func(*platformws.Session)) {}
func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
h.mu.Lock()
defer h.mu.Unlock()
if h.messages == nil {
h.messages = make(map[*melody.Session][][]byte)
}
h.messages[session] = append(h.messages[session], append([]byte(nil), message...))
return nil
}
type liveCatalogStub struct {
bizlive.CatalogRepo
room *bizlive.Room
err error
}
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) {
if s.err != nil {
return nil, s.err
}
copy := *s.room
return &copy, nil
}
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil
}
type liveStreamStub struct {
bizlive.StreamRepo
mu sync.Mutex
streams map[string]*bizlive.Stream
users map[uint]struct{}
registerCalls int
}
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
stream := s.streams[streamID]
if stream == nil {
return &bizlive.Stream{StreamID: streamID}, nil
}
copy := *stream
return &copy, nil
}
func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
items := make([]*bizlive.Stream, 0, len(s.streams))
for _, stream := range s.streams {
if stream.PublisherOnline {
copy := *stream
items = append(items, &copy)
}
}
return items, nil
}
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.registerCalls++
if s.users == nil {
s.users = make(map[uint]struct{})
}
if _, exists := s.users[userID]; exists {
return nil, bizlive.ErrSubscriberExists
}
s.users[userID] = struct{}{}
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
}
func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager {
streamUC := bizlive.NewStreamUsecase(streams)
return &Manager{
hub: &liveHubStub{},
streams: streamUC,
roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC),
rooms: make(map[string]*room),
peers: make(map[*melody.Session]*peer),
observers: make(map[*melody.Session]struct{}),
stop: make(chan struct{}),
}
}
func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
first := &melody.Session{Keys: map[string]any{}}
second := &melody.Session{Keys: map[string]any{}}
manager.join(first, joinPayload{Role: "subscriber", StreamID: "room-7"})
manager.join(second, joinPayload{Role: "subscriber", StreamID: "room-7"})
p1, p2 := manager.peer(first), manager.peer(second)
if p1 == nil || p2 == nil {
t.Fatalf("anonymous subscribers did not join: first=%+v second=%+v", p1, p2)
}
if p1.authenticated || p2.authenticated || p1.userID == 0 || p2.userID == 0 || p1.userID == p2.userID {
t.Fatalf("anonymous identities are not unique: first=%d second=%d", p1.userID, p2.userID)
}
if len(streams.users) != 2 {
t.Fatalf("subscriber count = %d, want 2", len(streams.users))
}
}
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
manager := newAnonymousTestManager(&liveCatalogStub{err: bizlive.ErrRoomNotFound}, &liveStreamStub{})
session := &melody.Session{Keys: map[string]any{}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-999999"})
if manager.peer(session) != nil || len(manager.rooms) != 0 {
t.Fatalf("unknown room created runtime state: peers=%d rooms=%d", len(manager.peers), len(manager.rooms))
}
}
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
streams := &liveStreamStub{}
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
session := &melody.Session{Keys: map[string]any{}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 {
t.Fatalf("offline room created runtime state: peers=%d rooms=%d users=%d registrations=%d", len(manager.peers), len(manager.rooms), len(streams.users), streams.registerCalls)
}
}
func TestAnonymousPublisherIsRejected(t *testing.T) {
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, Enabled: true}}, &liveStreamStub{})
session := &melody.Session{Keys: map[string]any{}}
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
if manager.peer(session) != nil {
t.Fatal("anonymous publisher joined")
}
}
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
t.Fatal(err)
}
defer oldPC.Close()
newPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
t.Fatal(err)
}
defer newPC.Close()
p := &peer{pc: oldPC}
replaced, installed := p.replacePeerConnection(newPC)
if !installed || replaced != oldPC {
t.Fatalf("replaced peer connection = %p, want %p", replaced, oldPC)
}
manager := &Manager{}
manager.handleICEConnectionStateChange(p, oldPC, webrtc.ICEConnectionStateClosed)
cleanupAvailable := false
p.cleanup.Do(func() { cleanupAvailable = true })
if !cleanupAvailable {
t.Fatal("superseded peer connection consumed peer cleanup")
}
if !p.hasPeerConnection(newPC) {
t.Fatal("replacement peer connection is no longer active")
}
}
var _ platformws.Hub = (*liveHubStub)(nil)
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
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))
+15 -1
View File
@@ -39,10 +39,24 @@ func Definition() module.Definition {
steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error {
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})
+71
View File
@@ -0,0 +1,71 @@
package live
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestLiveClientPolicyMigrationAddsNewPrivateRoutes(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE casbin_rule (
id integer primary key autoincrement,
ptype text,
v0 text,
v1 text,
v2 text,
v3 text,
v4 text,
v5 text
)`).Error; err != nil {
t.Fatal(err)
}
var migrate func(*gorm.DB) error
for _, step := range Definition().Migrations {
if step.ID == "202609220004_live_client_policies" {
migrate = step.Migrate
break
}
}
if migrate == nil {
t.Fatal("live client policy migration is missing")
}
if err = migrate(db); err != nil {
t.Fatal(err)
}
if err = migrate(db); err != nil {
t.Fatalf("policy migration is not idempotent: %v", err)
}
want := []struct{ method, path string }{
{method: "POST", path: "/live/client/auth/logout"},
{method: "GET", path: "/live/client/balance"},
{method: "GET", path: "/live/rooms/:id/follow"},
{method: "POST", path: "/live/rooms/:id/follow"},
{method: "DELETE", path: "/live/rooms/:id/follow"},
{method: "GET", path: "/live/recharge/orders/:orderNo"},
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
}
for _, route := range want {
var count int64
if err = db.Table("casbin_rule").Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "1000", route.path, route.method).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("policy %s %s count = %d, want 1", route.method, route.path, count)
}
}
var publicProviderPolicies int64
if err = db.Table("casbin_rule").Where("v0 = ? AND v1 = ?", "1000", "/live/recharge/providers").Count(&publicProviderPolicies).Error; err != nil {
t.Fatal(err)
}
if publicProviderPolicies != 0 {
t.Fatalf("public provider discovery unexpectedly received a client policy: %d", publicProviderPolicies)
}
}
+9 -1
View File
@@ -84,17 +84,24 @@ var routes = map[string]routeValue{
"GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"},
"GET /logViewer/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
View File
@@ -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",
+12
View File
@@ -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
}
+96 -15
View File
@@ -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 {
+6 -6
View File
@@ -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{}})
}
+14
View File
@@ -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 {
+36
View File
@@ -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 {
+32 -1
View File
@@ -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.
+34
View File
@@ -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))
}
}
+5 -3
View File
@@ -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)
+3 -1
View File
@@ -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)
}
+3
View File
@@ -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)
+1 -1
View File
@@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
func() { RegisterPayment(private, public, r.handlers.Payment) },
func() { 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) },
+22 -13
View File
@@ -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)
}
+21 -18
View File
@@ -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"`
+5
View File
@@ -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"`
+16
View File
@@ -0,0 +1,16 @@
package dto
import (
"encoding/json"
"testing"
)
func TestLiveClientRegisterRequestDecodesNickname(t *testing.T) {
var request LiveClientRegisterRequest
if err := json.Unmarshal([]byte(`{"username":"viewer","nickname":"直播用户","password":"password","confirmPassword":"password"}`), &request); err != nil {
t.Fatal(err)
}
if request.Nickname != "直播用户" {
t.Fatalf("nickname = %q", request.Nickname)
}
}
+4
View File
@@ -9,6 +9,10 @@ type RechargePlan struct {
AmountCents int64 `json:"amountCents"`
Enabled bool `json:"enabled"`
}
type RechargeProvider struct {
ID string `json:"id"`
Name string `json:"name"`
}
type RechargePlansRequest struct {
Plans []*RechargePlan `json:"plans" binding:"required"`
}
+20 -1
View File
@@ -76,6 +76,18 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
}
return result, total, nil
}
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}
}
+32
View File
@@ -0,0 +1,32 @@
package live
import (
"context"
"errors"
bizlive "kra/internal/biz/live"
)
type FollowService struct{ uc *bizlive.FollowUsecase }
func NewFollowService(uc *bizlive.FollowUsecase) *FollowService { return &FollowService{uc: uc} }
func (s *FollowService) Status(ctx context.Context, userID, roomID uint) (bool, error) {
_, err := s.uc.Get(ctx, userID, roomID)
if err == nil {
return true, nil
}
if errors.Is(err, bizlive.ErrFollowNotFound) {
return false, nil
}
return false, err
}
func (s *FollowService) Follow(ctx context.Context, userID, roomID uint) (bool, error) {
_, err := s.uc.Follow(ctx, userID, roomID)
return err == nil, err
}
func (s *FollowService) Unfollow(ctx context.Context, userID, roomID uint) error {
return s.uc.Unfollow(ctx, userID, roomID)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService)
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService)
+4
View File
@@ -30,3 +30,7 @@ func (s *StreamService) Get(ctx context.Context, streamID string) (*dto.LiveStre
}
return &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}, nil
}
func (s *StreamService) Like(ctx context.Context, streamID string) (int, error) {
return s.uc.Like(ctx, streamID)
}
+9 -1
View File
@@ -12,7 +12,7 @@ type Service struct{ uc *biz.Usecase }
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
func (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)
}
}
+58 -2
View File
@@ -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 {
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/gorilla/websocket"
"gorm.io/gorm"
)
type signalMessage struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: live_smoke <seed|publisher|wscheck|inspect> ...")
}
var err error
switch os.Args[1] {
case "seed":
err = seed(os.Args[2:])
case "publisher":
err = publisher(os.Args[2:])
case "wscheck":
err = wscheck(os.Args[2:])
case "inspect":
err = inspect(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
log.Fatal(err)
}
}
func openDB(path string) (*gorm.DB, error) {
return gorm.Open(sqlite.Open(path), &gorm.Config{})
}
func seed(args []string) error {
if len(args) != 2 {
return errors.New("seed requires <db-dsn> <user-id>")
}
userID, err := strconv.ParseUint(args[1], 10, 64)
if err != nil || userID == 0 {
return errors.New("invalid user id")
}
db, err := openDB(args[0])
if err != nil {
return err
}
if result := db.Exec("UPDATE sys_integration_configs SET enabled = ?, updated_at = ? WHERE kind = ? AND provider = ?", true, time.Now(), "websocket", "melody"); result.Error != nil {
return result.Error
} else if result.RowsAffected != 1 {
return fmt.Errorf("websocket config update affected %d rows", result.RowsAffected)
}
var categoryID uint
if err = db.Raw("SELECT id FROM live_categories WHERE enabled = ? ORDER BY sort, id LIMIT 1", true).Scan(&categoryID).Error; err != nil {
return err
}
if categoryID == 0 {
return errors.New("no enabled live category")
}
name := fmt.Sprintf("Smoke Live Room %d", userID)
if err = db.Exec("DELETE FROM live_rooms WHERE name = ?", name).Error; err != nil {
return err
}
if err = db.Exec(`INSERT INTO live_rooms
(name, category_id, cover_url, intro, sort, anchor_user_id, stream_url, replay_url, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
name, categoryID, "", "isolated live smoke test", 1, uint(userID), "", "", true, time.Now(), time.Now()).Error; err != nil {
return err
}
var roomID uint
if err = db.Raw("SELECT id FROM live_rooms WHERE name = ? ORDER BY id DESC LIMIT 1", name).Scan(&roomID).Error; err != nil {
return err
}
if roomID == 0 {
return errors.New("room insert did not return an id")
}
fmt.Printf("{\"roomId\":%d,\"categoryId\":%d}\n", roomID, categoryID)
return nil
}
func publisher(args []string) error {
if len(args) != 3 {
return errors.New("publisher requires <ws-url> <cookie-file> <room-id>")
}
token, err := cookieToken(args[1])
if err != nil {
return err
}
connection, _, err := websocket.DefaultDialer.Dial(args[0]+"?token="+token, nil)
if err != nil {
return err
}
defer connection.Close()
if err = writeSignal(connection, "Join", map[string]any{"role": "publisher", "streamId": "room-" + args[2]}); err != nil {
return err
}
if _, err = readUntil(connection, "Joined", 5*time.Second); err != nil {
return err
}
fmt.Println("publisher joined")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err = writeSignal(connection, "Heartbeat", map[string]any{"streamId": "room-" + args[2]}); err != nil {
return err
}
}
return nil
}
func wscheck(args []string) error {
if len(args) != 3 {
return errors.New("wscheck requires <ws-url> <cookie-file> <room-id>")
}
streamID := "room-" + args[2]
anonymous, _, err := websocket.DefaultDialer.Dial(args[0], nil)
if err != nil {
return fmt.Errorf("anonymous dial: %w", err)
}
defer anonymous.Close()
if err = writeSignal(anonymous, "Join", map[string]any{"role": "subscriber", "streamId": streamID}); err != nil {
return err
}
if _, err = readUntil(anonymous, "Joined", 5*time.Second); err != nil {
return fmt.Errorf("anonymous join: %w", err)
}
if err = writeSignal(anonymous, "ChatSend", map[string]any{"content": "anonymous must fail"}); err != nil {
return err
}
message, err := readUntil(anonymous, "Error", 5*time.Second)
if err != nil {
return fmt.Errorf("anonymous chat result: %w", err)
}
if message.Payload["code"] != "AUTH_REQUIRED" {
return fmt.Errorf("anonymous chat code=%v", message.Payload["code"])
}
token, err := cookieToken(args[1])
if err != nil {
return err
}
authenticated, _, err := websocket.DefaultDialer.Dial(args[0]+"?token="+token, nil)
if err != nil {
return fmt.Errorf("authenticated dial: %w", err)
}
defer authenticated.Close()
if err = writeSignal(authenticated, "Join", map[string]any{"role": "subscriber", "streamId": streamID}); err != nil {
return err
}
if _, err = readUntil(authenticated, "Joined", 5*time.Second); err != nil {
return fmt.Errorf("authenticated join: %w", err)
}
content := "smoke chat 2026-09-22"
if err = writeSignal(authenticated, "ChatSend", map[string]any{"content": content}); err != nil {
return err
}
message, err = readUntil(authenticated, "ChatMessage", 5*time.Second)
if err != nil {
return fmt.Errorf("authenticated chat result: %w", err)
}
if message.Payload["content"] != content {
return fmt.Errorf("chat content=%v", message.Payload["content"])
}
fmt.Println("anonymous join/auth rejection and authenticated chat broadcast passed")
return nil
}
func inspect(args []string) error {
if len(args) != 3 {
return errors.New("inspect requires <db-dsn> <user-id> <room-id>")
}
db, err := openDB(args[0])
if err != nil {
return err
}
userID, err := strconv.ParseUint(args[1], 10, 64)
if err != nil {
return err
}
roomID, err := strconv.ParseUint(args[2], 10, 64)
if err != nil {
return err
}
result := map[string]int64{}
queries := map[string]struct {
query string
args []any
}{
"balance": {"SELECT diamond_balance FROM live_user_wallets WHERE user_id = ?", []any{userID}},
"followCount": {"SELECT COUNT(*) FROM live_room_follows WHERE user_id = ? AND room_id = ?", []any{userID, roomID}},
"giftSendCount": {"SELECT COUNT(*) FROM live_gift_sends WHERE user_id = ? AND room_id = ?", []any{userID, roomID}},
"giftDiamonds": {"SELECT COALESCE(SUM(diamond_total), 0) FROM live_gift_sends WHERE user_id = ? AND room_id = ?", []any{userID, roomID}},
"chatRows": {"SELECT COUNT(*) FROM live_chat_messages WHERE room_id = ?", []any{roomID}},
"rechargeCount": {"SELECT COUNT(*) FROM live_recharge_orders WHERE user_id = ?", []any{userID}},
}
for key, item := range queries {
var value int64
if err = db.Raw(item.query, item.args...).Scan(&value).Error; err != nil {
return fmt.Errorf("%s: %w", key, err)
}
result[key] = value
}
encoded, _ := json.Marshal(result)
fmt.Println(string(encoded))
return nil
}
func writeSignal(connection *websocket.Conn, kind string, payload map[string]any) error {
return connection.WriteJSON(signalMessage{Type: kind, Payload: payload})
}
func readUntil(connection *websocket.Conn, kind string, timeout time.Duration) (*signalMessage, error) {
if err := connection.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return nil, err
}
for {
var message signalMessage
if err := connection.ReadJSON(&message); err != nil {
return nil, err
}
if message.Type == kind {
return &message, nil
}
if message.Type == "JoinRejected" {
return nil, fmt.Errorf("join rejected: %v", message.Payload)
}
}
}
func cookieToken(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
for _, line := range strings.Split(string(content), "\n") {
fields := strings.Fields(line)
if len(fields) >= 7 && fields[5] == "x-token" {
return fields[6], nil
}
}
return "", errors.New("x-token cookie not found")
}
+1 -1
View File
@@ -15,7 +15,7 @@ export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`,
export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
export const 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 -3
View File
@@ -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 ? '分类已启用' : '分类已停用') } }
+3 -3
View File
@@ -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) })
+3 -2
View File
@@ -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) {
+3 -2
View File
@@ -6,6 +6,7 @@
<el-form-item label="主播"><el-input v-model="query.anchorName" clearable placeholder="主播昵称" /></el-form-item>
<el-form-item label="分类"><el-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 } }
+3 -3
View File
@@ -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 || '直播间不存在') }
+3 -3
View File
@@ -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 }