This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 0a8ecbe09b
commit 435b601273
42 changed files with 2845 additions and 154 deletions
@@ -0,0 +1,42 @@
- generic [ref=e3]:
- banner [ref=e4]:
- generic [ref=e5]:
- link "宇宙大派队 宇宙大派队" [ref=e6] [cursor=pointer]:
- /url: "#/"
- img "宇宙大派队" [ref=e7]
- generic [ref=e8]: 宇宙大派队
- navigation [ref=e9]:
- link "首页" [ref=e10] [cursor=pointer]:
- /url: "#/"
- link "热门直播" [ref=e11] [cursor=pointer]:
- /url: "#/hot"
- link "公会合作" [ref=e12] [cursor=pointer]:
- /url: "#/guild"
- link "联系我们" [ref=e13] [cursor=pointer]:
- /url: "#/contact"
- link " 登录" [ref=e14] [cursor=pointer]:
- /url: "#"
- generic [ref=e15]:
- text: 登录
- main [ref=e16]:
- generic [ref=e17]:
- heading "🔥 热门直播" [level=1] [ref=e18]
- paragraph [ref=e19]: 大家都在看 · 即刻加入狂欢,发现当前最火热的直播间
- tablist "直播分区" [ref=e20]:
- tab "全部" [selected] [ref=e21] [cursor=pointer]
- tab "游戏竞技" [ref=e22] [cursor=pointer]
- tab "音乐区" [ref=e23] [cursor=pointer]
- tab "闲聊电台" [ref=e24] [cursor=pointer]
- tab "舞蹈才艺" [ref=e25] [cursor=pointer]
- tab "娱乐直播" [ref=e26] [cursor=pointer]
- generic [ref=e27]: 当前分区暂无直播间
- contentinfo [ref=e28]:
- generic [ref=e30]:
- paragraph [ref=e31]: 公司名称:泉州宇宙派队网络科技有限公司
- paragraph [ref=e32]: 地址:福建省泉州市丰泽区菲莉大厦1801、1802、1803室
- paragraph [ref=e33]: 客服邮箱:326522981@qq.com
- paragraph [ref=e34]: 商务合作:13328578621
- paragraph [ref=e35]:
- link "闽ICP备2024031087号" [ref=e36] [cursor=pointer]:
- /url: https://beian.miit.gov.cn/
- text:                            
+1 -1
View File
@@ -134,7 +134,7 @@ admin:
- http - http
- sql - sql
cors: cors:
mode: whitelist mode: allow-all
whitelist: [] whitelist: []
# Example: # Example:
# whitelist: # whitelist:
+34 -5
View File
@@ -4,7 +4,7 @@
## WebSocket ## WebSocket
连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力: 连接现有 WebSocket 地址(后端默认 `/ws`,生产反向代理地址为 `/api/ws`)。观看直播允许匿名握手;PC 登录后的 HttpOnly Cookie 会随同源握手自动发送,分享链接中不携带凭证。非浏览器客户端也可携带 GVA JWT:
```text ```text
wss://host/ws?token=<x-token> wss://host/ws?token=<x-token>
@@ -16,7 +16,7 @@ wss://host/ws?token=<x-token>
{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}} {"type":"Join","payload":{"role":"publisher","streamId":"room-1"}}
``` ```
角色支持 `publisher`、`subscriber`、`observer`。`subscriber` 可匿名加入,`publisher` 必须登录且只能发布自己拥有的启用直播间。主播和观众完成 Join 后,浏览器发送 SDP offer: 角色支持 `publisher`、`subscriber`、`observer`。`subscriber` 可匿名加入正在直播的房间;离线录播直接使用 HTTP 视频地址,不创建 WebRTC 观众连接。`publisher` 必须登录且只能发布自己绑定的启用直播间,分类也必须启用;允许从未开播状态开始推流。主播和观众完成 Join 后,浏览器发送 SDP offer:
```json ```json
{"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}} {"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}}
@@ -55,7 +55,7 @@ wss://host/ws?token=<x-token>
GET /live/streams GET /live/streams
GET /live/streams/:streamId GET /live/streams/:streamId
GET /live/categories GET /live/categories
GET /live/rooms?page=1&pageSize=20&categoryId=1&status=live&orderBy=viewer_count_desc GET /live/rooms?page=1&pageSize=20&categoryId=1&orderBy=viewer_count_desc
GET /live/rooms/:id GET /live/rooms/:id
POST /live/rooms/:id/like POST /live/rooms/:id/like
GET /live/rooms/:id/messages?limit=100 GET /live/rooms/:id/messages?limit=100
@@ -64,7 +64,11 @@ GET /live/recharge/providers
GET /live/recharge/plans GET /live/recharge/plans
``` ```
`/live/rooms` 默认只返回当前直播中的启用房间,并按在线人数倒序排列;支持 `categoryId`、`page`、`pageSize` 和 `orderBy` 筛选/排序。房间响应同时返回 `viewerCountText`(如 `5.1k`、`1.0w`)以及直播中的 `startedAt`。 `/live/rooms` 默认返回所有公开启用的房间,包括直播中、配置录播的离线房间、尚未开播的房间。停用房间、停用或已删除分类下的房间不公开。接口按在线人数倒序排列,再分页;支持 `categoryId`、`page`、`pageSize`、`orderBy`,显式传入 `status=live` 或 `status=offline` 时才限制直播状态。
`/live/categories` 的返回记录就是 PC 分类来源,客户端只额外增加“全部”,按分类 ID 筛选,不依赖固定分类名称。列表直接展示接口的 `viewerCountText`(如 `5.1k`、`1.0w`)。
`GET /live/rooms/:id` 同样允许访问公开的离线房间,并返回 `replayUrl`;未开播不再返回 `LIVE_ROOM_NOT_LIVE`。客户端在 `status=live` 时使用直播流或 WebRTC,离线时使用 `replayUrl`,未配置录播则显示封面和未开播状态。停用房间返回 `LIVE_ROOM_DISABLED`,停用分类返回 `LIVE_CATEGORY_DISABLED`。
聊天历史 HTTP 接口保留用于客户端契约兼容,但验证房间后始终返回空列表,不会向新进入用户泄露此前聊天。 聊天历史 HTTP 接口保留用于客户端契约兼容,但验证房间后始终返回空列表,不会向新进入用户泄露此前聊天。
@@ -94,7 +98,7 @@ GET /live/recharge/orders/:orderNo
GET /live/recharge/orders/:orderNo/status GET /live/recharge/orders/:orderNo/status
``` ```
点赞不要求登录,点赞数只在当前进程的直播运行态中累加;关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。 点赞不要求登录,但仅允许正在直播的公开房间;服务端点赞数在当前进程的直播运行态中累加。PC 保留本次访问的点赞状态,不生成随机点赞。关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。
创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识: 创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识:
@@ -111,10 +115,35 @@ GET /live/recharge/orders/:orderNo/status
礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。 礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。
PC 只展示接口返回的礼物,不补造礼物 ID、价格或随机聊天。赠送按钮在请求期间防止重复提交。充值只有确认 `creditedAt` 并刷新余额后才视为到账;余额足够时继续原先选择的礼物一次,仍不足则显示差额并允许继续充值。关闭充值弹窗、离开房间、切换账号或关播会取消尚未执行的赠送意图,迟到的响应不会重新打开弹窗或自动扣款。已提交支付的真实到账结果仍以服务端订单和钱包为准。
全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。 全站设置继续保存在 `live_settings`。字典用于稳定的枚举和下拉选项,不适合承载站点名称、公告、功能开关、兑换比例等需要类型校验和运行时读取的配置。
直播状态和在线连接是进程内状态,行为与旧项目一致;服务重启后房间自动清空。 直播状态和在线连接是进程内状态,行为与旧项目一致;服务重启后房间自动清空。
## PC 与后台
- PC 观众页:`/#/live/:id`。
- PC 主播页:`/#/studio/:id`,使用房间绑定的主播账号登录。提供设备预览、摄像头和麦克风选择、音视频开关、开始与停止直播。
- 后台保存房间后立即展示可复制链接,房间列表也可复制主播或观众链接。默认 PC 在域名根路径,后台位于 `/admin/`;独立域名或开发端口使用后台构建变量 `VITE_LIVE_CLIENT_URL`。
- 链接不含 token。后台不再保留观众送礼页面;后台直播诊断和管理详情使用管理接口,不受房间是否正在直播的限制。
- 摄像头需要 HTTPS 或 localhost;播放和推流失败时显示错误并释放失效会话,后续可重新连接。
- API 返回的相对媒体路径默认经 `/api` 加载,已带 `/api` 的路径不会重复拼接;绝对 CDN 地址保持不变。独立文件服务可设置 PC 构建变量 `VITE_FILE_BASE`。
`frontend/` 是独立 Git 仓库,主仓库中的 `web/` 是管理后台。发布本次修复时需要分别更新 PC、后台与 Go 服务,不能只发布其中一个。
## 本地验收
`tmp/live_acceptance/` 包含可复跑的隔离接口验收程序,固定访问 `127.0.0.1:18080`,仅操作 `.tmp-live-acceptance/acceptance.db`。测试配置、账号、模拟支付密钥和报告都保存在被忽略的 `.tmp-live-acceptance/` 下,不写入生产配置,也不删除已有业务数据库。
在隔离服务和本地支付模拟服务运行后执行:
```text
go run ./tmp/live_acceptance verify
```
验收覆盖后台管理、注册登录、权限、双用户实时聊天、60 秒以上连接保活、送礼事务、余额不足、充值订单归属、签名回调与重复回调,并直接核对 SQLite 钱包、礼物流水、充值记录和不持久化的聊天表。支付模拟仅用于本地验证,不代表实际商户渠道或公网跨网络 WebRTC 已验收。
## 网络配置 ## 网络配置
默认使用 Pion 主机候选。生产环境可以配置: 默认使用 Pion 主机候选。生产环境可以配置:
+28 -10
View File
@@ -24,6 +24,7 @@ var (
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled") ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting") ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting")
ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data") ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data")
ErrRoomDisabled = kratoserrors.BadRequest("LIVE_ROOM_DISABLED", "live room is disabled")
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting") ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found") ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
) )
@@ -152,8 +153,8 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
filter.Name = strings.TrimSpace(filter.Name) filter.Name = strings.TrimSpace(filter.Name)
filter.AnchorName = strings.TrimSpace(filter.AnchorName) filter.AnchorName = strings.TrimSpace(filter.AnchorName)
options = normalizeListOptions(options) options = normalizeListOptions(options)
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline { if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline || ordersByViewerCount(options.OrderBy) {
return uc.listByRuntimeStatus(ctx, filter, options) return uc.listByRuntime(ctx, filter, options)
} }
rooms, total, err := uc.repo.ListRooms(ctx, filter, options) rooms, total, err := uc.repo.ListRooms(ctx, filter, options)
if err != nil { if err != nil {
@@ -162,10 +163,15 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
return uc.attachRuntime(ctx, rooms), total, nil return uc.attachRuntime(ctx, rooms), total, nil
} }
func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) { func (uc *RoomUsecase) listByRuntime(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
all := make([]*Room, 0) all := make([]*Room, 0)
storageOrder := options.OrderBy
if ordersByViewerCount(storageOrder) {
// Runtime sorting happens after loading, so keep storage batches stable.
storageOrder = "id_asc"
}
for offset := 0; ; offset += 100 { for offset := 0; ; offset += 100 {
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy}) batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: storageOrder})
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -176,12 +182,13 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
} }
all = uc.attachRuntime(ctx, all) all = uc.attachRuntime(ctx, all)
matched := make([]*Room, 0, len(all)) matched := make([]*Room, 0, len(all))
filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline
for _, room := range all { for _, room := range all {
if room.Status == filter.Status { if !filterRuntimeStatus || room.Status == filter.Status {
matched = append(matched, room) matched = append(matched, room)
} }
} }
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") { if ordersByViewerCount(options.OrderBy) {
sort.SliceStable(matched, func(i, j int) bool { sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ViewerCount == matched[j].ViewerCount { if matched[i].ViewerCount == matched[j].ViewerCount {
return matched[i].ID < matched[j].ID return matched[i].ID < matched[j].ID
@@ -203,6 +210,10 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
return matched[options.Offset:end], total, nil return matched[options.Offset:end], total, nil
} }
func ordersByViewerCount(order string) bool {
return strings.EqualFold(order, "viewer_count_asc") || strings.EqualFold(order, "viewer_count_desc")
}
func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) { func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
if id == 0 { if id == 0 {
return nil, ErrRoomNotFound return nil, ErrRoomNotFound
@@ -219,15 +230,15 @@ func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !room.Enabled || room.Status != RoomStatusLive { if !room.Enabled {
return nil, ErrRoomNotLive return nil, ErrRoomDisabled
} }
category, err := uc.repo.GetCategory(ctx, room.CategoryID) category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !category.Enabled { if !category.Enabled {
return nil, ErrRoomNotLive return nil, ErrCategoryDisabled
} }
return room, nil return room, nil
} }
@@ -237,7 +248,14 @@ func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
return room.Enabled && room.Status == RoomStatusLive, nil if !room.Enabled || room.Status != RoomStatusLive {
return false, nil
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return false, err
}
return category.Enabled, nil
} }
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) { func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
+125 -17
View File
@@ -40,6 +40,12 @@ func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, option
if filter.Enabled != nil && room.Enabled != *filter.Enabled { if filter.Enabled != nil && room.Enabled != *filter.Enabled {
continue continue
} }
if filter.Status == RoomStatusDisabled && room.Enabled {
continue
}
if (filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline) && !room.Enabled {
continue
}
if filter.CategoryEnabled != nil { if filter.CategoryEnabled != nil {
category, err := r.GetCategory(context.Background(), room.CategoryID) category, err := r.GetCategory(context.Background(), room.CategoryID)
if err != nil || category.Enabled != *filter.CategoryEnabled { if err != nil || category.Enabled != *filter.CategoryEnabled {
@@ -105,19 +111,26 @@ func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
} }
} }
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) { func TestRoomUsecaseIsLiveRequiresEnabledRoomCategoryAndOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{rooms: []*Room{ catalog := &catalogRepoStub{
{ID: 1, Enabled: true}, categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
{ID: 2, Enabled: true}, rooms: []*Room{
{ID: 3, Enabled: false}, {ID: 1, CategoryID: 1, Enabled: true},
}} {ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true}, "room-1": {StreamID: "room-1", PublisherOnline: true},
"room-3": {StreamID: "room-3", PublisherOnline: true}, "room-3": {StreamID: "room-3", PublisherOnline: true},
"room-4": {StreamID: "room-4", PublisherOnline: true},
"room-5": {StreamID: "room-5", PublisherOnline: true},
}}) }})
rooms := NewRoomUsecase(catalog, streams) rooms := NewRoomUsecase(catalog, streams)
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} { for roomID, want := range map[uint]bool{1: true, 2: false, 3: false, 4: false} {
got, err := rooms.IsLive(context.Background(), roomID) got, err := rooms.IsLive(context.Background(), roomID)
if err != nil { if err != nil {
t.Fatalf("IsLive(%d) error = %v", roomID, err) t.Fatalf("IsLive(%d) error = %v", roomID, err)
@@ -126,16 +139,21 @@ func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want) t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
} }
} }
if live, err := rooms.IsLive(context.Background(), 5); live || !errors.Is(err, ErrCategoryNotFound) {
t.Fatalf("missing category IsLive(5) = %v, err=%v", live, err)
}
} }
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) { func TestRoomUsecaseGetPublicIncludesOfflineRoomsAndRequiresEnabledCategory(t *testing.T) {
catalog := &catalogRepoStub{ catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}}, categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
rooms: []*Room{ rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true}, {ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true}, {ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false}, {ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true}, {ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 1, Enabled: true},
{ID: 6, CategoryID: 3, Enabled: true},
}, },
} }
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
@@ -145,16 +163,106 @@ func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
}}) }})
rooms := NewRoomUsecase(catalog, streams) rooms := NewRoomUsecase(catalog, streams)
item, err := rooms.GetPublic(context.Background(), 1) for _, test := range []struct {
if err != nil || item.ID != 1 || item.Status != RoomStatusLive { id uint
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err) status string
} replay string
for _, roomID := range []uint{2, 3, 4} { }{
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) { {id: 1, status: RoomStatusLive},
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive) {id: 2, status: RoomStatusOffline, replay: "https://example.test/replay.mp4"},
{id: 5, status: RoomStatusOffline},
} {
item, err := rooms.GetPublic(context.Background(), test.id)
if err != nil || item.ID != test.id || item.Status != test.status || item.ReplayURL != test.replay {
t.Fatalf("GetPublic(%d) item=%+v err=%v", test.id, item, err)
} }
if _, err = rooms.Get(context.Background(), roomID); err != nil { }
for roomID, want := range map[uint]error{0: ErrRoomNotFound, 3: ErrRoomDisabled, 4: ErrCategoryDisabled, 6: ErrCategoryNotFound, 99: ErrRoomNotFound} {
if _, err := rooms.GetPublic(context.Background(), roomID); !errors.Is(err, want) {
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, want)
}
}
for _, roomID := range []uint{3, 4, 6} {
if _, err := rooms.Get(context.Background(), roomID); err != nil {
t.Errorf("admin Get(%d) error=%v", roomID, err) t.Errorf("admin Get(%d) error=%v", roomID, err)
} }
} }
} }
func TestRoomUsecaseListsPublicRoomsByRuntimeBeforePagination(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}, {ID: 3, Enabled: true}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: true},
{ID: 4, CategoryID: 1, Enabled: false},
{ID: 5, CategoryID: 2, Enabled: true},
{ID: 6, CategoryID: 1, Enabled: true},
{ID: 7, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 100},
"room-3": {StreamID: "room-3", PublisherOnline: true, SubscriberCount: 200},
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
"room-5": {StreamID: "room-5", PublisherOnline: true, SubscriberCount: 1000},
"room-7": {StreamID: "room-7", PublisherOnline: true, SubscriberCount: 50},
}})
rooms := NewRoomUsecase(catalog, streams)
for _, test := range []struct {
name string
status string
categoryID uint
order string
offset int
limit int
total int64
want []uint
}{
{name: "all default", limit: 10, total: 5, want: []uint{1, 2, 3, 6, 7}},
{name: "all popular", order: "viewer_count_desc", limit: 10, total: 5, want: []uint{3, 2, 7, 1, 6}},
{name: "all popular page", order: "viewer_count_desc", offset: 1, limit: 2, total: 5, want: []uint{2, 7}},
{name: "all ascending", order: "VIEWER_COUNT_ASC", limit: 10, total: 5, want: []uint{1, 6, 7, 2, 3}},
{name: "live only", status: RoomStatusLive, order: "viewer_count_desc", limit: 10, total: 3, want: []uint{3, 2, 7}},
{name: "offline replay and empty", status: RoomStatusOffline, order: "viewer_count_desc", limit: 10, total: 2, want: []uint{1, 6}},
{name: "category filter", categoryID: 1, order: "viewer_count_desc", limit: 10, total: 4, want: []uint{3, 2, 1, 6}},
{name: "disabled excluded", status: RoomStatusDisabled, order: "viewer_count_desc", limit: 10},
{name: "past final page", order: "viewer_count_desc", offset: 5, limit: 2, total: 5},
} {
t.Run(test.name, func(t *testing.T) {
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Status: test.status, CategoryID: test.categoryID, Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Offset: test.offset, Limit: test.limit, OrderBy: test.order})
if err != nil {
t.Fatal(err)
}
if total != test.total || len(items) != len(test.want) {
t.Fatalf("total=%d items=%+v, want total=%d ids=%v", total, items, test.total, test.want)
}
for index, id := range test.want {
if items[index].ID != id {
t.Fatalf("item[%d]=%d, want %d", index, items[index].ID, id)
}
}
})
}
}
func TestRoomUsecasePopularSortIncludesRoomsBeyondStorageBatch(t *testing.T) {
catalog := &catalogRepoStub{categories: []*Category{{ID: 1, Enabled: true}}}
for id := uint(1); id <= 105; id++ {
catalog.rooms = append(catalog.rooms, &Room{ID: id, CategoryID: 1, Enabled: true})
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-105": {StreamID: "room-105", PublisherOnline: true, SubscriberCount: 500},
}})
rooms := NewRoomUsecase(catalog, streams)
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Limit: 2, OrderBy: "viewer_count_desc"})
if err != nil {
t.Fatal(err)
}
if total != 105 || len(items) != 2 || items[0].ID != 105 || items[1].ID != 1 {
t.Fatalf("popular rooms total=%d items=%+v", total, items)
}
}
+7 -6
View File
@@ -4,11 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
bizgift "kra/internal/biz/gift" bizgift "kra/internal/biz/gift"
"kra/internal/data/ledger" "kra/internal/data/ledger"
"kra/internal/utils/serial"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -26,7 +26,7 @@ type giftPO struct {
Description string `gorm:"size:255"` Description string `gorm:"size:255"`
Tiers string `gorm:"type:text"` Tiers string `gorm:"type:text"`
Sort int `gorm:"default:0;index"` Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"` Enabled bool `gorm:"index"`
} }
func (giftPO) TableName() string { return "live_gifts" } func (giftPO) TableName() string { return "live_gifts" }
@@ -244,7 +244,11 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
} }
} }
roomIDCopy, giftIDCopy := room.ID, gift.ID roomIDCopy, giftIDCopy := room.ID, gift.ID
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate} serialNo, err := serial.New("DS")
if err != nil {
return err
}
row := ledger.GiftSendPO{SerialNo: serialNo, UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
if err := tx.Create(&row).Error; err != nil { if err := tx.Create(&row).Error; err != nil {
return err return err
} }
@@ -295,6 +299,3 @@ func sendOrder(v string) string {
} }
return "live_gift_sends.created_at DESC,live_gift_sends.id 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)
}
+182
View File
@@ -3,6 +3,7 @@ package gift
import ( import (
"context" "context"
"errors" "errors"
"regexp"
"testing" "testing"
bizgift "kra/internal/biz/gift" bizgift "kra/internal/biz/gift"
@@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
t.Fatalf("existing gift was overwritten: %+v", rose) t.Fatalf("existing gift was overwritten: %+v", rose)
} }
} }
func TestGiftRepoInsufficientBalanceDoesNotWriteLedger(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 11}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
_, err = repo.Send(context.Background(), 1, 7, item.ID, 3)
var insufficient *bizgift.InsufficientBalanceError
if !errors.Is(err, bizgift.ErrInsufficientBalance) || !errors.As(err, &insufficient) {
t.Fatalf("insufficient balance error = %v", err)
}
if insufficient.Required != 21 || insufficient.Balance != 11 || insufficient.Missing != 10 {
t.Fatalf("incorrect recharge shortfall: %+v", insufficient)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 11 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("failed gift changed ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoWalletFailureRollsBackGiftReceipt(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Send(context.Background(), 1, 7, item.ID, 3); err == nil {
t.Fatal("forced wallet update failure was ignored")
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("wallet failure was not rolled back: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoCreateDisabledGiftCannotExposeOrDebitIt(t *testing.T) {
db := openGiftTestDB(t)
if err := db.Table("live_gifts").AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
ctx := context.Background()
active, err := repo.Save(ctx, &bizgift.Gift{Name: "Active", Icon: "active.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
disabled, err := repo.Save(ctx, &bizgift.Gift{Name: "Disabled", Icon: "disabled.png", Price: 5, Tiers: []int{1}, Enabled: false})
if err != nil {
t.Fatal(err)
}
if disabled.Enabled {
t.Fatal("a newly created disabled gift was enabled")
}
reloaded, err := repo.Get(ctx, disabled.ID)
if err != nil || reloaded.Enabled {
t.Fatalf("reloaded disabled gift=%+v error=%v", reloaded, err)
}
enabled := true
items, total, err := repo.List(ctx, bizgift.Filter{Enabled: &enabled}, bizgift.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != active.ID {
t.Fatalf("public gifts include a disabled gift: total=%d items=%+v", total, items)
}
if err = repo.SetEnabled(ctx, active.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
t.Fatalf("disabled gift counted as an enabled alternative: error=%v", err)
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
if _, err = repo.Send(ctx, 1, 7, disabled.ID, 1); !errors.Is(err, bizgift.ErrSendNotAllowed) {
t.Fatalf("disabled gift could be sent: error=%v", err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err = db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("disabled gift changed the ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoConsecutiveSendsHaveUniqueShortSerialNumbers(t *testing.T) {
db := openGiftTestDB(t)
ctx := context.Background()
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
const count = 100
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: count}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
t.Fatal(err)
}
gift, err := repo.Save(ctx, &bizgift.Gift{Name: "Gift", Icon: "gift.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
pattern := regexp.MustCompile(`^DS[A-Z2-7]{26}$`)
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
sent, err := repo.Send(ctx, 1, 7, gift.ID, 1)
if err != nil {
t.Fatalf("gift %d failed: %v", index, err)
}
if !pattern.MatchString(sent.SerialNo) || len(sent.SerialNo) > 32 {
t.Fatalf("invalid gift serial: %q", sent.SerialNo)
}
if _, exists := seen[sent.SerialNo]; exists {
t.Fatalf("duplicate gift serial: %q", sent.SerialNo)
}
seen[sent.SerialNo] = struct{}{}
}
var receipts int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&receipts).Error; err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if receipts != count || wallet.DiamondBalance != 0 || wallet.TotalSpentDiamonds != count {
t.Fatalf("consecutive gifts did not reconcile: receipts=%d wallet=%+v", receipts, wallet)
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ type RechargePlanPO struct {
Sort int `gorm:"index"` Sort int `gorm:"index"`
Diamonds int64 Diamonds int64
AmountCents int64 AmountCents int64
Enabled bool `gorm:"default:true"` Enabled bool
} }
func (RechargePlanPO) TableName() string { return "live_recharge_plans" } func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
+4 -4
View File
@@ -23,7 +23,7 @@ type categoryPO struct {
Name string `gorm:"size:40;uniqueIndex"` Name string `gorm:"size:40;uniqueIndex"`
Icon string `gorm:"size:500"` Icon string `gorm:"size:500"`
Sort int `gorm:"default:0;index"` Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"` Enabled bool `gorm:"index"`
} }
func (categoryPO) TableName() string { return "live_categories" } func (categoryPO) TableName() string { return "live_categories" }
@@ -41,7 +41,7 @@ type roomPO struct {
AnchorUserID uint `gorm:"index"` AnchorUserID uint `gorm:"index"`
StreamURL string `gorm:"size:1000"` StreamURL string `gorm:"size:1000"`
ReplayURL string `gorm:"size:1000"` ReplayURL string `gorm:"size:1000"`
Enabled bool `gorm:"default:true;index"` Enabled bool `gorm:"index"`
} }
func (roomPO) TableName() string { return "live_rooms" } func (roomPO) TableName() string { return "live_rooms" }
@@ -171,7 +171,7 @@ func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) { func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
db := r.db(ctx) db := r.db(ctx)
query := db.Model(&roomPO{}). query := db.Model(&roomPO{}).
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id"). Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL") Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
if filter.Name != "" { if filter.Name != "" {
value := "%" + filter.Name + "%" value := "%" + filter.Name + "%"
@@ -219,7 +219,7 @@ func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, erro
var row roomRow var row roomRow
err := r.db(ctx).Model(&roomPO{}). err := r.db(ctx).Model(&roomPO{}).
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar"). Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id"). Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL"). Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
Where("live_rooms.id = ?", id).Scan(&row).Error Where("live_rooms.id = ?", id).Scan(&row).Error
if err != nil { if err != nil {
+158 -2
View File
@@ -2,6 +2,7 @@ package live
import ( import (
"context" "context"
"errors"
"testing" "testing"
bizlive "kra/internal/biz/live" bizlive "kra/internal/biz/live"
@@ -44,7 +45,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
if err := db.Create(&category).Error; err != nil { if err := db.Create(&category).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true} room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, ReplayURL: "https://example.test/replay.mp4", Enabled: true}
if err := db.Create(&room).Error; err != nil { if err := db.Create(&room).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -70,7 +71,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled { if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || got.ReplayURL != room.ReplayURL || !got.Enabled {
t.Fatalf("room get scan result=%+v", got) t.Fatalf("room get scan result=%+v", got)
} }
} }
@@ -110,3 +111,158 @@ func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms) t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
} }
} }
func TestCatalogRepoPublicVisibilityFiltersBeforePagination(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)
}
visible := categoryPO{Name: "Visible", Enabled: true}
disabled := categoryPO{Name: "Disabled", Enabled: true}
deleted := categoryPO{Name: "Deleted", Enabled: true}
for _, category := range []*categoryPO{&visible, &disabled, &deleted} {
if err := db.Create(category).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(&disabled).Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&deleted).Error; err != nil {
t.Fatal(err)
}
rooms := []*roomPO{
{Name: "Offline Room", CategoryID: visible.ID, Enabled: true},
{Name: "Replay Room", CategoryID: visible.ID, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{Name: "Disabled Room", CategoryID: visible.ID, Enabled: true},
{Name: "Disabled Category", CategoryID: disabled.ID, Enabled: true},
{Name: "Deleted Category", CategoryID: deleted.ID, Enabled: true},
{Name: "Missing Category", CategoryID: 999, Enabled: true},
{Name: "Deleted Room", CategoryID: visible.ID, Enabled: true},
}
for _, room := range rooms {
room.AnchorUserID = 2
room.CoverURL = "cover.png"
if err := db.Create(room).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(rooms[2]).Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(rooms[6]).Error; err != nil {
t.Fatal(err)
}
repo := NewCatalogRepo(catalogTestProvider{db: db})
enabled := true
filter := bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}
items, total, err := repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10, OrderBy: "id_asc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 2 || items[0].ID != rooms[0].ID || items[1].ID != rooms[1].ID {
t.Fatalf("public candidates total=%d items=%+v", total, items)
}
if items[1].ReplayURL != rooms[1].ReplayURL {
t.Fatalf("replay URL=%q, want %q", items[1].ReplayURL, rooms[1].ReplayURL)
}
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Offset: 1, Limit: 1, OrderBy: "id_asc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 1 || items[0].ID != rooms[1].ID {
t.Fatalf("public page total=%d items=%+v", total, items)
}
filter.CategoryID = disabled.ID
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("disabled category returned rooms: total=%d items=%+v", total, items)
}
}
func TestCatalogRepoCreatePreservesExplicitEnabledState(t *testing.T) {
db := openCatalogTestDB(t)
// Existing installations still have the old database-level default.
for _, table := range []string{"live_categories", "live_rooms"} {
if err := db.Table(table).AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
}
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)
}
repo := NewCatalogRepo(catalogTestProvider{db: db})
ctx := context.Background()
for _, enabled := range []bool{false, true} {
name := "Disabled"
if enabled {
name = "Enabled"
}
t.Run(name+" category", func(t *testing.T) {
created, err := repo.CreateCategory(ctx, &bizlive.Category{Name: name, Enabled: enabled})
if err != nil {
t.Fatal(err)
}
if created.Enabled != enabled {
t.Fatalf("created category enabled=%v, want %v", created.Enabled, enabled)
}
reloaded, err := repo.GetCategory(ctx, created.ID)
if err != nil || reloaded.Enabled != enabled {
t.Fatalf("reloaded category=%+v error=%v, want enabled=%v", reloaded, err, enabled)
}
})
}
enabled := true
categories, total, err := repo.ListCategories(ctx, bizlive.CategoryFilter{Enabled: &enabled}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(categories) != 1 || categories[0].Name != "Enabled" {
t.Fatalf("public categories include a disabled new category: total=%d categories=%+v", total, categories)
}
categoryID := categories[0].ID
streams := bizlive.NewStreamUsecase(NewStreamRepo())
rooms := bizlive.NewRoomUsecase(repo, streams)
for _, enabled := range []bool{false, true} {
name := "Disabled"
if enabled {
name = "Enabled"
}
t.Run(name+" room", func(t *testing.T) {
created, err := repo.CreateRoom(ctx, &bizlive.Room{
Name: name, CategoryID: categoryID, AnchorUserID: 2,
CoverURL: "cover.png", ReplayURL: "https://example.test/replay.mp4", Enabled: enabled,
})
if err != nil {
t.Fatal(err)
}
if created.Enabled != enabled {
t.Fatalf("created room enabled=%v, want %v", created.Enabled, enabled)
}
reloaded, err := repo.GetRoom(ctx, created.ID)
if err != nil || reloaded.Enabled != enabled {
t.Fatalf("reloaded room=%+v error=%v, want enabled=%v", reloaded, err, enabled)
}
public, err := rooms.GetPublic(ctx, created.ID)
if enabled {
if err != nil || public.Status != bizlive.RoomStatusOffline {
t.Fatalf("enabled offline public room=%+v error=%v", public, err)
}
} else if public != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("disabled new room passed public/publisher authorization: room=%+v error=%v", public, err)
}
})
}
public, total, err := repo.ListRooms(ctx, bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(public) != 1 || public[0].Name != "Enabled" {
t.Fatalf("public list includes a disabled new room: total=%d rooms=%+v", total, public)
}
}
+7 -7
View File
@@ -3,7 +3,6 @@ package recharge
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -13,6 +12,7 @@ import (
biz "kra/internal/biz/recharge" biz "kra/internal/biz/recharge"
"kra/internal/data/ledger" "kra/internal/data/ledger"
"kra/internal/utils/serial"
) )
type Provider interface{ DB() *gorm.DB } type Provider interface{ DB() *gorm.DB }
@@ -39,7 +39,7 @@ func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
return err return err
} }
for _, p := range plans { for _, p := range plans {
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil { if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: p.Enabled}).Error; err != nil {
return err return err
} }
} }
@@ -127,7 +127,11 @@ func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, err
rate = v rate = v
} }
} }
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate} orderNo, err := serial.New("CZ")
if err != nil {
return nil, err
}
v := ledger.RechargeOrderPO{OrderNo: orderNo, UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
if err := r.db(ctx).Create(&v).Error; err != nil { if err := r.db(ctx).Create(&v).Error; err != nil {
return nil, err return nil, err
} }
@@ -185,7 +189,3 @@ func orderBy(v string) string {
} }
return "live_recharge_orders.created_at DESC" return "live_recharge_orders.created_at DESC"
} }
func number(p string) string {
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
}
+173
View File
@@ -2,6 +2,8 @@ package recharge
import ( import (
"context" "context"
"errors"
"regexp"
"testing" "testing"
"time" "time"
@@ -95,3 +97,174 @@ func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
t.Fatalf("payment state was not joined: %+v", item) t.Fatalf("payment state was not joined: %+v", item)
} }
} }
func TestMarkPaidWalletFailureRollsBackCreditMarkerAndAllowsRetry(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.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
t.Fatal(err)
}
order := ledger.RechargeOrderPO{OrderNo: "CZ-ROLLBACK", UserID: 3, Diamonds: 60, AmountCents: 600}
if err = db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 3, DiamondBalance: 10}).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err == nil {
t.Fatal("forced wallet update failure was ignored")
}
var stored ledger.RechargeOrderPO
if err = db.First(&stored, order.ID).Error; err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if stored.CreditedAt != nil || wallet.DiamondBalance != 10 || wallet.TotalRechargeCents != 0 {
t.Fatalf("failed fulfillment changed credit marker or wallet: order=%+v wallet=%+v", stored, wallet)
}
if err = db.Exec(`DROP TRIGGER reject_wallet_update`).Error; err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
t.Fatal(err)
}
}
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if err = db.First(&stored, order.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreditedAt == nil || wallet.DiamondBalance != 70 || wallet.TotalRechargeCents != 600 {
t.Fatalf("retry did not credit exactly once: order=%+v wallet=%+v", stored, wallet)
}
}
func TestRechargePlanStoragePreservesExplicitDisabledState(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.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}); err != nil {
t.Fatal(err)
}
if err = db.Table("live_recharge_plans").AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
disabled := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false}
if err = db.Create(&disabled).Error; err != nil {
t.Fatal(err)
}
var stored ledger.RechargePlanPO
if err = db.First(&stored, disabled.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Enabled {
t.Fatal("storage default enabled an explicitly disabled recharge plan")
}
repo := NewRepo(testProvider{db: db})
items, err := repo.Plans(context.Background())
if err != nil || len(items) != 0 {
t.Fatalf("public plans include a disabled plan: plans=%+v error=%v", items, err)
}
if _, err = repo.Create(context.Background(), 1, disabled.ID); !errors.Is(err, biz.ErrInvalid) {
t.Fatalf("disabled recharge plan accepted an order: error=%v", err)
}
var orders int64
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if orders != 0 {
t.Fatalf("disabled recharge plan created %d orders", orders)
}
}
func TestRechargeRepoSavePlansPreservesDomainEnabledValues(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.AutoMigrate(&ledger.RechargePlanPO{}); err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
if err = repo.SavePlans(context.Background(), []*biz.Plan{
{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false},
{Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
}); err != nil {
t.Fatal(err)
}
var stored []ledger.RechargePlanPO
if err = db.Order("sort ASC").Find(&stored).Error; err != nil {
t.Fatal(err)
}
if len(stored) != 2 || stored[0].Enabled || !stored[1].Enabled {
t.Fatalf("saved plans lost explicit enabled values: %+v", stored)
}
items, err := repo.Plans(context.Background())
if err != nil || len(items) != 1 || items[0].Diamonds != 300 {
t.Fatalf("public plan filter returned disabled entries: plans=%+v error=%v", items, err)
}
}
func TestRechargeRepoConsecutiveOrdersHaveUniquePaymentSafeNumbers(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.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}, &ledger.SettingPO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`,
`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`,
`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
t.Fatal(err)
}
plan := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}
if err = db.Create(&plan).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
pattern := regexp.MustCompile(`^CZ[A-Z2-7]{26}$`)
const count = 100
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
order, err := repo.Create(context.Background(), 3, plan.ID)
if err != nil {
t.Fatalf("order %d failed: %v", index, err)
}
if !pattern.MatchString(order.OrderNo) || len(order.OrderNo) > 32 {
t.Fatalf("invalid recharge order number: %q", order.OrderNo)
}
if _, exists := seen[order.OrderNo]; exists {
t.Fatalf("duplicate recharge order number: %q", order.OrderNo)
}
seen[order.OrderNo] = struct{}{}
}
var orders int64
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if orders != count {
t.Fatalf("created orders=%d, want %d", orders, count)
}
}
+6 -4
View File
@@ -322,6 +322,11 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"}) m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
return return
} }
available, err := m.roomCatalog.IsLive(context.Background(), roomID)
if err != nil || !available {
m.send(session, "Error", map[string]any{"code": "ROOM_UNAVAILABLE", "message": "live room is unavailable"})
return
}
item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content) item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content)
if err != nil { if err != nil {
m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()}) m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()})
@@ -379,13 +384,10 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
if err != nil || id == 0 { if err != nil || id == 0 {
return errors.New("invalid live room stream id") return errors.New("invalid live room stream id")
} }
item, err := m.roomCatalog.Get(context.Background(), uint(id)) item, err := m.roomCatalog.GetPublic(context.Background(), uint(id))
if err != nil { if err != nil {
return errors.New("live room is unavailable") return errors.New("live room is unavailable")
} }
if !item.Enabled {
return errors.New("live room is disabled")
}
if item.AnchorUserID != userID { if item.AnchorUserID != userID {
return errors.New("current user is not the room anchor") return errors.New("current user is not the room anchor")
} }
+289 -8
View File
@@ -2,6 +2,7 @@ package live
import ( import (
"context" "context"
"encoding/json"
"sync" "sync"
"testing" "testing"
@@ -9,6 +10,7 @@ import (
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
bizlive "kra/internal/biz/live" bizlive "kra/internal/biz/live"
bizchat "kra/internal/biz/livechat"
platformws "kra/pkg/websocket" platformws "kra/pkg/websocket"
) )
@@ -39,28 +41,41 @@ func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
type liveCatalogStub struct { type liveCatalogStub struct {
bizlive.CatalogRepo bizlive.CatalogRepo
room *bizlive.Room room *bizlive.Room
err error category *bizlive.Category
err error
categoryErr error
} }
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) { func (s *liveCatalogStub) GetRoom(_ context.Context, id uint) (*bizlive.Room, error) {
if s.err != nil { if s.err != nil {
return nil, s.err return nil, s.err
} }
if s.room == nil || s.room.ID != id {
return nil, bizlive.ErrRoomNotFound
}
copy := *s.room copy := *s.room
return &copy, nil return &copy, nil
} }
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) { func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
if s.categoryErr != nil {
return nil, s.categoryErr
}
if s.category != nil {
copy := *s.category
return &copy, nil
}
return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil
} }
type liveStreamStub struct { type liveStreamStub struct {
bizlive.StreamRepo bizlive.StreamRepo
mu sync.Mutex mu sync.Mutex
streams map[string]*bizlive.Stream streams map[string]*bizlive.Stream
users map[uint]struct{} users map[uint]struct{}
registerCalls int registerCalls int
publisherRegisterCalls int
} }
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) { func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
@@ -86,6 +101,38 @@ func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
} }
return items, nil return items, nil
} }
func (s *liveStreamStub) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.streams == nil {
s.streams = make(map[string]*bizlive.Stream)
}
stream := s.streams[streamID]
if stream == nil {
stream = &bizlive.Stream{StreamID: streamID}
s.streams[streamID] = stream
}
if stream.PublisherOnline {
return nil, bizlive.ErrPublisherExists
}
s.publisherRegisterCalls++
stream.PublisherOnline, stream.PublisherID = true, userID
copy := *stream
return &copy, nil
}
func (s *liveStreamStub) UnregisterPublisher(_ context.Context, streamID string, userID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
stream := s.streams[streamID]
if stream == nil || !stream.PublisherOnline || stream.PublisherID != userID {
return bizlive.ErrPublisherNotFound
}
stream.PublisherOnline, stream.PublisherID = false, 0
return nil
}
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) { func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -97,15 +144,49 @@ func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string,
return nil, bizlive.ErrSubscriberExists return nil, bizlive.ErrSubscriberExists
} }
s.users[userID] = struct{}{} s.users[userID] = struct{}{}
if stream := s.streams[streamID]; stream != nil {
stream.SubscriberCount = len(s.users)
}
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
} }
func (s *liveStreamStub) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[userID]; !exists {
return bizlive.ErrSubscriberNotFound
}
delete(s.users, userID)
if stream := s.streams[streamID]; stream != nil {
stream.SubscriberCount = len(s.users)
}
return nil
}
type liveChatStub struct {
bizchat.Repo
cleared []uint
messages []*bizchat.Message
}
func (s *liveChatStub) Clear(_ context.Context, roomID uint) error {
s.cleared = append(s.cleared, roomID)
return nil
}
func (s *liveChatStub) CreateChat(_ context.Context, roomID, userID uint, content string) (*bizchat.Message, error) {
item := &bizchat.Message{ID: uint(len(s.messages) + 1), RoomID: roomID, UserID: userID, Content: content, Type: bizchat.TypeChat}
s.messages = append(s.messages, item)
return item, nil
}
func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager { func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager {
streamUC := bizlive.NewStreamUsecase(streams) streamUC := bizlive.NewStreamUsecase(streams)
return &Manager{ return &Manager{
hub: &liveHubStub{}, hub: &liveHubStub{},
streams: streamUC, streams: streamUC,
roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC), roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC),
chat: bizchat.NewUsecase(&liveChatStub{}),
rooms: make(map[string]*room), rooms: make(map[string]*room),
peers: make(map[*melody.Session]*peer), peers: make(map[*melody.Session]*peer),
observers: make(map[*melody.Session]struct{}), observers: make(map[*melody.Session]struct{}),
@@ -132,6 +213,11 @@ func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
if len(streams.users) != 2 { if len(streams.users) != 2 {
t.Fatalf("subscriber count = %d, want 2", len(streams.users)) t.Fatalf("subscriber count = %d, want 2", len(streams.users))
} }
for _, session := range []*melody.Session{first, second} {
if payload := lastMessagePayload(t, manager, session, "Joined"); payload["userId"] != float64(0) {
t.Fatalf("anonymous runtime identity leaked in response: %+v", payload)
}
}
} }
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) { func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
@@ -147,7 +233,7 @@ func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) { func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
streams := &liveStreamStub{} streams := &liveStreamStub{}
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams) manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}, streams)
session := &melody.Session{Keys: map[string]any{}} session := &melody.Session{Keys: map[string]any{}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"}) manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
@@ -155,6 +241,9 @@ func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 { 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) 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)
} }
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("offline join rejection=%+v", payload)
}
} }
func TestAnonymousPublisherIsRejected(t *testing.T) { func TestAnonymousPublisherIsRejected(t *testing.T) {
@@ -168,6 +257,198 @@ func TestAnonymousPublisherIsRejected(t *testing.T) {
} }
} }
func TestBoundAnchorCanPublishOfflineRoomAndDisconnectRestoresReplay(t *testing.T) {
catalog := &liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, AnchorUserID: 42, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}
streams := &liveStreamStub{}
manager := newAnonymousTestManager(catalog, streams)
chat := &liveChatStub{}
manager.chat = bizchat.NewUsecase(chat)
publisher := &melody.Session{Keys: map[string]any{"user_id": uint(42)}}
viewer := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
before, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || before.Status != bizlive.RoomStatusOffline {
t.Fatalf("offline public room=%+v error=%v", before, err)
}
manager.join(publisher, joinPayload{Role: "publisher", StreamID: "room-7"})
peer := manager.peer(publisher)
if peer == nil || peer.role != "publisher" || !peer.authenticated || peer.userID != 42 {
t.Fatalf("bound anchor did not join offline room: peer=%+v", peer)
}
if payload := lastMessagePayload(t, manager, publisher, "Joined"); payload["role"] != "publisher" || payload["streamId"] != "room-7" {
t.Fatalf("publisher join response=%+v", payload)
}
live, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || live.Status != bizlive.RoomStatusLive || streams.publisherRegisterCalls != 1 {
t.Fatalf("published room=%+v registrations=%d error=%v", live, streams.publisherRegisterCalls, err)
}
manager.join(viewer, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(viewer) == nil {
t.Fatal("viewer could not join published room")
}
if payload := lastMessagePayload(t, manager, viewer, "Joined"); payload["userId"] != float64(9) {
t.Fatalf("authenticated viewer response=%+v", payload)
}
manager.handleChat(viewer, chatSendPayload{Content: "Hello"})
if len(chat.messages) != 1 || chat.messages[0].RoomID != 7 || chat.messages[0].UserID != 9 {
t.Fatalf("live chat messages=%+v", chat.messages)
}
manager.onDisconnect(publisher)
after, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || after.Status != bizlive.RoomStatusOffline || after.ReplayURL != before.ReplayURL {
t.Fatalf("room after disconnect=%+v error=%v", after, err)
}
if payload := lastMessagePayload(t, manager, viewer, "PublisherStatus"); payload["ready"] != false || payload["reason"] != "disconnected" {
t.Fatalf("offline notification=%+v", payload)
}
if available, err := manager.roomCatalog.IsLive(context.Background(), 7); err != nil || available {
t.Fatalf("room remained live after disconnect: live=%v error=%v", available, err)
}
manager.handleChat(viewer, chatSendPayload{Content: "Not live"})
if len(chat.messages) != 1 {
t.Fatalf("offline message was persisted: %+v", chat.messages)
}
if payload := lastMessagePayload(t, manager, viewer, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("offline chat rejection=%+v", payload)
}
manager.onDisconnect(viewer)
if len(manager.peers) != 0 || len(manager.rooms) != 0 || len(streams.users) != 0 {
t.Fatalf("disconnected peers remain: peers=%d rooms=%d users=%d", len(manager.peers), len(manager.rooms), len(streams.users))
}
if len(chat.cleared) != 2 || chat.cleared[0] != 7 || chat.cleared[1] != 7 {
t.Fatalf("chat reset rooms=%v", chat.cleared)
}
}
func TestPublisherJoinRequiresEnabledRoomCategoryAndBoundAnchor(t *testing.T) {
for _, test := range []struct {
name string
userID uint
enabled bool
category *bizlive.Category
roomErr error
categoryErr error
code string
}{
{name: "anonymous", enabled: true, code: "AUTH_REQUIRED"},
{name: "wrong anchor", userID: 9, enabled: true, code: "PUBLISH_FORBIDDEN"},
{name: "disabled room", userID: 42, code: "PUBLISH_FORBIDDEN"},
{name: "disabled category", userID: 42, enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}, code: "PUBLISH_FORBIDDEN"},
{name: "missing room", userID: 42, enabled: true, roomErr: bizlive.ErrRoomNotFound, code: "PUBLISH_FORBIDDEN"},
{name: "missing category", userID: 42, enabled: true, categoryErr: bizlive.ErrCategoryNotFound, code: "PUBLISH_FORBIDDEN"},
} {
t.Run(test.name, func(t *testing.T) {
streams := &liveStreamStub{}
manager := newAnonymousTestManager(&liveCatalogStub{
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: test.enabled, AnchorUserID: 42},
category: test.category,
err: test.roomErr,
categoryErr: test.categoryErr,
}, streams)
session := &melody.Session{Keys: map[string]any{"user_id": test.userID}}
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.publisherRegisterCalls != 0 {
t.Fatalf("forbidden publisher created state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.publisherRegisterCalls)
}
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != test.code {
t.Fatalf("publisher rejection=%+v, want code=%s", payload, test.code)
}
})
}
}
func TestSubscriberJoinCannotBypassDisabledRoomOrCategory(t *testing.T) {
for _, test := range []struct {
name string
enabled bool
category *bizlive.Category
}{
{name: "disabled room"},
{name: "disabled category", enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}},
} {
t.Run(test.name, func(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: test.enabled},
category: test.category,
}, streams)
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.registerCalls != 0 {
t.Fatalf("unavailable room created subscriber state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.registerCalls)
}
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("subscriber rejection=%+v", payload)
}
})
}
}
func TestChatRechecksRoomAndCategoryVisibilityAfterJoin(t *testing.T) {
for _, disableCategory := range []bool{false, true} {
name := "disabled room"
if disableCategory {
name = "disabled category"
}
t.Run(name, func(t *testing.T) {
catalog := &liveCatalogStub{
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true},
category: &bizlive.Category{ID: 1, Enabled: true},
}
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
manager := newAnonymousTestManager(catalog, streams)
chat := &liveChatStub{}
manager.chat = bizchat.NewUsecase(chat)
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(session) == nil {
t.Fatal("viewer did not join enabled room")
}
if disableCategory {
catalog.category.Enabled = false
} else {
catalog.room.Enabled = false
}
manager.handleChat(session, chatSendPayload{Content: "Hello"})
if len(chat.messages) != 0 {
t.Fatalf("unavailable chat was persisted: %+v", chat.messages)
}
if payload := lastMessagePayload(t, manager, session, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("unavailable chat rejection=%+v", payload)
}
})
}
}
func lastMessagePayload(t *testing.T, manager *Manager, session *melody.Session, msgType string) map[string]any {
t.Helper()
hub := manager.hub.(*liveHubStub)
hub.mu.Lock()
defer hub.mu.Unlock()
messages := hub.messages[session]
for index := len(messages) - 1; index >= 0; index-- {
var msg struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
if err := json.Unmarshal(messages[index], &msg); err != nil {
t.Fatal(err)
}
if msg.Type == msgType {
return msg.Payload
}
}
t.Fatalf("missing %s message in %q", msgType, messages)
return nil
}
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) { func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil { if err != nil {
+1 -1
View File
@@ -83,7 +83,7 @@ func (h *Live) Like(c *gin.Context) {
// Do not create runtime stream state for arbitrary room IDs. The stream // Do not create runtime stream state for arbitrary room IDs. The stream
// repository is process-local, so validating the catalog first also keeps // repository is process-local, so validating the catalog first also keeps
// bogus likes from accumulating forever. // bogus likes from accumulating forever.
room, err := h.catalog.GetRoom(c.Request.Context(), id) room, err := h.catalog.GetPublicRoom(c.Request.Context(), id)
if err != nil { if err != nil {
businessFail(c, err) businessFail(c, err)
return return
+13 -2
View File
@@ -10,7 +10,18 @@ import (
type CatalogService struct { type CatalogService struct {
categories *bizlive.CategoryUsecase categories *bizlive.CategoryUsecase
rooms *bizlive.RoomUsecase rooms roomCatalogUsecase
}
type roomCatalogUsecase interface {
List(context.Context, bizlive.RoomFilter, bizlive.ListOptions) ([]*bizlive.Room, int64, error)
Get(context.Context, uint) (*bizlive.Room, error)
GetPublic(context.Context, uint) (*bizlive.Room, error)
Create(context.Context, *bizlive.Room) (*bizlive.Room, error)
Update(context.Context, *bizlive.Room) (*bizlive.Room, error)
Delete(context.Context, uint) error
SetEnabled(context.Context, uint, bool) error
SaveSort(context.Context, map[uint]int) error
} }
func NewCatalogService(categories *bizlive.CategoryUsecase, rooms *bizlive.RoomUsecase) *CatalogService { func NewCatalogService(categories *bizlive.CategoryUsecase, rooms *bizlive.RoomUsecase) *CatalogService {
@@ -78,7 +89,7 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
} }
func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) { func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) {
enabled := true 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)) items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: input.Status, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
+97
View File
@@ -0,0 +1,97 @@
package live
import (
"context"
"errors"
"testing"
bizlive "kra/internal/biz/live"
"kra/internal/service/dto"
)
type roomCatalogStub struct {
roomCatalogUsecase
filter bizlive.RoomFilter
options bizlive.ListOptions
items []*bizlive.Room
total int64
publicID uint
item *bizlive.Room
err error
}
func (s *roomCatalogStub) List(_ context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
s.filter, s.options = filter, options
return s.items, s.total, s.err
}
func (s *roomCatalogStub) GetPublic(_ context.Context, id uint) (*bizlive.Room, error) {
s.publicID = id
return s.item, s.err
}
func TestListPublicRoomsPreservesOptionalStatusAndEnforcesVisibility(t *testing.T) {
for _, status := range []string{"", bizlive.RoomStatusLive, bizlive.RoomStatusOffline, bizlive.RoomStatusDisabled} {
t.Run("status="+status, func(t *testing.T) {
rooms := &roomCatalogStub{
items: []*bizlive.Room{{ID: 7, Status: bizlive.RoomStatusOffline, ReplayURL: "https://example.test/replay.mp4"}},
total: 21,
}
service := &CatalogService{rooms: rooms}
disabled := false
result, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{
LiveListRequest: dto.LiveListRequest{Page: 2, PageSize: 20, OrderBy: "viewer_count_desc"},
Name: "Room",
CategoryID: 3,
Status: status,
Enabled: &disabled,
})
if err != nil {
t.Fatal(err)
}
if rooms.filter.Status != status || rooms.filter.Name != "Room" || rooms.filter.CategoryID != 3 {
t.Fatalf("public filter=%+v", rooms.filter)
}
if rooms.filter.Enabled == nil || !*rooms.filter.Enabled || rooms.filter.CategoryEnabled == nil || !*rooms.filter.CategoryEnabled {
t.Fatalf("public visibility filter=%+v", rooms.filter)
}
if rooms.options != (bizlive.ListOptions{Offset: 20, Limit: 20, OrderBy: "viewer_count_desc"}) {
t.Fatalf("list options=%+v", rooms.options)
}
if total != 21 || len(result) != 1 || result[0].ID != 7 || result[0].Status != bizlive.RoomStatusOffline || result[0].ReplayURL != rooms.items[0].ReplayURL {
t.Fatalf("public response total=%d rooms=%+v", total, result)
}
})
}
}
func TestGetPublicRoomPreservesOfflineReplayAndAnchorDetails(t *testing.T) {
rooms := &roomCatalogStub{item: &bizlive.Room{
ID: 7,
Name: "Offline Room",
CategoryID: 3,
AnchorUserID: 42,
ReplayURL: "https://example.test/replay.mp4",
Enabled: true,
Status: bizlive.RoomStatusOffline,
}}
service := &CatalogService{rooms: rooms}
result, err := service.GetPublicRoom(context.Background(), 7)
if err != nil {
t.Fatal(err)
}
if rooms.publicID != 7 || result.StreamID != "room-7" || result.Status != bizlive.RoomStatusOffline || result.AnchorUserID != 42 || result.ReplayURL != rooms.item.ReplayURL {
t.Fatalf("public detail=%+v requestedID=%d", result, rooms.publicID)
}
}
func TestPublicCatalogPropagatesUsecaseErrors(t *testing.T) {
service := &CatalogService{rooms: &roomCatalogStub{err: bizlive.ErrRoomDisabled}}
if items, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{}); items != nil || total != 0 || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("ListPublicRooms items=%+v total=%d error=%v", items, total, err)
}
if item, err := service.GetPublicRoom(context.Background(), 7); item != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("GetPublicRoom item=%+v error=%v", item, err)
}
}
+16
View File
@@ -0,0 +1,16 @@
package serial
import (
"crypto/rand"
"encoding/base32"
"fmt"
)
// New appends a 26-character base32 value with 128 random bits to prefix.
func New(prefix string) (string, error) {
var entropy [16]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", fmt.Errorf("generate serial number: %w", err)
}
return prefix + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(entropy[:]), nil
}
+96
View File
@@ -0,0 +1,96 @@
package serial
import (
"encoding/base32"
"regexp"
"strings"
"sync"
"testing"
)
var validSerial = regexp.MustCompile(`^(DS|CZ)[A-Z2-7]{26}$`)
func TestNewPreservesPrefixAndPaymentSafeLength(t *testing.T) {
for _, prefix := range []string{"DS", "CZ"} {
t.Run(prefix, func(t *testing.T) {
value, err := New(prefix)
if err != nil {
t.Fatal(err)
}
if len(value) != 28 || !strings.HasPrefix(value, prefix) || !validSerial.MatchString(value) {
t.Fatalf("unexpected serial format: %q", value)
}
entropy, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimPrefix(value, prefix))
if err != nil || len(entropy) != 16 {
t.Fatalf("serial does not carry 128 bits: bytes=%d error=%v", len(entropy), err)
}
})
}
}
func TestNewIsUniqueAcrossRapidSequentialCalls(t *testing.T) {
const count = 10000
for _, prefix := range []string{"DS", "CZ"} {
t.Run(prefix, func(t *testing.T) {
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
value, err := New(prefix)
if err != nil {
t.Fatal(err)
}
if _, exists := seen[value]; exists {
t.Fatalf("duplicate serial at call %d: %q", index, value)
}
seen[value] = struct{}{}
}
})
}
}
func TestNewIsUniqueAcrossParallelCalls(t *testing.T) {
const workers, perWorker = 32, 500
type result struct {
value string
err error
}
start := make(chan struct{})
results := make(chan result, workers)
var group sync.WaitGroup
for worker := 0; worker < workers; worker++ {
group.Add(1)
go func(worker int) {
defer group.Done()
prefix := "DS"
if worker%2 == 0 {
prefix = "CZ"
}
<-start
for index := 0; index < perWorker; index++ {
value, err := New(prefix)
results <- result{value: value, err: err}
}
}(worker)
}
go func() {
group.Wait()
close(results)
}()
close(start)
seen := make(map[string]struct{}, workers*perWorker)
for result := range results {
if result.err != nil {
t.Errorf("generate parallel serial: %v", result.err)
continue
}
if !validSerial.MatchString(result.value) || len(result.value) > 32 {
t.Errorf("invalid parallel serial format: %q", result.value)
}
if _, exists := seen[result.value]; exists {
t.Errorf("duplicate parallel serial: %q", result.value)
}
seen[result.value] = struct{}{}
}
if len(seen) != workers*perWorker {
t.Fatalf("unique serials=%d, want %d", len(seen), workers*perWorker)
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openFixtureDB() (*gorm.DB, error) {
path, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
if err != nil {
return nil, err
}
root, err := filepath.Abs(outputDir)
if err != nil || filepath.Dir(path) != root {
return nil, fmt.Errorf("database path is not inside the acceptance directory")
}
return gorm.Open(sqlite.Open(path), &gorm.Config{})
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"os"
"path/filepath"
"time"
"kra/internal/service/dto"
)
const (
baseURL = "http://127.0.0.1:18080"
wsURL = "ws://127.0.0.1:18080/ws"
outputDir = ".tmp-live-acceptance"
adminPassword = "LocalAdmin_2026!"
userPassword = "LocalAccept_2026!"
)
type apiClient struct {
http *http.Client
token string
}
type envelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
type account struct {
Username string `json:"username"`
Password string `json:"password"`
ID uint `json:"id"`
}
type fixtures struct {
BaseURL string `json:"baseUrl"`
Database string `json:"database"`
CreatedAt string `json:"createdAt"`
Admin account `json:"admin"`
Anchor account `json:"anchor"`
Viewer account `json:"viewer"`
Other account `json:"other"`
CategoryID uint `json:"categoryId"`
RoomID uint `json:"roomId"`
ReplayID uint `json:"replayRoomId"`
DisabledID uint `json:"disabledRoomId"`
BrowserID uint `json:"browserRoomId,omitempty"`
GiftID uint `json:"giftId"`
Checks []string `json:"setupChecks"`
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
if len(os.Args) != 2 {
log.Fatal("usage: go run ./tmp/live_acceptance <setup|verify|publisher|mock-payment|browser-room>")
}
var err error
switch os.Args[1] {
case "setup":
err = setup()
case "verify":
err = verify()
case "publisher":
err = holdPublisher()
case "mock-payment":
err = serveMockPayments()
case "browser-room":
err = setupBrowserRoom()
default:
err = errors.New("unknown command")
}
if err != nil {
log.Fatal(err)
}
}
func newClient() *apiClient {
jar, _ := cookiejar.New(nil)
return &apiClient{http: &http.Client{Jar: jar, Timeout: 30 * time.Second}}
}
func (c *apiClient) call(method, path string, body, out any) error {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("x-token", c.token)
}
response, err := c.http.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
var result envelope
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
return fmt.Errorf("%s %s status=%d: %w", method, path, response.StatusCode, err)
}
if result.Code != 0 || response.StatusCode >= 400 {
return fmt.Errorf("%s %s status=%d code=%d: %s", method, path, response.StatusCode, result.Code, result.Msg)
}
if out != nil {
return json.Unmarshal(result.Data, out)
}
return nil
}
func login(user account, admin bool) (*apiClient, error) {
c := newClient()
path := "/live/client/auth/login"
if admin {
path = "/base/login"
}
var result struct {
Token string `json:"token"`
}
if err := c.call("POST", path, map[string]any{"username": user.Username, "password": user.Password}, &result); err != nil {
return nil, err
}
if result.Token == "" {
return nil, errors.New("login returned no token")
}
c.token = result.Token
return c, nil
}
func readFixtures() (*fixtures, error) {
raw, err := os.ReadFile(filepath.Join(outputDir, "fixtures.json"))
if err != nil {
return nil, err
}
var f fixtures
if err = json.Unmarshal(raw, &f); err != nil {
return nil, err
}
absolute, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
if err != nil || f.Database != absolute || f.BaseURL != baseURL {
return nil, errors.New("fixture points outside the isolated acceptance environment")
}
return &f, nil
}
func saveJSON(name string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outputDir, name), append(data, '\n'), 0600)
}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(condition bool, format string, args ...any) {
if !condition {
panic(fmt.Sprintf(format, args...))
}
}
func roomPath(id uint) string { return fmt.Sprintf("/live/rooms/%d", id) }
func adminRoomPath(id uint) string { return fmt.Sprintf("/live/admin/rooms/%d", id) }
func makeRoom(name string, categoryID, anchorID uint, replay string, enabled bool) dto.LiveRoomRequest {
return dto.LiveRoomRequest{
Name: name, CategoryID: categoryID, AnchorUserID: anchorID,
CoverURL: "/uploads/acceptance-cover.jpg", Intro: "Local isolated acceptance room",
ReplayURL: replay, Enabled: enabled,
}
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const mockURL = "http://127.0.0.1:18081"
const mockAppID = "local-acceptance-app"
type mockKeys struct {
MerchantPrivate string `json:"merchantPrivate"`
PlatformPrivate string `json:"platformPrivate"`
PlatformPublic string `json:"platformPublic"`
}
type mockOrder struct {
Number string `json:"number"`
Amount string `json:"amount"`
Paid bool `json:"paid"`
}
type mockPayments struct {
keys *mockKeys
key *rsa.PrivateKey
mu sync.Mutex
orders map[string]mockOrder
}
func newRSAKey() (*rsa.PrivateKey, string, string) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
private := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
public, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
must(err)
return key, private, string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))
}
func loadMockKeys() (*mockKeys, error) {
raw, err := os.ReadFile(filepath.Join(outputDir, "mock-payment-keys.json"))
if err == nil {
var keys mockKeys
err = json.Unmarshal(raw, &keys)
return &keys, err
}
if !os.IsNotExist(err) {
return nil, err
}
_, merchant, _ := newRSAKey()
_, private, public := newRSAKey()
keys := &mockKeys{MerchantPrivate: merchant, PlatformPrivate: private, PlatformPublic: public}
return keys, saveJSON("mock-payment-keys.json", keys)
}
func parsePrivate(value string) *rsa.PrivateKey {
block, _ := pem.Decode([]byte(value))
check(block != nil, "invalid local test private key")
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
must(err)
return key
}
func rsa2Sign(key *rsa.PrivateKey, raw []byte) string {
digest := sha256.Sum256(raw)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
must(err)
return base64.StdEncoding.EncodeToString(signature)
}
func signingFields(values url.Values, includeSignType bool) string {
keys := make([]string, 0, len(values))
for key := range values {
if key != "sign" && (includeSignType || key != "sign_type") && values.Get(key) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+values.Get(key))
}
return strings.Join(parts, "&")
}
func serveMockPayments() error {
keys, err := loadMockKeys()
if err != nil {
return err
}
m := &mockPayments{keys: keys, key: parsePrivate(keys.PlatformPrivate), orders: map[string]mockOrder{}}
if raw, readErr := os.ReadFile(filepath.Join(outputDir, "mock-payment-orders.json")); readErr == nil {
must(json.Unmarshal(raw, &m.orders))
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "local-only-mock") })
mux.HandleFunc("/gateway", m.gateway)
mux.HandleFunc("/confirm", m.confirm)
log.Printf("Local-only signed payment mock listening at %s", mockURL)
server := &http.Server{Addr: "127.0.0.1:18081", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
return server.ListenAndServe()
}
func (m *mockPayments) gateway(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
merchant := parsePrivate(m.keys.MerchantPrivate)
signature, err := base64.StdEncoding.DecodeString(r.Form.Get("sign"))
digest := sha256.Sum256([]byte(signingFields(r.Form, true)))
if err != nil || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, digest[:], signature) != nil || r.Form.Get("app_id") != mockAppID {
http.Error(w, "invalid local merchant signature", http.StatusForbidden)
return
}
var content map[string]any
if err = json.Unmarshal([]byte(r.Form.Get("biz_content")), &content); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
number, _ := content["out_trade_no"].(string)
method := r.Form.Get("method")
m.mu.Lock()
defer m.mu.Unlock()
var response map[string]any
switch method {
case "alipay.trade.precreate":
amount, _ := content["total_amount"].(string)
m.orders[number] = mockOrder{Number: number, Amount: amount}
must(saveJSON("mock-payment-orders.json", m.orders))
response = map[string]any{
"code": "10000", "msg": "Success", "out_trade_no": number,
"qr_code": mockURL + "/confirm?orderNo=" + url.QueryEscape(number),
}
case "alipay.trade.query":
order, found := m.orders[number]
if !found {
http.Error(w, "unknown local test order", http.StatusNotFound)
return
}
status := "WAIT_BUYER_PAY"
if order.Paid {
status = "TRADE_SUCCESS"
}
response = map[string]any{
"code": "10000", "msg": "Success", "trade_no": "LOCAL-" + number, "out_trade_no": number,
"trade_status": status, "total_amount": order.Amount, "buyer_pay_amount": order.Amount,
"receipt_amount": order.Amount, "trans_currency": "CNY",
}
default:
http.Error(w, "method not implemented by local-only mock", http.StatusBadRequest)
return
}
raw, err := json.Marshal(response)
must(err)
envelope := map[string]any{strings.ReplaceAll(method, ".", "_") + "_response": json.RawMessage(raw), "sign": rsa2Sign(m.key, raw)}
w.Header().Set("Content-Type", "application/json")
must(json.NewEncoder(w).Encode(envelope))
}
func (m *mockPayments) confirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "local test confirmation requires POST", http.StatusMethodNotAllowed)
return
}
must(r.ParseForm())
number := r.Form.Get("orderNo")
m.mu.Lock()
order, found := m.orders[number]
if !found {
m.mu.Unlock()
http.Error(w, "unknown local test order", http.StatusNotFound)
return
}
order.Paid = true
m.orders[number] = order
must(saveJSON("mock-payment-orders.json", m.orders))
m.mu.Unlock()
fields := url.Values{
"app_id": {mockAppID}, "trade_no": {"LOCAL-" + number}, "out_trade_no": {number},
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {order.Amount}, "sign_type": {"RSA2"},
}
fields.Set("sign", rsa2Sign(m.key, []byte(signingFields(fields, false))))
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.PostForm(baseURL+"/payment/callback/alipay", fields)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer response.Body.Close()
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}
func configureMockPayment(admin *apiClient) error {
keys, err := loadMockKeys()
if err != nil {
return err
}
response, err := http.Get(mockURL + "/health")
if err != nil {
return fmt.Errorf("start the local mock-payment command before verification: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("local payment mock unavailable")
}
return admin.call("PUT", "/integration/configs/payment/alipay", map[string]any{
"enabled": true,
"config": map[string]any{
"app_id": mockAppID, "private_key": keys.MerchantPrivate, "public_key": keys.PlatformPublic,
"environment": "sandbox", "sign_type": "RSA2", "method": "alipay.trade.precreate", "gateway_url": mockURL + "/gateway",
"notify_url": baseURL + "/payment/callback/alipay",
},
}, nil)
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"kra/internal/service/dto"
)
func setup() error {
if f, err := readFixtures(); err == nil {
fmt.Printf("Existing isolated fixtures retained: %s room=%d replay=%d\n", f.Database, f.RoomID, f.ReplayID)
return nil
}
c := newClient()
var status struct {
NeedInit bool `json:"needInit"`
}
must(c.call("POST", "/init/checkdb", nil, &status))
databaseDir, err := filepath.Abs(outputDir)
if err != nil {
return err
}
if status.NeedInit {
must(c.call("POST", "/init/initdb", dto.DatabaseInitRequest{
DBType: "sqlite", DBName: "acceptance.db", DBPath: databaseDir,
AdminPassword: adminPassword,
}, nil))
}
db, err := openFixtureDB()
if err != nil {
return err
}
// This test-only fixture setting must be seeded before the first login.
// Production configuration and security settings are never touched.
must(db.Exec("UPDATE sys_security_config SET captcha_open = 1000 WHERE id = 1").Error)
sqlDB, err := db.DB()
if err != nil {
return err
}
must(sqlDB.Close())
f := &fixtures{
BaseURL: baseURL, Database: filepath.Join(databaseDir, "acceptance.db"),
CreatedAt: time.Now().Format(time.RFC3339),
Admin: account{Username: "admin", Password: adminPassword, ID: 1},
}
admin, err := login(f.Admin, true)
if err != nil {
return err
}
f.Checks = append(f.Checks, "isolated SQLite initialization API and administrator login")
users := []*account{&f.Anchor, &f.Viewer, &f.Other}
for index, username := range []string{"accept_anchor", "accept_viewer", "accept_other"} {
var auth dto.LiveClientAuthResponse
input := dto.LiveClientRegisterRequest{Username: username, Nickname: username, Password: userPassword, ConfirmPassword: userPassword}
client := newClient()
if err = client.call("POST", "/live/client/auth/register", input, &auth); err != nil {
client, err = login(account{Username: username, Password: userPassword}, false)
if err != nil {
return fmt.Errorf("create fixture %s: %w", username, err)
}
var user dto.LiveClientAccountResponse
must(client.call("GET", "/live/client/me", nil, &user))
auth.User = &user
}
check(auth.User != nil && auth.User.UserID != 0, "register %s returned no user", username)
*users[index] = account{Username: username, Password: userPassword, ID: auth.User.UserID}
}
f.Checks = append(f.Checks, "PC anchor and two viewer registrations")
var integration dto.IntegrationConfigResponse
must(admin.call("GET", "/integration/configs/websocket/melody", nil, &integration))
must(admin.call("PUT", "/integration/configs/websocket/melody", dto.IntegrationConfigRequest{Enabled: true, Config: integration.Config}, nil))
f.Checks = append(f.Checks, "WebSocket integration enabled through administration API")
var category dto.LiveCategoryResponse
categoryInput := dto.LiveCategoryRequest{Name: "API Test", Icon: "video", Sort: 1, Enabled: true}
must(admin.call("POST", "/live/admin/categories", categoryInput, &category))
f.CategoryID = category.ID
categoryInput.Name = "API Live"
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), categoryInput, &category))
f.Checks = append(f.Checks, "category creation and update through administration API")
for _, room := range []struct {
name string
replay string
enabled bool
id *uint
}{
{"Acceptance Live Room", "", true, &f.RoomID},
{"Acceptance Replay Room", "/uploads/acceptance-replay.webm", true, &f.ReplayID},
{"Acceptance Disabled Room", "", false, &f.DisabledID},
} {
var value dto.LiveRoomResponse
must(admin.call("POST", "/live/admin/rooms", makeRoom(room.name, f.CategoryID, f.Anchor.ID, room.replay, room.enabled), &value))
*room.id = value.ID
}
f.Checks = append(f.Checks, "live, recorded and disabled rooms created through administration API")
var gift dto.GiftResponse
must(admin.call("POST", "/live/admin/gifts", dto.GiftRequest{
Name: "API Star", Icon: "star", Description: "Local acceptance gift",
Price: 7, Tiers: []int{1, 10}, Enabled: true, Sort: 1,
}, &gift))
f.GiftID = gift.ID
f.Checks = append(f.Checks, "configured gift created through administration API")
must(admin.call("PUT", "/live/admin/recharge/plans", dto.RechargePlansRequest{Plans: []*dto.RechargePlan{
{ID: 1, Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true},
{ID: 2, Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
{ID: 3, Sort: 3, Diamonds: 10, AmountCents: 100, Enabled: true},
}}, nil))
f.Checks = append(f.Checks, "recharge plans configured through administration API")
must(os.MkdirAll(filepath.Join(outputDir, "uploads"), 0700))
cover, err := os.ReadFile("frontend/src/assets/images/tupian/cyber_streamer_1.jpg")
if err != nil {
return err
}
must(os.WriteFile(filepath.Join(outputDir, "uploads", "acceptance-cover.jpg"), cover, 0600))
must(saveJSON("fixtures.json", f))
fmt.Printf("API=%s database=%s\nanchor=%s viewer=%s other=%s password=%s\nroom=%d replay=%d disabled=%d category=%d gift=%d\n",
baseURL, f.Database, f.Anchor.Username, f.Viewer.Username, f.Other.Username, userPassword,
f.RoomID, f.ReplayID, f.DisabledID, f.CategoryID, f.GiftID)
return nil
}
func setupBrowserRoom() error {
f, err := readFixtures()
if err != nil {
return err
}
if f.BrowserID == 0 {
admin, err := login(f.Admin, true)
if err != nil {
return err
}
var room dto.LiveRoomResponse
must(admin.call("POST", "/live/admin/rooms", makeRoom("Acceptance Browser Room", f.CategoryID, f.Anchor.ID, "", true), &room))
f.BrowserID = room.ID
must(saveJSON("fixtures.json", f))
}
fmt.Printf("Dedicated browser publisher room=%d anchor=%s password=%s; API verification uses room=%d\n",
f.BrowserID, f.Anchor.Username, f.Anchor.Password, f.RoomID)
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"fmt"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
type acceptanceReport struct {
StartedAt string `json:"startedAt"`
Checks []string `json:"checks"`
Findings []string `json:"findings"`
Records map[string]any `json:"records"`
Database string `json:"database"`
}
func verify() (err error) {
f, err := readFixtures()
if err != nil {
return err
}
report := &acceptanceReport{StartedAt: time.Now().Format(time.RFC3339), Database: f.Database, Records: map[string]any{}}
defer func() {
if recovered := recover(); recovered != nil {
report.Findings = append(report.Findings, fmt.Sprint(recovered))
}
if saveErr := saveJSON("report.json", report); saveErr != nil {
err = saveErr
return
}
if len(report.Findings) > 0 {
err = fmt.Errorf("%d passed groups, %d findings; inspect %s/report.json", len(report.Checks), len(report.Findings), outputDir)
}
}()
admin, err := login(f.Admin, true)
must(err)
viewer, err := login(f.Viewer, false)
must(err)
other, err := login(f.Other, false)
must(err)
anchor, err := login(f.Anchor, false)
must(err)
public := newClient()
db, err := openFixtureDB()
must(err)
sqlDB, err := db.DB()
must(err)
defer sqlDB.Close()
must(admin.call("PATCH", adminRoomPath(f.DisabledID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
var list struct {
List []*dto.LiveRoomResponse `json:"list"`
Total int64 `json:"total"`
}
must(public.call("GET", fmt.Sprintf("/live/rooms?categoryId=%d&pageSize=100", f.CategoryID), nil, &list))
foundLive, foundReplay := false, false
for _, item := range list.List {
check(item.Enabled && item.ID != f.DisabledID, "public list exposed a disabled room: %+v", item)
foundLive = foundLive || item.ID == f.RoomID
foundReplay = foundReplay || item.ID == f.ReplayID
}
check(foundLive && foundReplay, "public listing omitted enabled live/replay fixtures: %+v", list)
var replay dto.LiveRoomResponse
must(public.call("GET", roomPath(f.ReplayID), nil, &replay))
check(replay.Status == "offline" && replay.ReplayURL != "", "offline replay inaccessible: %+v", replay)
check(public.call("GET", roomPath(f.DisabledID), nil, nil) != nil, "disabled room was exposed publicly")
passed(report, "public list includes offline and recorded rooms and excludes disabled rooms")
verifyCatalog(admin, public, viewer, f, report)
verifyRecharge(admin, viewer, other, f, db, report)
verifyLive(admin, public, anchor, viewer, other, f, db, report)
verifyDisabledAccount(admin, other, f, report)
verifyAdminSurface(admin, viewer, f, db, report)
verifyClientAccount(report)
var final ledger.WalletPO
must(db.Where("user_id = ?", f.Viewer.ID).First(&final).Error)
var chatRows, sends, rechargeOrders int64
must(db.Table("live_chat_messages").Count(&chatRows).Error)
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", f.Viewer.ID).Count(&sends).Error)
must(db.Model(&ledger.RechargeOrderPO{}).Where("user_id = ?", f.Viewer.ID).Count(&rechargeOrders).Error)
check(chatRows == 0, "chat content was persisted: %d rows", chatRows)
report.Records["finalWallet"] = final
report.Records["giftSendRows"] = sends
report.Records["rechargeOrderRows"] = rechargeOrders
report.Records["chatRows"] = chatRows
passed(report, "SQLite wallet, gift-send and recharge records reconciled; chat table remains empty")
fmt.Printf("Completed %d acceptance check groups\n", len(report.Checks))
return nil
}
func passed(report *acceptanceReport, text string) {
report.Checks = append(report.Checks, text)
fmt.Printf("PASS %s\n", text)
must(saveJSON("report.json", report))
}
func wallet(db *gorm.DB, userID uint) ledger.WalletPO {
var value ledger.WalletPO
must(db.Where("user_id = ?", userID).First(&value).Error)
return value
}
func giftCount(db *gorm.DB, userID uint) int64 {
var count int64
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", userID).Count(&count).Error)
return count
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"fmt"
"time"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyAdminSurface(admin, viewer *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
for _, path := range []string{
"/live/admin/dashboard",
"/live/admin/categories?pageSize=100",
"/live/admin/rooms?pageSize=100",
"/live/admin/gifts?pageSize=100",
fmt.Sprintf("/live/admin/gifts/%d", f.GiftID),
"/live/admin/sends?pageSize=100",
"/live/admin/orders?pageSize=100",
"/live/admin/users?pageSize=100",
} {
must(admin.call("GET", path, nil, nil))
check(viewer.call("GET", path, nil, nil) != nil, "client accessed %s", path)
}
must(admin.call("PUT", "/live/admin/rooms/sort", dto.LiveSortRequest{Values: map[uint]int{f.RoomID: 3, f.ReplayID: 4}}, nil))
must(admin.call("PUT", "/live/admin/gifts/sort", dto.LiveSortRequest{Values: map[uint]int{f.GiftID: 1}}, nil))
var room dto.LiveRoomResponse
must(admin.call("GET", adminRoomPath(f.RoomID), nil, &room))
check(room.Sort == 3, "room sorting was not persisted")
var settings dto.PlatformSetting
must(newClient().call("GET", "/live/settings", nil, &settings))
check(settings.DiamondRateCents > 0, "diamond rate missing")
must(admin.call("PUT", "/live/admin/settings", settings, nil))
before := wallet(db, f.Viewer.ID)
path := fmt.Sprintf("/live/admin/users/%d", f.Viewer.ID)
var account dto.AssetUserResponse
must(admin.call("GET", path, nil, &account))
check(account.DiamondBalance == before.DiamondBalance, "admin asset balance differs from ledger")
for _, operation := range []string{"increase", "decrease"} {
must(admin.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: operation, Amount: 5, Reason: "local acceptance adjustment"}, &account))
}
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "balanced manual adjustments changed wallet")
check(viewer.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: "increase", Amount: 5, Reason: "unauthorized adjustment"}, nil) != nil, "client adjusted their own wallet")
passed(report, "all administration list/detail surfaces, sorting, settings and audited balance adjustment; clients denied administration access")
}
func verifyClientAccount(report *acceptanceReport) {
client := newClient()
username := fmt.Sprintf("accept_%d", time.Now().UnixMilli())
var registered dto.LiveClientAuthResponse
must(client.call("POST", "/live/client/auth/register", dto.LiveClientRegisterRequest{
Username: username, Nickname: "Local viewer", Password: userPassword, ConfirmPassword: userPassword,
}, &registered))
check(registered.User != nil && registered.User.DiamondBalance == 100, "new account did not receive exactly 100 diamonds")
client.token = registered.Token
var profile dto.LiveClientAccountResponse
must(client.call("PUT", "/live/client/me", dto.LiveClientProfileRequest{Nickname: "Updated viewer", Avatar: "/uploads/acceptance-cover.jpg"}, &profile))
check(profile.Nickname == "Updated viewer", "profile update not applied")
var balance dto.LiveClientBalanceResponse
must(client.call("GET", "/live/client/balance", nil, &balance))
check(balance.DiamondBalance == 100, "profile update changed wallet")
check(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
OldPassword: "WrongPassword!", NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
}, nil) != nil, "wrong previous password accepted")
var changed dto.LiveClientAuthResponse
must(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
OldPassword: userPassword, NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
}, &changed))
check(changed.Token != "" && changed.Token != registered.Token, "password change did not rotate session")
stale := newClient()
stale.token = registered.Token
check(stale.call("GET", "/live/client/me", nil, nil) != nil, "previous session remained valid after password change")
client.token = changed.Token
must(client.call("GET", "/live/client/me", nil, &profile))
must(client.call("POST", "/live/client/auth/logout", nil, nil))
check(client.call("GET", "/live/client/me", nil, nil) != nil, "logged-out session remained authorized")
report.Records["accountLifecycleUserID"] = profile.UserID
passed(report, "fresh registration grants 100 diamonds; profile/balance, password validation and rotation, expired session and logout")
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"time"
"kra/internal/service/dto"
)
func verifyCatalog(admin, public, viewer *apiClient, f *fixtures, report *acceptanceReport) {
var category dto.LiveCategoryResponse
name := fmt.Sprintf("T%d", time.Now().UnixMilli()%100000000)
input := dto.LiveCategoryRequest{Name: name, Icon: "video", Sort: 2, Enabled: false}
must(admin.call("POST", "/live/admin/categories", input, &category))
if category.Enabled {
report.Findings = append(report.Findings, "Creating a category with enabled:false returns enabled:true")
}
input.Enabled = true
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, &category))
check(category.Enabled, "category update did not preserve enabled:true")
var room dto.LiveRoomResponse
roomInput := makeRoom("API Disabled Test", category.ID, f.Anchor.ID, "", false)
must(admin.call("POST", "/live/admin/rooms", roomInput, &room))
if room.Enabled {
report.Findings = append(report.Findings, "Creating a room with enabled:false returns enabled:true and exposes the room publicly")
}
must(admin.call("PATCH", adminRoomPath(room.ID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room remains public after disabling")
check(viewer.call("GET", adminRoomPath(room.ID), nil, nil) != nil, "PC viewer accessed an administration room")
roomInput.Enabled = true
roomInput.Intro = "Updated through API"
must(admin.call("PUT", adminRoomPath(room.ID), roomInput, &room))
check(room.Intro == roomInput.Intro && room.Enabled, "room update did not persist")
input.Enabled = false
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room in a disabled category was exposed")
input.Enabled = true
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
must(admin.call("DELETE", adminRoomPath(room.ID), nil, nil))
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "deleted room remained public")
must(admin.call("DELETE", fmt.Sprintf("/live/admin/categories/%d", category.ID), nil, nil))
var gift dto.GiftResponse
giftInput := dto.GiftRequest{Name: name, Icon: "star", Price: 9, Tiers: []int{1, 10}, Enabled: true, Sort: 2}
must(admin.call("POST", "/live/admin/gifts", giftInput, &gift))
giftInput.Description = "Updated through API"
must(admin.call("PUT", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), giftInput, &gift))
check(gift.Description == giftInput.Description, "gift update did not persist")
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", gift.ID), dto.LiveStatusRequest{Enabled: false}, nil))
must(admin.call("DELETE", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), nil, nil))
passed(report, "administrator category, room and gift CRUD; category/room disabling; client administration access denied")
}
func verifyDisabledAccount(admin, other *apiClient, f *fixtures, report *acceptanceReport) {
path := fmt.Sprintf("/live/admin/users/%d/status", f.Other.ID)
if err := admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: false}, nil); err != nil {
report.Findings = append(report.Findings, "Administrator cannot disable the client user: "+err.Error())
return
}
defer func() {
must(admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: true}, nil))
}()
check(admin.call("PATCH", fmt.Sprintf("/live/admin/users/%d/status", f.Admin.ID), dto.LiveStatusRequest{Enabled: false}, nil) != nil, "administrator disabled their own account")
_, err := login(f.Other, false)
check(err != nil, "disabled account could still log in")
check(other.call("GET", "/live/client/balance", nil, nil) != nil, "disabled account token remained authorized")
passed(report, "disabled user cannot log in or use an existing authenticated API token")
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"fmt"
"strings"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyLive(admin, public, anchor, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
defer func() {
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
}()
for _, token := range []string{"", other.token} {
wrong, err := joinSocket(token, "publisher", f.RoomID)
if wrong != nil {
_ = wrong.conn.Close()
}
check(err != nil, "non-owner/anonymous client was allowed to publish")
}
publisher, err := joinSocket(anchor.token, "publisher", f.RoomID)
must(err)
defer publisher.conn.Close()
var room dto.LiveRoomResponse
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "live", "registered publisher did not make room live: %+v", room)
anonymous, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer anonymous.conn.Close()
must(anonymous.write("ChatSend", map[string]any{"content": "anonymous must be rejected"}))
message, err := anonymous.wait("Error", nil, 5*time.Second)
must(err)
check(message.Payload["code"] == "AUTH_REQUIRED", "anonymous chat was not rejected: %+v", message)
first, err := joinSocket(viewer.token, "subscriber", f.RoomID)
must(err)
defer first.conn.Close()
second, err := joinSocket(other.token, "subscriber", f.RoomID)
must(err)
defer second.conn.Close()
for index, sender := range []*signalSocket{first, second} {
content := fmt.Sprintf("acceptance-%d-%d", time.Now().UnixNano(), index)
must(sender.write("ChatSend", map[string]any{"content": content, "userId": f.Anchor.ID}))
for _, receiver := range []*signalSocket{first, second, anonymous} {
broadcast, err := receiver.wait("ChatMessage", func(payload map[string]any) bool { return payload["content"] == content }, 5*time.Second)
must(err)
expectedUser := f.Viewer.ID
if index == 1 {
expectedUser = f.Other.ID
}
check(broadcast.Payload["userId"] == float64(expectedUser), "chat trusted spoofed sender identity: %+v", broadcast)
}
}
late, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer late.conn.Close()
must(late.quietChat(700 * time.Millisecond))
assertNoHistory(public, f.RoomID)
passed(report, "authenticated owner publishing, anonymous viewing, unauthenticated chat rejection, two-user broadcast and no history replay")
var follow struct {
Followed bool `json:"followed"`
}
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
check(follow.Followed, "follow was not created")
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
var follows int64
must(db.Table("live_room_follows").Where("user_id = ? AND room_id = ?", f.Viewer.ID, f.RoomID).Count(&follows).Error)
check(follows == 1, "following twice created duplicate rows: %d", follows)
must(viewer.call("DELETE", roomPath(f.RoomID)+"/follow", nil, &follow))
check(!follow.Followed, "unfollow failed")
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, nil))
var likes struct {
LikeCount int `json:"likeCount"`
}
must(public.call("POST", roomPath(f.RoomID)+"/like", nil, &likes))
check(likes.LikeCount == room.LikeCount+1, "like count did not increment")
passed(report, "follow/unfollow idempotency reconciles to one database row and live-room likes increment")
// The old smoke publisher never read ping frames and disconnected at the
// default 60-second pong deadline, hiding gift and live-state regressions.
fmt.Println("Waiting 65 seconds to cross the default WebSocket pong deadline...")
time.Sleep(65 * time.Second)
select {
case <-publisher.done:
panic("publisher disconnected before the 65-second liveness assertion")
default:
}
must(publisher.write("Heartbeat", map[string]any{}))
_, err = publisher.wait("HeartbeatAck", nil, 5*time.Second)
must(err)
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "live", "room fell offline despite heartbeat/pong reads")
passed(report, "publisher connection remains alive beyond 60 seconds with continuous reads, pongs and heartbeats")
before := wallet(db, f.Viewer.ID)
otherBefore := wallet(db, f.Other.ID)
countBefore := giftCount(db, f.Viewer.ID)
var sent dto.GiftSendResponse
must(viewer.call("POST", roomPath(f.RoomID)+"/gifts", map[string]any{
"giftId": f.GiftID, "quantity": 3, "userId": f.Other.ID,
}, &sent))
check(sent.Quantity == 3 && sent.DiamondTotal == 21 && sent.UserID == f.Viewer.ID, "custom gift send did not use authenticated account and configured price: %+v", sent)
after := wallet(db, f.Viewer.ID)
check(after.DiamondBalance == before.DiamondBalance-21 && after.TotalSpentDiamonds == before.TotalSpentDiamonds+21, "gift wallet change incorrect: before=%+v after=%+v", before, after)
check(wallet(db, f.Other.ID).DiamondBalance == otherBefore.DiamondBalance, "gift altered another user's wallet")
check(giftCount(db, f.Viewer.ID) == countBefore+1, "gift ledger receipt missing")
giftMessage, err := second.wait("GiftMessage", nil, 5*time.Second)
must(err)
check(giftMessage.Payload["quantity"] == float64(3), "gift notification quantity incorrect: %+v", giftMessage)
var stored ledger.GiftSendPO
must(db.First(&stored, sent.ID).Error)
check(stored.Quantity == 3 && stored.DiamondTotal == 21 && stored.UserID == f.Viewer.ID, "gift ledger mismatch: %+v", stored)
passed(report, "custom gift quantity outside presets debits exact amount atomically, records receipt and broadcasts gift")
sendErr := viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 9999}, nil)
check(sendErr != nil && strings.Contains(sendErr.Error(), "insufficient"), "insufficient-balance response not returned: %v", sendErr)
for _, quantity := range []int{0, -1, 10000} {
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: quantity}, nil) != nil, "invalid gift quantity %d accepted", quantity)
}
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: false}, nil))
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled gift accepted")
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "failed gift request changed wallet or receipt count")
passed(report, "insufficient balance, invalid quantity and disabled gift reject without debit or extra ledger rows")
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled room accepted a gift")
denied, err := joinSocket("", "subscriber", f.RoomID)
if denied != nil {
_ = denied.conn.Close()
}
check(err != nil, "disabled room accepted a new subscriber")
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
must(publisher.write("Leave", map[string]any{}))
_, err = first.wait("PublisherStatus", func(payload map[string]any) bool {
return payload["ready"] == false && payload["reason"] == "left"
}, 5*time.Second)
must(err)
must(public.call("GET", roomPath(f.RoomID), nil, &room))
check(room.Status == "offline", "room did not become offline after publisher left")
must(first.write("ChatSend", map[string]any{"content": "offline chat must be rejected"}))
message, err = first.wait("Error", nil, 5*time.Second)
must(err)
check(message.Payload["code"] == "ROOM_UNAVAILABLE", "offline room accepted chat: %+v", message)
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "offline room accepted gift")
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "disabled/offline gift requests changed ledger")
assertNoHistory(public, f.RoomID)
restarted, err := joinSocket(anchor.token, "publisher", f.RoomID)
must(err)
defer restarted.conn.Close()
afterRestart, err := joinSocket("", "subscriber", f.RoomID)
must(err)
defer afterRestart.conn.Close()
must(afterRestart.quietChat(700 * time.Millisecond))
assertNoHistory(public, f.RoomID)
must(restarted.write("Leave", map[string]any{}))
passed(report, "disabled/offline room access and gift restrictions; publisher-offline notification; reopen has no previous chat history")
report.Records["lastGiftSendID"] = sent.ID
}
func assertNoHistory(public *apiClient, id uint) {
var history struct {
List []any `json:"list"`
}
must(public.call("GET", roomPath(id)+"/messages", nil, &history))
check(len(history.List) == 0, "HTTP endpoint exposed previous session chat")
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"io"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/data/ledger"
"kra/internal/service/dto"
"gorm.io/gorm"
)
func verifyRecharge(admin, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
must(configureMockPayment(admin))
var plans struct {
List []dto.RechargePlan `json:"list"`
}
must(newClient().call("GET", "/live/recharge/plans", nil, &plans))
check(len(plans.List) > 0 && plans.List[0].AmountCents > 0, "recharge plans missing")
var providers struct {
List []dto.RechargeProvider `json:"list"`
}
must(newClient().call("GET", "/live/recharge/providers", nil, &providers))
found := false
for _, item := range providers.List {
found = found || item.ID == "alipay"
}
check(found, "local mock payment provider was not available")
before := wallet(db, f.Viewer.ID)
var checkout dto.RechargeCheckoutResponse
must(viewer.call("POST", "/live/recharge/orders", dto.RechargeOrderRequest{
PlanID: plans.List[0].ID, Provider: "alipay", NotifyURL: baseURL + "/payment/callback/alipay",
Extra: map[string]any{"userId": f.Other.ID, "diamonds": 99999},
}, &checkout))
check(checkout.Order != nil && checkout.Payment != nil, "recharge checkout missing order or payment")
order := checkout.Order
check(order.UserID == f.Viewer.ID && order.Diamonds == 60 && order.AmountCents == 600, "untrusted checkout values changed ownership or price: %+v", order)
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "pending recharge credited wallet")
path := "/live/recharge/orders/" + order.OrderNo
var owned dto.RechargeOrderResponse
must(viewer.call("GET", path, nil, &owned))
check(other.call("GET", path, nil, nil) != nil, "another user accessed recharge order")
check(other.call("GET", path+"/status", nil, nil) != nil, "another user accessed recharge status")
check(newClient().call("GET", path, nil, nil) != nil, "anonymous user accessed recharge order")
must(admin.call("GET", "/live/admin/orders/"+order.OrderNo, nil, nil))
client := &http.Client{Timeout: 15 * time.Second}
badResponse, err := client.PostForm(baseURL+"/payment/callback/alipay", url.Values{
"app_id": {mockAppID}, "out_trade_no": {order.OrderNo}, "trade_no": {"LOCAL-" + order.OrderNo},
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"6.00"}, "sign_type": {"RSA2"}, "sign": {"invalid"},
})
must(err)
badBody, err := io.ReadAll(badResponse.Body)
must(err)
must(badResponse.Body.Close())
check(strings.TrimSpace(string(badBody)) != "success", "invalid callback signature was acknowledged")
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "invalid callback credited wallet")
passed(report, "public recharge plans/provider, signed local checkout, order ownership and invalid callback rejection")
for repeat := 0; repeat < 2; repeat++ {
response, err := client.PostForm(mockURL+"/confirm", url.Values{"orderNo": {order.OrderNo}})
must(err)
body, err := io.ReadAll(response.Body)
must(err)
must(response.Body.Close())
check(response.StatusCode == 200 && strings.TrimSpace(string(body)) == "success", "local signed callback rejected: status=%d body=%s", response.StatusCode, body)
current := wallet(db, f.Viewer.ID)
check(current.DiamondBalance == before.DiamondBalance+60 && current.TotalRechargeCents == before.TotalRechargeCents+600, "recharge was not credited exactly once: before=%+v after=%+v", before, current)
}
must(viewer.call("GET", path, nil, &owned))
check(owned.PaymentStatus == "paid" && owned.FulfillmentStatus == "succeeded" && owned.CreditedAt != nil, "recharge status was not fulfilled: %+v", owned)
var stored ledger.RechargeOrderPO
must(db.Where("order_no = ?", order.OrderNo).First(&stored).Error)
check(stored.UserID == f.Viewer.ID && stored.CreditedAt != nil, "recharge database ownership or credit marker incorrect")
report.Records["rechargeOrderNo"] = order.OrderNo
passed(report, "valid local RSA2 callback, authoritative signed query and duplicate callback credit wallet only once")
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"fmt"
"log"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
type signalMessage struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
type signalSocket struct {
conn *websocket.Conn
messages chan signalMessage
done chan struct{}
writeMu sync.Mutex
}
func connectSocket(token string) (*signalSocket, error) {
endpoint := wsURL
if token != "" {
endpoint += "?token=" + url.QueryEscape(token)
}
conn, _, err := websocket.DefaultDialer.Dial(endpoint, nil)
if err != nil {
return nil, err
}
s := &signalSocket{conn: conn, messages: make(chan signalMessage, 512), done: make(chan struct{})}
// The read loop processes WebSocket ping frames, allowing the server to
// receive pongs while HTTP assertions and application heartbeats run.
go func() {
defer close(s.done)
defer close(s.messages)
for {
var message signalMessage
if err := conn.ReadJSON(&message); err != nil {
return
}
select {
case s.messages <- message:
case <-time.After(5 * time.Second):
_ = conn.Close()
return
}
}
}()
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
if s.write("Heartbeat", map[string]any{}) != nil {
_ = s.conn.Close()
return
}
}
}
}()
return s, nil
}
func (s *signalSocket) write(kind string, payload map[string]any) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_ = s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
return s.conn.WriteJSON(signalMessage{Type: kind, Payload: payload})
}
func (s *signalSocket) wait(kind string, match func(map[string]any) bool, timeout time.Duration) (signalMessage, error) {
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case message, ok := <-s.messages:
if !ok {
return signalMessage{}, fmt.Errorf("WebSocket closed waiting for %s", kind)
}
if message.Type == kind && (match == nil || match(message.Payload)) {
return message, nil
}
if message.Type == "JoinRejected" {
return message, fmt.Errorf("join rejected: %v", message.Payload)
}
case <-timer.C:
return signalMessage{}, fmt.Errorf("timed out waiting for %s", kind)
}
}
}
func (s *signalSocket) quietChat(duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
for {
select {
case message, ok := <-s.messages:
if !ok {
return fmt.Errorf("WebSocket closed while checking no chat history")
}
if message.Type == "ChatMessage" || message.Type == "ChatHistory" {
return fmt.Errorf("unexpected chat replay: %s %v", message.Type, message.Payload)
}
case <-timer.C:
return nil
}
}
}
func joinSocket(token, role string, room uint) (*signalSocket, error) {
s, err := connectSocket(token)
if err != nil {
return nil, err
}
if err = s.write("Join", map[string]any{"role": role, "streamId": fmt.Sprintf("room-%d", room)}); err != nil {
_ = s.conn.Close()
return nil, err
}
if _, err = s.wait("Joined", nil, 5*time.Second); err != nil {
_ = s.conn.Close()
return nil, err
}
return s, nil
}
func holdPublisher() error {
f, err := readFixtures()
if err != nil {
return err
}
client, err := login(f.Anchor, false)
if err != nil {
return err
}
socket, err := joinSocket(client.token, "publisher", f.RoomID)
if err != nil {
return err
}
defer socket.conn.Close()
log.Printf("Local fixture publisher joined room=%d with continuous reads and heartbeats", f.RoomID)
for message := range socket.messages {
if message.Type == "Error" || message.Type == "JoinRejected" {
return fmt.Errorf("publisher error: %v", message.Payload)
}
}
return fmt.Errorf("publisher disconnected")
}
+33
View File
@@ -0,0 +1,33 @@
# Live Room Links
Room links point to the PC client, not the administration application:
- Viewer: `/#/live/:id`
- Publisher: `/#/studio/:id`
Production defaults to the current origin root, matching the deployment with
the administration application at `/admin/`. Development defaults to the same
hostname on the PC Vite port `5173`.
When the client uses another origin, port, or base path, set
`VITE_LIVE_CLIENT_URL` in the administration build environment, for example:
```dotenv
VITE_LIVE_CLIENT_URL=https://live.example.com/
```
For a PC development server on another port:
```dotenv
VITE_LIVE_CLIENT_URL=http://localhost:5174/
```
Publisher links contain no authentication credentials. The recipient must log
in to the PC client as the user assigned to that enabled room. A room configured
with an external stream continues to use its external publisher.
Run the link regression tests from `web`:
```text
node --test src/modules/live/utils/*.test.js
```
+1 -6
View File
@@ -15,9 +15,7 @@ export const updateCategory = (id, data) => put(`/live/admin/categories/${id}`,
export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`) export const deleteCategory = (id) => remove(`/live/admin/categories/${id}`)
export const getRooms = (params) => get('/live/admin/rooms', params) export const getRooms = (params) => get('/live/admin/rooms', params)
export const getPublicRooms = (params) => get('/live/rooms', params) export const getRoomDetail = (id) => get(`/live/admin/rooms/${id}`)
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) export const createRoom = (data) => post('/live/admin/rooms', data)
export const updateRoom = (id, data) => put(`/live/admin/rooms/${id}`, data) export const updateRoom = (id, data) => put(`/live/admin/rooms/${id}`, data)
export const deleteRoom = (id) => remove(`/live/admin/rooms/${id}`) export const deleteRoom = (id) => remove(`/live/admin/rooms/${id}`)
@@ -25,20 +23,17 @@ export const setRoomStatus = (id, enabled) => patch(`/live/admin/rooms/${id}/sta
export const saveRoomSort = (values) => put('/live/admin/rooms/sort', { values }) export const saveRoomSort = (values) => put('/live/admin/rooms/sort', { values })
export const getGifts = (params) => get('/live/admin/gifts', params) export const getGifts = (params) => get('/live/admin/gifts', params)
export const getPublicGifts = () => get('/live/gifts')
export const createGift = (data) => post('/live/admin/gifts', data) export const createGift = (data) => post('/live/admin/gifts', data)
export const updateGift = (id, data) => put(`/live/admin/gifts/${id}`, data) export const updateGift = (id, data) => put(`/live/admin/gifts/${id}`, data)
export const deleteGift = (id) => remove(`/live/admin/gifts/${id}`) export const deleteGift = (id) => remove(`/live/admin/gifts/${id}`)
export const setGiftStatus = (id, enabled) => patch(`/live/admin/gifts/${id}/status`, { enabled }) export const setGiftStatus = (id, enabled) => patch(`/live/admin/gifts/${id}/status`, { enabled })
export const saveGiftSort = (values) => put('/live/admin/gifts/sort', { values }) export const saveGiftSort = (values) => put('/live/admin/gifts/sort', { values })
export const sendGift = (roomId, data) => post(`/live/rooms/${roomId}/gifts`, data)
export const getGiftSends = (params) => get('/live/admin/sends', params) export const getGiftSends = (params) => get('/live/admin/sends', params)
export const getRechargePlans = () => get('/live/recharge/plans') export const getRechargePlans = () => get('/live/recharge/plans')
export const saveRechargePlans = (plans) => put('/live/admin/recharge/plans', { plans }) export const saveRechargePlans = (plans) => put('/live/admin/recharge/plans', { plans })
export const getOrders = (params) => get('/live/admin/orders', params) export const getOrders = (params) => get('/live/admin/orders', params)
export const getOrder = (orderNo) => get(`/live/admin/orders/${orderNo}`) export const getOrder = (orderNo) => get(`/live/admin/orders/${orderNo}`)
export const createRechargeOrder = (data) => post('/live/recharge/orders', data)
export const getAssetUsers = (params) => get('/live/admin/users', params) export const getAssetUsers = (params) => get('/live/admin/users', params)
export const getAssetUser = (id) => get(`/live/admin/users/${id}`) export const getAssetUser = (id) => get(`/live/admin/users/${id}`)
+24
View File
@@ -0,0 +1,24 @@
export async function copyLiveText(value, {
clipboard = navigator.clipboard,
documentRef = document,
} = {}) {
const text = String(value)
if (clipboard?.writeText) {
try { await clipboard.writeText(text); return }
catch { /* Older or restricted browsers can still support a selection copy. */ }
}
const previousFocus = documentRef.activeElement
const input = documentRef.createElement('textarea')
input.value = text
input.readOnly = true
input.style.position = 'fixed'
input.style.left = '-9999px'
documentRef.body.appendChild(input)
try {
input.select()
if (!documentRef.execCommand('copy')) throw new Error('Clipboard copy failed')
} finally {
input.remove()
previousFocus?.focus?.({ preventScroll: true })
}
}
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { copyLiveText } from './clipboard.js'
function legacyDocument(success) {
const input = { style: {}, select() { this.selected = true }, remove() { this.removed = true } }
return {
input,
activeElement: { focus() {} },
createElement: () => input,
body: { appendChild() {} },
execCommand: () => success,
}
}
test('copies an absolute link through the modern clipboard', async () => {
let copied
await copyLiveText('https://live.example.com/#/studio/2', {
clipboard: { async writeText(value) { copied = value } },
documentRef: {},
})
assert.equal(copied, 'https://live.example.com/#/studio/2')
})
test('uses a selection fallback when clipboard access is denied', async () => {
const documentRef = legacyDocument(true)
await copyLiveText('viewer-link', {
clipboard: { async writeText() { throw new Error('denied') } },
documentRef,
})
assert.equal(documentRef.input.value, 'viewer-link')
assert.equal(documentRef.input.selected, true)
assert.equal(documentRef.input.removed, true)
})
test('does not report success when both browser copy mechanisms fail', async () => {
const documentRef = legacyDocument(false)
await assert.rejects(copyLiveText('viewer-link', { clipboard: null, documentRef }), /copy failed/)
assert.equal(documentRef.input.removed, true)
})
+21
View File
@@ -0,0 +1,21 @@
export function getRoomLinks(roomId, {
origin = window.location.origin,
clientBaseUrl = import.meta.env?.VITE_LIVE_CLIENT_URL,
development = import.meta.env?.DEV,
} = {}) {
const id = Number(roomId)
if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Invalid live room ID')
const clientBase = String(clientBaseUrl || '').trim()
const url = new URL(clientBase || '/', origin)
if (!clientBase && development) url.port = '5173'
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('Invalid live client URL')
}
url.search = ''
if (!/\.html?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/+$/, '')}/`
url.hash = `/live/${id}`
const viewer = url.toString()
url.hash = `/studio/${id}`
return { viewer, studio: url.toString() }
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getRoomLinks } from './roomLinks.js'
test('production links target the PC routes, not the administration path', () => {
assert.deepEqual(getRoomLinks(2, { origin: 'https://live.example.com', development: false }), {
viewer: 'https://live.example.com/#/live/2',
studio: 'https://live.example.com/#/studio/2',
})
})
test('custom client origins and base paths discard unrelated query and hash state', () => {
assert.deepEqual(getRoomLinks('12', {
origin: 'https://admin.example.com',
clientBaseUrl: 'https://client.example.com/player?token=unused#/old',
}), {
viewer: 'https://client.example.com/player/#/live/12',
studio: 'https://client.example.com/player/#/studio/12',
})
})
test('development links use the PC dev server and support an explicit port override', () => {
assert.equal(getRoomLinks(1, { origin: 'http://localhost:8080', development: true }).viewer, 'http://localhost:5173/#/live/1')
assert.equal(getRoomLinks(1, {
origin: 'http://localhost:8080',
development: true,
clientBaseUrl: 'http://localhost:5174/',
}).studio, 'http://localhost:5174/#/studio/1')
})
test('client base supports a relative path and a static HTML entrypoint', () => {
assert.equal(getRoomLinks(3, {
origin: 'https://live.example.com',
clientBaseUrl: '/pc/index.html',
}).viewer, 'https://live.example.com/pc/index.html#/live/3')
})
test('invalid room IDs, executable schemes and URL credentials are rejected', () => {
for (const id of [0, -1, 1.2, '2/other', undefined]) {
assert.throws(() => getRoomLinks(id, { origin: 'https://live.example.com' }))
}
for (const clientBaseUrl of ['javascript:alert(1)', 'https://user:password@example.com/']) {
assert.throws(() => getRoomLinks(2, { origin: 'https://live.example.com', clientBaseUrl }))
}
})
+20 -7
View File
@@ -83,8 +83,10 @@ import { ArrowLeft, CircleCheck, CircleClose, Clock, CopyDocument, Delete, Docum
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/pinia/modules/user' import { useUserStore } from '@/pinia/modules/user'
import { getLiveStreams, getRoom } from '../api/live' import { getLiveStreams, getRoomDetail } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC' import { useLiveRTC } from '../composables/useLiveRTC'
import { getRoomLinks } from '../utils/roomLinks'
import { copyLiveText } from '../utils/clipboard'
defineOptions({ name: 'LiveDiagnostics' }) defineOptions({ name: 'LiveDiagnostics' })
@@ -106,9 +108,12 @@ const tests = reactive({
viewer: { status: 'idle', detail: '等待检测观众下行链路' }, viewer: { status: 'idle', detail: '等待检测观众下行链路' },
}) })
const base = () => `${window.location.origin}${window.location.pathname}#` const roomLinks = computed(() => {
const viewerURL = computed(() => `${base()}/live/room/${route.params.id}`) try { return getRoomLinks(route.params.id) }
const studioURL = computed(() => `${base()}/live/studio/${route.params.id}`) catch { return { viewer: '', studio: '' } }
})
const viewerURL = computed(() => roomLinks.value.viewer)
const studioURL = computed(() => roomLinks.value.studio)
const currentUserID = computed(() => Number(userStore.userInfo?.ID || 0)) const currentUserID = computed(() => Number(userStore.userInfo?.ID || 0))
const currentUserLabel = computed(() => userStore.userInfo?.nickName || userStore.userInfo?.userName || `用户 ${currentUserID.value || '-'}`) const currentUserLabel = computed(() => userStore.userInfo?.nickName || userStore.userInfo?.userName || `用户 ${currentUserID.value || '-'}`)
const canPublish = computed(() => Boolean(room.value?.enabled && currentUserID.value && Number(room.value?.anchorUserId) === currentUserID.value && !room.value?.streamUrl)) const canPublish = computed(() => Boolean(room.value?.enabled && currentUserID.value && Number(room.value?.anchorUserId) === currentUserID.value && !room.value?.streamUrl))
@@ -147,7 +152,7 @@ const sleep = (duration) => new Promise((resolve) => window.setTimeout(resolve,
const loadRoom = async () => { const loadRoom = async () => {
if (userStore.token && !currentUserID.value) await userStore.GetUserInfo() if (userStore.token && !currentUserID.value) await userStore.GetUserInfo()
const response = await getRoom(Number(route.params.id)) const response = await getRoomDetail(Number(route.params.id))
if (response.code !== 0 || !response.data) throw new Error(response.msg || '房间接口不可用') if (response.code !== 0 || !response.data) throw new Error(response.msg || '房间接口不可用')
room.value = response.data room.value = response.data
return response.data return response.data
@@ -158,6 +163,7 @@ const testLinks = async () => {
tests.link.detail = '正在请求房间接口和运行状态' tests.link.detail = '正在请求房间接口和运行状态'
try { try {
const item = await loadRoom() const item = await loadRoom()
if (!viewerURL.value || !studioURL.value) throw new Error('PC 站点地址配置无效,无法生成房间链接')
const streams = await getLiveStreams() const streams = await getLiveStreams()
runtime.value = (streams.data?.streams || []).find((stream) => stream.streamId === item.streamId) || null runtime.value = (streams.data?.streams || []).find((stream) => stream.streamId === item.streamId) || null
if (!item.enabled) throw new Error('房间接口正常,但直播间已停用') if (!item.enabled) throw new Error('房间接口正常,但直播间已停用')
@@ -280,8 +286,15 @@ const report = () => ({
publisher: { state: publisherRTC.state.value, diagnostics: { ...publisherRTC.diagnostics }, events: publisherRTC.events.value }, publisher: { state: publisherRTC.state.value, diagnostics: { ...publisherRTC.diagnostics }, events: publisherRTC.events.value },
viewer: { state: viewerRTC.state.value, diagnostics: { ...viewerRTC.diagnostics }, events: viewerRTC.events.value }, viewer: { state: viewerRTC.state.value, diagnostics: { ...viewerRTC.diagnostics }, events: viewerRTC.events.value },
}) })
const copyReport = async () => { await navigator.clipboard.writeText(JSON.stringify(report(), null, 2)); ElMessage.success('诊断报告已复制') } const copyReport = async () => {
const copy = async (value) => { await navigator.clipboard.writeText(value); ElMessage.success('链接已复制') } try { await copyLiveText(JSON.stringify(report(), null, 2)); ElMessage.success('诊断报告已复制') }
catch { ElMessage.warning('复制失败,请检查浏览器剪贴板权限') }
}
const copy = async (value) => {
if (!value) return ElMessage.error('无法生成链接,请检查 PC 站点地址配置')
try { await copyLiveText(value); ElMessage.success('链接已复制') }
catch { ElMessage.warning('复制失败,请选中地址复制') }
}
const open = (value) => window.open(value, '_blank', 'noopener') const open = (value) => window.open(value, '_blank', 'noopener')
const clearLogs = () => { publisherRTC.events.value = []; viewerRTC.events.value = [] } const clearLogs = () => { publisherRTC.events.value = []; viewerRTC.events.value = [] }
const stopConnections = () => { publisherRTC.disconnect(); viewerRTC.disconnect() } const stopConnections = () => { publisherRTC.disconnect(); viewerRTC.disconnect() }
-47
View File
@@ -1,47 +0,0 @@
<template>
<main class="live-client-page">
<div class="viewer-stage">
<video v-if="room?.streamUrl" ref="videoRef" :src="room.streamUrl" controls autoplay playsinline />
<video v-else-if="rtc.remoteStream.value" ref="videoRef" :srcObject="rtc.remoteStream.value" autoplay playsinline controls />
<video v-else-if="room?.replayUrl && room?.status !== 'live'" :src="getUrl(room.replayUrl)" controls autoplay playsinline />
<el-image v-else-if="room?.coverUrl" :src="getUrl(room.coverUrl)" fit="cover" class="viewer-cover" />
<div v-if="rtc.message.value" class="viewer-state"><el-icon><VideoPause /></el-icon><strong>{{ rtc.message.value }}</strong><el-button v-if="rtc.state.value === 'auth-required'" type="primary" @click="goLogin">去登录</el-button></div>
<header class="viewer-top"><button class="viewer-back" title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div class="viewer-room"><el-avatar :size="42" :src="getUrl(room?.anchorAvatar)">{{ room?.anchorName?.slice(0, 1) }}</el-avatar><div><strong>{{ room?.name || '直播间' }}</strong><span>{{ room?.anchorName || '主播' }} · {{ room?.viewerCount || 0 }} 人观看</span></div></div></header>
</div>
<aside class="viewer-sidebar"><div class="viewer-info"><h1>{{ room?.name }}</h1><p>{{ room?.intro || '主播暂未填写直播简介。' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div><div class="gift-panel"><div class="gift-heading"><strong>送礼物</strong><span>余额 {{ number(balance) }} 钻石</span></div><div class="gift-grid"><button v-for="gift in gifts" :key="gift.id" :class="{ active: selectedGift?.id === gift.id }" @click="selectedGift = gift"><span v-if="!isImage(gift.icon)">{{ gift.icon }}</span><img v-else :src="getUrl(gift.icon)"><strong>{{ gift.name }}</strong><small>{{ gift.price }} 钻石</small></button></div><div class="gift-send"><el-select v-model="quantity"><el-option v-for="tier in selectedGift?.tiers || [1]" :key="tier" :label="`x${tier}`" :value="tier" /></el-select><el-button type="primary" :disabled="!selectedGift" @click="submitGift">赠送</el-button></div></div><section class="chat-panel"><div class="chat-heading"><strong>聊天室</strong><span>{{ rtc.chatMessages.value.length }} 条消息</span></div><div class="chat-list"><div v-for="item in rtc.chatMessages.value" :key="`${item.id}-${item.createdAt}`" class="chat-item"><el-avatar :size="24" :src="getUrl(item.avatar)">{{ (item.nickname || item.username || '用').slice(0, 1) }}</el-avatar><div><strong>{{ item.nickname || item.username || `用户 ${item.userId}` }}</strong><small>{{ formatChatTime(item.createdAt) }}</small><p v-if="item.type === 'gift'">送出 {{ item.giftIcon }} {{ item.giftName }} x{{ item.quantity }}</p><p v-else>{{ item.content }}</p></div></div><el-empty v-if="!rtc.chatMessages.value.length" description="暂无聊天消息" :image-size="45" /></div><div class="chat-compose"><el-input v-model="chatDraft" maxlength="100" show-word-limit placeholder="说点什么" @keyup.enter="sendChatMessage" /><el-button type="primary" :disabled="!chatDraft.trim()" @click="sendChatMessage">发送</el-button></div></section></aside>
</main>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from 'vue'
import { ArrowLeft, VideoPause } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getUrl } from '@/utils/image'
import { useUserStore } from '@/pinia/modules/user'
import { getAssetUser, getChatHistory, getPublicGifts, getRoom, sendGift } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC'
defineOptions({ name: 'LiveViewer' })
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), gifts = ref([]), selectedGift = ref(null), quantity = ref(1), balance = ref(0), chatDraft = ref(''), videoRef = ref()
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), isImage = (v) => /^(https?:|\/|data:)/.test(String(v || ''))
watch(() => rtc.remoteStream.value, async (stream) => { await nextTick(); if (videoRef.value && stream) { videoRef.value.srcObject = stream; videoRef.value.play().catch(() => {}) } })
const load = async () => { const id = Number(route.params.id); if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo(); const [roomRes, giftRes, chatRes] = await Promise.all([getRoom(id), getPublicGifts(), getChatHistory(id, { limit: 100 })]); if (roomRes.code === 0) room.value = roomRes.data; if (giftRes.code === 0) { gifts.value = giftRes.data.list || []; selectedGift.value = gifts.value[0]; quantity.value = selectedGift.value?.tiers?.[0] || 1 } if (chatRes.code === 0) rtc.chatMessages.value = chatRes.data.list || []; if (userStore.token && userStore.userInfo?.ID) { const asset = await getAssetUser(userStore.userInfo.ID); if (asset.code === 0) balance.value = asset.data.diamondBalance } if (room.value && !room.value.streamUrl && room.value.status === 'live') rtc.connect({ mode: 'subscriber', id: room.value.streamId }).catch(() => {}) }
const submitGift = async () => { if (!selectedGift.value) return; const total = selectedGift.value.price * quantity.value; if (total > balance.value) return ElMessage.warning('钻石余额不足'); const res = await sendGift(room.value.id, { giftId: selectedGift.value.id, quantity: quantity.value }); if (res.code === 0) { balance.value -= total; ElMessage.success(`已送出 ${quantity.value} 个${selectedGift.value.name}`) } }
const sendChatMessage = () => { if (!userStore.token) return goLogin(); if (!rtc.sendChat(chatDraft.value)) return ElMessage.warning(room.value?.status === 'live' ? '正在连接聊天室,请稍后再试' : '直播未开始,暂时不能发言'); chatDraft.value = '' }
const formatChatTime = (value) => value ? new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''
const goBack = () => window.history.length > 1 ? router.back() : router.push('/'), goLogin = () => router.push({ name: 'Login', query: { redirect: route.fullPath } })
onMounted(load)
</script>
<style scoped>
.live-client-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #101014; color: #fff; }.viewer-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; background: #08080b; }.viewer-stage video,.viewer-cover { width: 100%; height: 100vh; object-fit: contain; }.viewer-cover { opacity: .42; filter: blur(2px); }.viewer-top { position: absolute; z-index: 2; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 14px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 65%), transparent); }.viewer-back { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 12%); color: #fff; cursor: pointer; }.viewer-room { display: flex; align-items: center; gap: 10px; }.viewer-room div { display: grid; gap: 4px; }.viewer-room strong { font-size: 15px; }.viewer-room span { color: rgb(255 255 255 / 70%); font-size: 11px; }.viewer-state { position: absolute; z-index: 2; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; background: rgb(0 0 0 / 26%); }.viewer-state .el-icon { font-size: 42px; color: rgb(255 255 255 / 60%); }.viewer-sidebar { min-height: 100vh; background: #fff; color: #2a2025; }.viewer-info { padding: 25px 22px; border-bottom: 1px solid #f0e4e8; }.viewer-info h1 { margin: 0; font-size: 20px; letter-spacing: 0; }.viewer-info p { min-height: 42px; margin: 10px 0; color: #7a6d72; font-size: 13px; line-height: 1.6; }.gift-panel { padding: 20px 18px; }.gift-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }.gift-heading span { color: #a37e8c; font-size: 12px; }.gift-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }.gift-grid button { display: grid; min-width: 0; place-items: center; gap: 4px; padding: 10px 5px; border: 1px solid #f0e0e6; border-radius: 6px; background: #fff; color: inherit; cursor: pointer; }.gift-grid button.active { border-color: #e95f91; background: #fff4f8; }.gift-grid button > span,.gift-grid button > img { width: 36px; height: 36px; font-size: 28px; object-fit: cover; }.gift-grid strong { overflow: hidden; max-width: 100%; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.gift-grid small { color: #d54c7e; font-size: 10px; }.gift-send { display: grid; grid-template-columns: 1fr 110px; gap: 8px; margin-top: 14px; }
.chat-panel { display: grid; grid-template-rows: auto minmax(150px, 1fr) auto; min-height: 300px; padding: 18px; border-top: 1px solid #f0e4e8; }
.chat-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.chat-heading span { color: #a37e8c; font-size: 11px; }
.chat-list { min-height: 0; max-height: 340px; overflow: auto; }
.chat-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 0; }
.chat-item > div { min-width: 0; }
.chat-item strong { font-size: 11px; }
.chat-item small { margin-left: 6px; color: #b2a2a8; font-size: 10px; }
.chat-item p { margin: 3px 0 0; color: #615158; font-size: 12px; line-height: 1.45; word-break: break-word; }
.chat-compose { display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 8px; margin-top: 10px; }
@media (max-width: 850px) { .live-client-page { display: block; }.viewer-stage,.viewer-stage video,.viewer-cover { min-height: 56vh; height: 56vh; }.viewer-sidebar { min-height: 44vh; }.viewer-info { padding: 17px; }.gift-panel { padding: 16px; } }
</style>
+58 -7
View File
@@ -25,7 +25,7 @@
<el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusText(row.status) }}</el-tag></template></el-table-column> <el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusText(row.status) }}</el-tag></template></el-table-column>
<el-table-column v-if="!compactTable" label="观看人数" min-width="82" prop="viewerCount" align="right" /> <el-table-column v-if="!compactTable" label="观看人数" min-width="82" prop="viewerCount" align="right" />
<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.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="share" :icon="Share">复制分享链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column> <el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="studio" :icon="CopyDocument">复制主播链接</el-dropdown-item><el-dropdown-item command="viewer" :icon="Share">复制观众链接</el-dropdown-item><el-dropdown-item command="links" :icon="Connection">查看链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
</el-table> </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 class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div>
</div> </div>
@@ -46,18 +46,30 @@
<el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item> <el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item>
</el-form> </el-form>
</el-drawer> </el-drawer>
<el-dialog v-model="linksDialog" title="直播间链接" width="min(640px, calc(100vw - 32px))">
<p class="room-links-name">{{ linkedRoom?.name }} <span>#{{ linkedRoom?.id }}</span></p>
<div v-for="item in linkItems" :key="item.key" class="room-link-row">
<label :for="`room-link-${item.key}`">{{ item.label }}</label>
<el-input :id="`room-link-${item.key}`" :model-value="roomLinks[item.key]" readonly @focus="$event.target.select()" />
<el-tooltip :content="`复制${item.label}`"><el-button :icon="CopyDocument" :aria-label="`复制${item.label}`" @click="copyLink(linkedRoom, item.key)" /></el-tooltip>
<el-tooltip :content="`打开${item.label}`"><el-button :icon="TopRight" :aria-label="`打开${item.label}`" @click="openLink(roomLinks[item.key])" /></el-tooltip>
</div>
<template #footer><el-button @click="linksDialog = false">关闭</el-button></template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, VideoCamera } from '@element-plus/icons-vue' import { Connection, CopyDocument, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, TopRight, VideoCamera } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getUrl } from '@/utils/image' import { getUrl } from '@/utils/image'
import ImageUploader from '../components/ImageUploader.vue' import ImageUploader from '../components/ImageUploader.vue'
import VideoUploader from '../components/VideoUploader.vue' import VideoUploader from '../components/VideoUploader.vue'
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live' import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
import { getRoomLinks } from '../utils/roomLinks'
import { copyLiveText } from '../utils/clipboard'
defineOptions({ name: 'LiveRooms' }) defineOptions({ name: 'LiveRooms' })
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }) const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' })
@@ -67,6 +79,8 @@ const compactTable = computed(() => width.value < 1180)
const page = ref(1), pageSize = ref(10), total = ref(0), editingId = ref(0), editingStatus = ref('') const page = ref(1), pageSize = ref(10), total = ref(0), editingId = ref(0), editingStatus = ref('')
const blank = () => ({ name: '', categoryId: undefined, coverUrl: '', intro: '', sort: 0, anchorUserId: undefined, streamUrl: '', replayUrl: '', enabled: true }) const blank = () => ({ name: '', categoryId: undefined, coverUrl: '', intro: '', sort: 0, anchorUserId: undefined, streamUrl: '', replayUrl: '', enabled: true })
const form = reactive(blank()) const form = reactive(blank())
const linksDialog = ref(false), linkedRoom = ref(null), roomLinks = ref({ viewer: '', studio: '' })
const linkItems = [{ key: 'studio', label: '主播链接' }, { key: 'viewer', label: '观众链接' }]
const rules = { name: [{ required: true, message: '请输入直播间名称', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }], anchorUserId: [{ required: true, message: '请选择主播', trigger: 'change' }], coverUrl: [{ required: true, message: '请上传直播封面', trigger: 'change' }] } const rules = { name: [{ required: true, message: '请输入直播间名称', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }], anchorUserId: [{ required: true, message: '请选择主播', trigger: 'change' }], coverUrl: [{ required: true, message: '请上传直播封面', trigger: 'change' }] }
const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已禁用' }[value] || value) const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已禁用' }[value] || value)
const statusType = (value) => ({ live: 'success', offline: 'info', disabled: 'danger' }[value] || 'info') const statusType = (value) => ({ live: 'success', offline: 'info', disabled: 'danger' }[value] || 'info')
@@ -77,14 +91,48 @@ const search = () => { page.value = 1; load() }
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() } const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() }
const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true } const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true }
const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true } const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true }
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form); if (res.code === 0) { ElMessage.success('直播间已保存'); drawer.value = false; load() } } finally { saving.value = false } } const submit = async () => {
await formRef.value?.validate()
saving.value = true
try {
const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form)
if (res.code === 0) {
ElMessage.success('直播间已保存')
drawer.value = false
showLinks(res.data || { ...form, id: editingId.value })
load()
}
} finally { saving.value = false }
}
const removeRow = async (row) => { await ElMessageBox.confirm(`确认删除直播间“${row.name}”吗?`, '删除直播间', { type: 'warning' }); const res = await deleteRoom(row.id); if (res.code === 0) { ElMessage.success('已删除'); load() } } const removeRow = async (row) => { await ElMessageBox.confirm(`确认删除直播间“${row.name}”吗?`, '删除直播间', { type: 'warning' }); const res = await deleteRoom(row.id); if (res.code === 0) { ElMessage.success('已删除'); load() } }
const persistSort = async () => { const values = Object.fromEntries(rows.value.map((item) => [item.id, item.sort])); const res = await saveRoomSort(values); if (res.code === 0) { ElMessage.success('排序已保存'); load() } } const persistSort = async () => { const values = Object.fromEntries(rows.value.map((item) => [item.id, item.sort])); const res = await saveRoomSort(values); if (res.code === 0) { ElMessage.success('排序已保存'); load() } }
const shareUrl = (row, studio = false) => `${window.location.origin}${window.location.pathname}#/live/${studio ? 'studio' : 'room'}/${row.id}` const showLinks = (row) => {
const copyLink = async (row) => { await navigator.clipboard.writeText(shareUrl(row)); ElMessage.success('观看链接已复制') } try {
const openStudio = (row) => window.open(shareUrl(row, true), '_blank') roomLinks.value = getRoomLinks(row.id)
linkedRoom.value = row
linksDialog.value = true
} catch { ElMessage.error('无法生成直播间链接,请检查 PC 站点地址配置') }
}
const copyLink = async (row, kind) => {
try {
await copyLiveText(getRoomLinks(row.id)[kind])
ElMessage.success(kind === 'studio' ? '主播链接已复制' : '观众链接已复制')
} catch {
showLinks(row)
ElMessage.warning('复制失败,请在链接窗口中选中地址复制')
}
}
const openLink = (url) => window.open(url, '_blank', 'noopener,noreferrer')
const openStudio = (row) => {
try { openLink(getRoomLinks(row.id).studio) }
catch { ElMessage.error('无法生成主播链接,请检查 PC 站点地址配置') }
}
const openDiagnostics = (row) => window.open(`${window.location.origin}${window.location.pathname}#/live/diagnostics/${row.id}`, '_blank', 'noopener') const openDiagnostics = (row) => window.open(`${window.location.origin}${window.location.pathname}#/live/diagnostics/${row.id}`, '_blank', 'noopener')
const handleRoomAction = (command, row) => command === 'share' ? copyLink(row) : removeRow(row) const handleRoomAction = (command, row) => {
if (command === 'studio' || command === 'viewer') return copyLink(row, command)
if (command === 'links') return showLinks(row)
if (command === 'delete') return removeRow(row)
}
onMounted(() => { loadOptions(); load() }) onMounted(() => { loadOptions(); load() })
</script> </script>
@@ -94,4 +142,7 @@ onMounted(() => { loadOptions(); load() })
.anchor-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }.anchor-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .anchor-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }.anchor-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-slot { display: grid; width: 100%; height: 100%; place-items: center; color: var(--el-text-color-placeholder); font-size: 11px; } .image-slot { display: grid; width: 100%; height: 100%; place-items: center; color: var(--el-text-color-placeholder); font-size: 11px; }
.room-delete-action { color: var(--el-color-danger); } .room-delete-action { color: var(--el-color-danger); }
.room-links-name { margin: 0 0 18px; overflow-wrap: anywhere; }.room-links-name span { margin-left: 6px; color: var(--el-text-color-secondary); }
.room-link-row { display: grid; grid-template-columns: 64px minmax(0, 1fr) 32px 32px; gap: 8px; align-items: center; margin: 12px 0; }.room-link-row .el-button { width: 32px; margin: 0; }.room-link-row label { font-size: 12px; }
@media (max-width: 480px) { .room-link-row { grid-template-columns: minmax(0, 1fr) 32px 32px; }.room-link-row label { grid-column: 1 / -1; } }
</style> </style>
+2 -2
View File
@@ -20,7 +20,7 @@
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue' import { Refresh, Search } from '@element-plus/icons-vue'
import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live' import { getAssetUser, getGiftSends, getGifts, getRoomDetail } from '../api/live'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
defineOptions({ name: 'LiveSends' }) defineOptions({ name: 'LiveSends' })
const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined, 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 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 })
@@ -31,7 +31,7 @@ const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize:
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }); dateRange.value = []; search() } const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }); dateRange.value = []; search() }
onMounted(() => { loadGifts(); load() }) onMounted(() => { loadGifts(); load() })
const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } } const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } }
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoom(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') } const openRoom = async (id) => { linkedUser.value = null; const res = await getRoomDetail(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
</script> </script>
<style lang="scss"> <style lang="scss">
@use '../styles/live.scss'; @use '../styles/live.scss';
+68 -10
View File
@@ -5,33 +5,91 @@
<div v-if="!media" class="studio-placeholder"><el-icon><VideoCamera /></el-icon><strong>摄像头预览将在这里显示</strong></div> <div v-if="!media" class="studio-placeholder"><el-icon><VideoCamera /></el-icon><strong>摄像头预览将在这里显示</strong></div>
<div class="studio-status"><span :class="`is-${rtc.state.value}`" />{{ statusText }}</div> <div class="studio-status"><span :class="`is-${rtc.state.value}`" />{{ statusText }}</div>
<header><button title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div><strong>{{ room?.name || '主播开播台' }}</strong><span>{{ room?.streamId }}</span></div></header> <header><button title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div><strong>{{ room?.name || '主播开播台' }}</strong><span>{{ room?.streamId }}</span></div></header>
<footer><button :class="{ off: !audioEnabled }" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live" class="go-live" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">结束直播</button></footer> <footer><button :class="{ off: !audioEnabled }" :disabled="!media" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" :disabled="!media" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live && !starting" class="go-live" :disabled="loading || Boolean(permissionError) || !media" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">{{ live ? '结束直播' : '取消连接' }}</button></footer>
</section> </section>
<aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ live ? '直播中' : '未开播' }}</strong></div></div></section></aside> <aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form><el-button v-if="mediaError" :disabled="!canPublish" :loading="mediaLoading" @click="refreshMedia">重试设备</el-button></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ statusText }}</strong></div></div></section></aside>
</main> </main>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { ArrowLeft, Microphone, Mute, VideoCamera, VideoPause } from '@element-plus/icons-vue' import { ArrowLeft, Microphone, Mute, VideoCamera, VideoPause } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/pinia/modules/user' import { useUserStore } from '@/pinia/modules/user'
import { getUrl } from '@/utils/image' import { getUrl } from '@/utils/image'
import { getRoom } from '../api/live' import { getRoomDetail } from '../api/live'
import { useLiveRTC } from '../composables/useLiveRTC' import { useLiveRTC } from '../composables/useLiveRTC'
defineOptions({ name: 'LiveStudio' }) defineOptions({ name: 'LiveStudio' })
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = ref(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false), permissionError = ref('') const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = shallowRef(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false)
const loading = ref(true), starting = ref(false), mediaLoading = ref(false), roomError = ref(''), mediaError = ref('')
let disposed = false
const canPublish = computed(() => Boolean(!loading.value && !roomError.value && room.value?.enabled && !room.value.streamUrl && userStore.userInfo?.ID && Number(room.value.anchorUserId) === Number(userStore.userInfo.ID)))
const permissionError = computed(() => {
if (roomError.value) return roomError.value
if (loading.value) return ''
if (!room.value?.enabled) return '该直播间已停用,不能开播。'
if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) return '当前账号不是该直播间绑定的主播。'
if (room.value.streamUrl) return '该直播间使用外部直播流,请通过已配置的推流端开播。'
return mediaError.value
})
const statusText = computed(() => ({ idle: '准备开播', connecting: '连接中', negotiating: '建立推流', connected: '直播中', error: rtc.message.value, closed: '连接已关闭' }[rtc.state.value] || rtc.message.value || '准备开播')) const statusText = computed(() => ({ idle: '准备开播', connecting: '连接中', negotiating: '建立推流', connected: '直播中', error: rtc.message.value, closed: '连接已关闭' }[rtc.state.value] || rtc.message.value || '准备开播'))
const enumerate = async () => { const devices = await navigator.mediaDevices.enumerateDevices(); cameras.value = devices.filter((item) => item.kind === 'videoinput'); microphones.value = devices.filter((item) => item.kind === 'audioinput'); if (!cameraId.value) cameraId.value = cameras.value[0]?.deviceId || ''; if (!microphoneId.value) microphoneId.value = microphones.value[0]?.deviceId || '' } const enumerate = async () => { const devices = await navigator.mediaDevices.enumerateDevices(); cameras.value = devices.filter((item) => item.kind === 'videoinput'); microphones.value = devices.filter((item) => item.kind === 'audioinput'); if (!cameraId.value) cameraId.value = cameras.value[0]?.deviceId || ''; if (!microphoneId.value) microphoneId.value = microphones.value[0]?.deviceId || '' }
const refreshMedia = async () => { if (live.value) return ElMessage.warning('直播中不能切换设备'); media.value?.getTracks().forEach((track) => track.stop()); try { media.value = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true }); await nextTick(); if (preview.value) preview.value.srcObject = media.value; await enumerate() } catch (error) { permissionError.value = '无法使用摄像头或麦克风,请检查浏览器权限和设备占用情况。'; throw error } } const refreshMedia = async () => {
const startLive = async () => { if (permissionError.value || !media.value) return ElMessage.warning('请先允许使用摄像头和麦克风'); if (Number(room.value?.anchorUserId) !== Number(userStore.userInfo?.ID)) return ElMessage.error('只有该直播间绑定的主播可以开播'); try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }); live.value = true; ElMessage.success('正在开始直播') } catch { live.value = false } } if (!canPublish.value || live.value || starting.value || mediaLoading.value) return
const stopLive = async () => { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }); rtc.disconnect(); live.value = false; ElMessage.success('直播已结束') } mediaError.value = ''
media.value?.getTracks().forEach((track) => track.stop())
media.value = null
mediaLoading.value = true
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('media unavailable')
const stream = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true })
if (disposed || !canPublish.value) { stream.getTracks().forEach((track) => track.stop()); return }
media.value = stream
stream.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value })
stream.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value })
await nextTick()
if (preview.value) preview.value.srcObject = media.value
await enumerate()
} catch {
mediaError.value = '无法使用摄像头或麦克风,请检查 HTTPS、浏览器权限和设备占用情况。'
} finally { mediaLoading.value = false }
}
const startLive = async () => {
if (!canPublish.value || permissionError.value || !media.value || starting.value) return
starting.value = true
try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }) }
catch { starting.value = false; live.value = false }
}
const stopLive = async () => {
try { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }) }
catch { return }
rtc.disconnect()
starting.value = false
live.value = false
ElMessage.success('直播已结束')
}
const toggleAudio = () => { audioEnabled.value = !audioEnabled.value; media.value?.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value }) }, toggleVideo = () => { videoEnabled.value = !videoEnabled.value; media.value?.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value }) } const toggleAudio = () => { audioEnabled.value = !audioEnabled.value; media.value?.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value }) }, toggleVideo = () => { videoEnabled.value = !videoEnabled.value; media.value?.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value }) }
const goBack = () => router.back() const goBack = () => router.back()
onMounted(async () => { const res = await getRoom(Number(route.params.id)); if (res.code !== 0) return; room.value = res.data; if (!room.value.enabled) permissionError.value = '该直播间已停用,不能开播。'; else if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) permissionError.value = '当前账号不是该直播间绑定的主播。'; await refreshMedia().catch(() => {}) }) watch(() => rtc.state.value, (state) => {
onBeforeUnmount(() => media.value?.getTracks().forEach((track) => track.stop())) if (state === 'connected') { live.value = true; starting.value = false }
else if (['idle', 'error', 'closed', 'kicked', 'auth-required'].includes(state)) { live.value = false; starting.value = false }
})
onMounted(async () => {
try {
if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo()
if (!userStore.userInfo?.ID) { roomError.value = '账号信息加载失败,请重新登录。'; return }
const res = await getRoomDetail(Number(route.params.id))
if (disposed) return
if (res.code !== 0 || !res.data) { roomError.value = res.msg || '直播间信息加载失败。'; return }
room.value = res.data
} catch { roomError.value = '直播间信息加载失败,请稍后重试。' }
finally { loading.value = false }
if (!disposed && canPublish.value) await refreshMedia()
})
onBeforeUnmount(() => { disposed = true; rtc.disconnect(); media.value?.getTracks().forEach((track) => track.stop()) })
</script> </script>
<style scoped> <style scoped>
.studio-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #111116; color: #fff; }.studio-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; }.studio-stage video { width: 100%; height: 100vh; background: #09090c; object-fit: contain; }.studio-stage header { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 12px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 70%), transparent); }.studio-stage header button,.studio-stage footer button { display: grid; width: 42px; height: 42px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 15%); color: #fff; cursor: pointer; }.studio-stage header div { display: grid; gap: 3px; }.studio-stage header span { color: rgb(255 255 255 / 60%); font-size: 11px; }.studio-placeholder { position: absolute; inset: 0; display: grid; place-content: center; gap: 12px; color: rgb(255 255 255 / 55%); text-align: center; }.studio-placeholder .el-icon { margin: auto; font-size: 50px; }.studio-status { position: absolute; top: 22px; right: 22px; display: flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 4px; background: rgb(0 0 0 / 50%); font-size: 12px; }.studio-status span { width: 8px; height: 8px; border-radius: 50%; background: #999; }.studio-status .is-connected { background: #ff417f; box-shadow: 0 0 0 4px rgb(255 65 127 / 20%); }.studio-stage footer { position: absolute; bottom: 25px; left: 50%; display: flex; align-items: center; gap: 12px; transform: translateX(-50%); }.studio-stage footer button.off { background: #a83842; }.studio-stage footer .go-live,.studio-stage footer .end-live { width: auto; min-width: 130px; padding: 0 24px; border-radius: 22px; background: #ec5b8d; font-weight: 600; }.studio-stage footer .end-live { background: #d94a53; }.studio-panel { min-height: 100vh; padding: 22px; background: #fff; color: #2a2025; }.studio-room { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 14px; padding-bottom: 20px; border-bottom: 1px solid #f0e4e8; }.studio-room .el-image { width: 100px; height: 72px; border-radius: 5px; }.studio-room h1 { margin: 0; font-size: 17px; letter-spacing: 0; }.studio-room p { display: -webkit-box; overflow: hidden; margin: 7px 0; color: #807176; font-size: 12px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.studio-panel section { padding: 20px 0; border-bottom: 1px solid #f0e4e8; }.studio-panel h2 { margin: 0 0 14px; font-size: 14px; }.studio-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.studio-metrics div { padding: 14px; background: #fff4f8; }.studio-metrics span { display: block; color: #9a7d87; font-size: 11px; }.studio-metrics strong { display: block; margin-top: 5px; color: #d74679; font-size: 19px; } .studio-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #111116; color: #fff; }.studio-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; }.studio-stage video { width: 100%; height: 100vh; background: #09090c; object-fit: contain; }.studio-stage header { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 12px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 70%), transparent); }.studio-stage header button,.studio-stage footer button { display: grid; width: 42px; height: 42px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 15%); color: #fff; cursor: pointer; }.studio-stage header div { display: grid; gap: 3px; }.studio-stage header span { color: rgb(255 255 255 / 60%); font-size: 11px; }.studio-placeholder { position: absolute; inset: 0; display: grid; place-content: center; gap: 12px; color: rgb(255 255 255 / 55%); text-align: center; }.studio-placeholder .el-icon { margin: auto; font-size: 50px; }.studio-status { position: absolute; top: 22px; right: 22px; display: flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 4px; background: rgb(0 0 0 / 50%); font-size: 12px; }.studio-status span { width: 8px; height: 8px; border-radius: 50%; background: #999; }.studio-status .is-connected { background: #ff417f; box-shadow: 0 0 0 4px rgb(255 65 127 / 20%); }.studio-stage footer { position: absolute; bottom: 25px; left: 50%; display: flex; align-items: center; gap: 12px; transform: translateX(-50%); }.studio-stage footer button.off { background: #a83842; }.studio-stage footer .go-live,.studio-stage footer .end-live { width: auto; min-width: 130px; padding: 0 24px; border-radius: 22px; background: #ec5b8d; font-weight: 600; }.studio-stage footer .end-live { background: #d94a53; }.studio-panel { min-height: 100vh; padding: 22px; background: #fff; color: #2a2025; }.studio-room { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 14px; padding-bottom: 20px; border-bottom: 1px solid #f0e4e8; }.studio-room .el-image { width: 100px; height: 72px; border-radius: 5px; }.studio-room h1 { margin: 0; font-size: 17px; letter-spacing: 0; }.studio-room p { display: -webkit-box; overflow: hidden; margin: 7px 0; color: #807176; font-size: 12px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.studio-panel section { padding: 20px 0; border-bottom: 1px solid #f0e4e8; }.studio-panel h2 { margin: 0 0 14px; font-size: 14px; }.studio-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.studio-metrics div { padding: 14px; background: #fff4f8; }.studio-metrics span { display: block; color: #9a7d87; font-size: 11px; }.studio-metrics strong { display: block; margin-top: 5px; color: #d74679; font-size: 19px; }
@media (max-width: 850px) { .studio-page { display: block; }.studio-stage,.studio-stage video { min-height: 62vh; height: 62vh; }.studio-panel { min-height: 38vh; }.studio-stage footer { bottom: 18px; } } @media (max-width: 850px) { .studio-page { display: block; }.studio-stage,.studio-stage video { min-height: 62vh; height: 62vh; }.studio-panel { min-height: 38vh; }.studio-stage footer { bottom: 18px; } }
.studio-stage footer button:disabled { opacity: .45; cursor: not-allowed; }
</style> </style>
-1
View File
@@ -73,7 +73,6 @@
"/src/modules/live/view/diagnostics.vue": "LiveDiagnostics", "/src/modules/live/view/diagnostics.vue": "LiveDiagnostics",
"/src/modules/live/view/gifts.vue": "LiveGifts", "/src/modules/live/view/gifts.vue": "LiveGifts",
"/src/modules/live/view/orders.vue": "LiveOrders", "/src/modules/live/view/orders.vue": "LiveOrders",
"/src/modules/live/view/room.vue": "LiveViewer",
"/src/modules/live/view/rooms.vue": "LiveRooms", "/src/modules/live/view/rooms.vue": "LiveRooms",
"/src/modules/live/view/sends.vue": "LiveSends", "/src/modules/live/view/sends.vue": "LiveSends",
"/src/modules/live/view/settings.vue": "LiveSettings", "/src/modules/live/view/settings.vue": "LiveSettings",
-6
View File
@@ -30,12 +30,6 @@ const routes = [
component: () => import('@/view/system/security/forceChangePassword.vue'), component: () => import('@/view/system/security/forceChangePassword.vue'),
meta: { title: '修改密码' } meta: { title: '修改密码' }
}, },
{
path: '/live/room/:id',
name: 'LiveViewer',
component: () => import('@/modules/live/view/room.vue'),
meta: { title: '直播间', client: true }
},
{ {
path: '/live/studio/:id', path: '/live/studio/:id',
name: 'LiveStudio', name: 'LiveStudio',