From ceaea78b0f395f74bb3de4013eb6ba135984d3d1 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Tue, 22 Sep 2026 20:20:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../page-2026-09-22T11-23-43-257Z.yml | 42 +++ configs/config.yaml | 2 +- docs/LIVE.md | 39 ++- internal/biz/live/catalog.go | 38 ++- internal/biz/live/catalog_runtime_test.go | 142 ++++++++- internal/data/gift/gift.go | 13 +- internal/data/gift/gift_test.go | 182 +++++++++++ internal/data/ledger/models.go | 2 +- internal/data/live/catalog.go | 8 +- internal/data/live/catalog_test.go | 160 +++++++++- internal/data/recharge/recharge.go | 14 +- internal/data/recharge/recharge_test.go | 173 ++++++++++ internal/integration/live/server.go | 10 +- internal/integration/live/server_test.go | 297 +++++++++++++++++- internal/server/handler/live.go | 2 +- internal/service/live/catalog.go | 15 +- internal/service/live/catalog_test.go | 97 ++++++ internal/utils/serial/serial.go | 16 + internal/utils/serial/serial_test.go | 96 ++++++ tmp/live_acceptance/database.go | 21 ++ tmp/live_acceptance/main.go | 191 +++++++++++ tmp/live_acceptance/payment_mock.go | 232 ++++++++++++++ tmp/live_acceptance/setup.go | 147 +++++++++ tmp/live_acceptance/verify.go | 109 +++++++ tmp/live_acceptance/verify_account.go | 81 +++++ tmp/live_acceptance/verify_catalog.go | 68 ++++ tmp/live_acceptance/verify_live.go | 172 ++++++++++ tmp/live_acceptance/verify_recharge.go | 78 +++++ tmp/live_acceptance/websocket.go | 154 +++++++++ web/src/modules/live/README.md | 33 ++ web/src/modules/live/api/live.js | 7 +- web/src/modules/live/utils/clipboard.js | 24 ++ web/src/modules/live/utils/clipboard.test.js | 40 +++ web/src/modules/live/utils/roomLinks.js | 21 ++ web/src/modules/live/utils/roomLinks.test.js | 45 +++ web/src/modules/live/view/diagnostics.vue | 27 +- web/src/modules/live/view/room.vue | 47 --- web/src/modules/live/view/rooms.vue | 65 +++- web/src/modules/live/view/sends.vue | 4 +- web/src/modules/live/view/studio.vue | 78 ++++- web/src/pathInfo.json | 1 - web/src/router/index.js | 6 - 42 files changed, 2845 insertions(+), 154 deletions(-) create mode 100644 .playwright-mcp/page-2026-09-22T11-23-43-257Z.yml create mode 100644 internal/service/live/catalog_test.go create mode 100644 internal/utils/serial/serial.go create mode 100644 internal/utils/serial/serial_test.go create mode 100644 tmp/live_acceptance/database.go create mode 100644 tmp/live_acceptance/main.go create mode 100644 tmp/live_acceptance/payment_mock.go create mode 100644 tmp/live_acceptance/setup.go create mode 100644 tmp/live_acceptance/verify.go create mode 100644 tmp/live_acceptance/verify_account.go create mode 100644 tmp/live_acceptance/verify_catalog.go create mode 100644 tmp/live_acceptance/verify_live.go create mode 100644 tmp/live_acceptance/verify_recharge.go create mode 100644 tmp/live_acceptance/websocket.go create mode 100644 web/src/modules/live/README.md create mode 100644 web/src/modules/live/utils/clipboard.js create mode 100644 web/src/modules/live/utils/clipboard.test.js create mode 100644 web/src/modules/live/utils/roomLinks.js create mode 100644 web/src/modules/live/utils/roomLinks.test.js delete mode 100644 web/src/modules/live/view/room.vue diff --git a/.playwright-mcp/page-2026-09-22T11-23-43-257Z.yml b/.playwright-mcp/page-2026-09-22T11-23-43-257Z.yml new file mode 100644 index 0000000..fc4b5ef --- /dev/null +++ b/.playwright-mcp/page-2026-09-22T11-23-43-257Z.yml @@ -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:                             \ No newline at end of file diff --git a/configs/config.yaml b/configs/config.yaml index 7d48190..b9cfdee 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -134,7 +134,7 @@ admin: - http - sql cors: - mode: whitelist + mode: allow-all whitelist: [] # Example: # whitelist: diff --git a/docs/LIVE.md b/docs/LIVE.md index 6f8998a..0843969 100644 --- a/docs/LIVE.md +++ b/docs/LIVE.md @@ -4,7 +4,7 @@ ## WebSocket -连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力: +连接现有 WebSocket 地址(后端默认 `/ws`,生产反向代理地址为 `/api/ws`)。观看直播允许匿名握手;PC 登录后的 HttpOnly Cookie 会随同源握手自动发送,分享链接中不携带凭证。非浏览器客户端也可携带 GVA JWT: ```text wss://host/ws?token= @@ -16,7 +16,7 @@ wss://host/ws?token= {"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 {"type":"Offer","payload":{"type":"offer","sdp":"v=0..."}} @@ -55,7 +55,7 @@ wss://host/ws?token= GET /live/streams GET /live/streams/:streamId 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 POST /live/rooms/:id/like GET /live/rooms/:id/messages?limit=100 @@ -64,7 +64,11 @@ GET /live/recharge/providers 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 接口保留用于客户端契约兼容,但验证房间后始终返回空列表,不会向新进入用户泄露此前聊天。 @@ -94,7 +98,7 @@ GET /live/recharge/orders/:orderNo GET /live/recharge/orders/:orderNo/status ``` -点赞不要求登录,点赞数只在当前进程的直播运行态中累加;关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。 +点赞不要求登录,但仅允许正在直播的公开房间;服务端点赞数在当前进程的直播运行态中累加。PC 保留本次访问的点赞状态,不生成随机点赞。关注关系按直播用户和房间保存,登录后可查询、关注或取消关注。 创建充值订单直接接入 KRA 通用支付订单,请求使用支付模块的渠道标识: @@ -111,10 +115,35 @@ GET /live/recharge/orders/:orderNo/status 礼物数量允许 `1..9999`,礼物接口返回的 `tiers` 仅作为快捷数量。服务端会拒绝向未开播房间送礼;扣款、礼物流水和钱包余额在同一事务内完成,余额不会小于 0。余额不足时返回 `required`、`balance` 和 `missing`,供客户端打开充值流程;成功后通过 `GiftMessage` 广播给房间内在线用户。 +PC 只展示接口返回的礼物,不补造礼物 ID、价格或随机聊天。赠送按钮在请求期间防止重复提交。充值只有确认 `creditedAt` 并刷新余额后才视为到账;余额足够时继续原先选择的礼物一次,仍不足则显示差额并允许继续充值。关闭充值弹窗、离开房间、切换账号或关播会取消尚未执行的赠送意图,迟到的响应不会重新打开弹窗或自动扣款。已提交支付的真实到账结果仍以服务端订单和钱包为准。 + 全站设置继续保存在 `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 主机候选。生产环境可以配置: diff --git a/internal/biz/live/catalog.go b/internal/biz/live/catalog.go index 7526f39..9b7ac91 100644 --- a/internal/biz/live/catalog.go +++ b/internal/biz/live/catalog.go @@ -24,6 +24,7 @@ var ( ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled") ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting") ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data") + ErrRoomDisabled = kratoserrors.BadRequest("LIVE_ROOM_DISABLED", "live room is disabled") ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting") 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.AnchorName = strings.TrimSpace(filter.AnchorName) options = normalizeListOptions(options) - if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline { - return uc.listByRuntimeStatus(ctx, filter, options) + if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline || ordersByViewerCount(options.OrderBy) { + return uc.listByRuntime(ctx, filter, options) } rooms, total, err := uc.repo.ListRooms(ctx, filter, options) 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 } -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) + storageOrder := options.OrderBy + if ordersByViewerCount(storageOrder) { + // Runtime sorting happens after loading, so keep storage batches stable. + storageOrder = "id_asc" + } 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 { return nil, 0, err } @@ -176,12 +182,13 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte } all = uc.attachRuntime(ctx, all) matched := make([]*Room, 0, len(all)) + filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline for _, room := range all { - if room.Status == filter.Status { + if !filterRuntimeStatus || room.Status == filter.Status { 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 { if matched[i].ViewerCount == matched[j].ViewerCount { 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 } +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) { if id == 0 { return nil, ErrRoomNotFound @@ -219,15 +230,15 @@ func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) { if err != nil { return nil, err } - if !room.Enabled || room.Status != RoomStatusLive { - return nil, ErrRoomNotLive + if !room.Enabled { + return nil, ErrRoomDisabled } category, err := uc.repo.GetCategory(ctx, room.CategoryID) if err != nil { return nil, err } if !category.Enabled { - return nil, ErrRoomNotLive + return nil, ErrCategoryDisabled } return room, nil } @@ -237,7 +248,14 @@ func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) { if err != nil { 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) { diff --git a/internal/biz/live/catalog_runtime_test.go b/internal/biz/live/catalog_runtime_test.go index 7228a42..d7967f9 100644 --- a/internal/biz/live/catalog_runtime_test.go +++ b/internal/biz/live/catalog_runtime_test.go @@ -40,6 +40,12 @@ func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, option if filter.Enabled != nil && room.Enabled != *filter.Enabled { continue } + if filter.Status == RoomStatusDisabled && room.Enabled { + continue + } + if (filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline) && !room.Enabled { + continue + } if filter.CategoryEnabled != nil { category, err := r.GetCategory(context.Background(), room.CategoryID) if err != nil || category.Enabled != *filter.CategoryEnabled { @@ -105,19 +111,26 @@ func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) { } } -func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) { - catalog := &catalogRepoStub{rooms: []*Room{ - {ID: 1, Enabled: true}, - {ID: 2, Enabled: true}, - {ID: 3, Enabled: false}, - }} +func TestRoomUsecaseIsLiveRequiresEnabledRoomCategoryAndOnlinePublisher(t *testing.T) { + catalog := &catalogRepoStub{ + categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}}, + rooms: []*Room{ + {ID: 1, CategoryID: 1, Enabled: true}, + {ID: 2, CategoryID: 1, Enabled: true, 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{ "room-1": {StreamID: "room-1", 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) - 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) if err != nil { 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) } } + 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{ categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}}, rooms: []*Room{ {ID: 1, CategoryID: 1, Enabled: true}, - {ID: 2, CategoryID: 1, Enabled: true}, + {ID: 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: 1, Enabled: true}, + {ID: 6, CategoryID: 3, Enabled: true}, }, } streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{ @@ -145,16 +163,106 @@ func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) { }}) rooms := NewRoomUsecase(catalog, streams) - item, err := rooms.GetPublic(context.Background(), 1) - if err != nil || item.ID != 1 || item.Status != RoomStatusLive { - t.Fatalf("GetPublic(1) item=%+v err=%v", item, err) - } - for _, roomID := range []uint{2, 3, 4} { - if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) { - t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive) + for _, test := range []struct { + id uint + status string + replay string + }{ + {id: 1, status: RoomStatusLive}, + {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) } } } + +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) + } +} diff --git a/internal/data/gift/gift.go b/internal/data/gift/gift.go index 08af368..dd26b11 100644 --- a/internal/data/gift/gift.go +++ b/internal/data/gift/gift.go @@ -4,11 +4,11 @@ import ( "context" "encoding/json" "errors" - "fmt" "gorm.io/gorm" "gorm.io/gorm/clause" bizgift "kra/internal/biz/gift" "kra/internal/data/ledger" + "kra/internal/utils/serial" "strconv" "strings" "time" @@ -26,7 +26,7 @@ type giftPO struct { Description string `gorm:"size:255"` Tiers string `gorm:"type:text"` Sort int `gorm:"default:0;index"` - Enabled bool `gorm:"default:true;index"` + Enabled bool `gorm:"index"` } 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 - 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 { return err } @@ -295,6 +299,3 @@ func sendOrder(v string) string { } 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) -} diff --git a/internal/data/gift/gift_test.go b/internal/data/gift/gift_test.go index 871f59d..e264f7c 100644 --- a/internal/data/gift/gift_test.go +++ b/internal/data/gift/gift_test.go @@ -3,6 +3,7 @@ package gift import ( "context" "errors" + "regexp" "testing" bizgift "kra/internal/biz/gift" @@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) { 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) + } +} diff --git a/internal/data/ledger/models.go b/internal/data/ledger/models.go index 5fda549..e33f202 100644 --- a/internal/data/ledger/models.go +++ b/internal/data/ledger/models.go @@ -19,7 +19,7 @@ type RechargePlanPO struct { Sort int `gorm:"index"` Diamonds int64 AmountCents int64 - Enabled bool `gorm:"default:true"` + Enabled bool } func (RechargePlanPO) TableName() string { return "live_recharge_plans" } diff --git a/internal/data/live/catalog.go b/internal/data/live/catalog.go index 970bbc6..6e9de01 100644 --- a/internal/data/live/catalog.go +++ b/internal/data/live/catalog.go @@ -23,7 +23,7 @@ type categoryPO struct { Name string `gorm:"size:40;uniqueIndex"` Icon string `gorm:"size:500"` Sort int `gorm:"default:0;index"` - Enabled bool `gorm:"default:true;index"` + Enabled bool `gorm:"index"` } func (categoryPO) TableName() string { return "live_categories" } @@ -41,7 +41,7 @@ type roomPO struct { AnchorUserID uint `gorm:"index"` StreamURL 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" } @@ -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) { db := r.db(ctx) 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") if filter.Name != "" { value := "%" + filter.Name + "%" @@ -219,7 +219,7 @@ func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, erro var row roomRow 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"). - 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"). Where("live_rooms.id = ?", id).Scan(&row).Error if err != nil { diff --git a/internal/data/live/catalog_test.go b/internal/data/live/catalog_test.go index 4e66814..8dc9aff 100644 --- a/internal/data/live/catalog_test.go +++ b/internal/data/live/catalog_test.go @@ -2,6 +2,7 @@ package live import ( "context" + "errors" "testing" bizlive "kra/internal/biz/live" @@ -44,7 +45,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) { if err := db.Create(&category).Error; err != nil { 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 { t.Fatal(err) } @@ -70,7 +71,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) { if err != nil { 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) } } @@ -110,3 +111,158 @@ func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) { 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) + } +} diff --git a/internal/data/recharge/recharge.go b/internal/data/recharge/recharge.go index e24a861..5bf1c61 100644 --- a/internal/data/recharge/recharge.go +++ b/internal/data/recharge/recharge.go @@ -3,7 +3,6 @@ package recharge import ( "context" "errors" - "fmt" "strconv" "strings" "time" @@ -13,6 +12,7 @@ import ( biz "kra/internal/biz/recharge" "kra/internal/data/ledger" + "kra/internal/utils/serial" ) type Provider interface{ DB() *gorm.DB } @@ -39,7 +39,7 @@ func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error { return err } 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 } } @@ -127,7 +127,11 @@ func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, err 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 { return nil, err } @@ -185,7 +189,3 @@ func orderBy(v string) string { } 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) -} diff --git a/internal/data/recharge/recharge_test.go b/internal/data/recharge/recharge_test.go index 1dc74f1..eac05c1 100644 --- a/internal/data/recharge/recharge_test.go +++ b/internal/data/recharge/recharge_test.go @@ -2,6 +2,8 @@ package recharge import ( "context" + "errors" + "regexp" "testing" "time" @@ -95,3 +97,174 @@ func TestListReadsPaymentStateFromKRAOrders(t *testing.T) { 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) + } +} diff --git a/internal/integration/live/server.go b/internal/integration/live/server.go index 1fef4dd..154240f 100644 --- a/internal/integration/live/server.go +++ b/internal/integration/live/server.go @@ -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"}) 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) if err != nil { 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 { 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 { return errors.New("live room is unavailable") } - if !item.Enabled { - return errors.New("live room is disabled") - } if item.AnchorUserID != userID { return errors.New("current user is not the room anchor") } diff --git a/internal/integration/live/server_test.go b/internal/integration/live/server_test.go index 8c9b66d..6a4d778 100644 --- a/internal/integration/live/server_test.go +++ b/internal/integration/live/server_test.go @@ -2,6 +2,7 @@ package live import ( "context" + "encoding/json" "sync" "testing" @@ -9,6 +10,7 @@ import ( "github.com/pion/webrtc/v4" bizlive "kra/internal/biz/live" + bizchat "kra/internal/biz/livechat" platformws "kra/pkg/websocket" ) @@ -39,28 +41,41 @@ func (h *liveHubStub) Send(session *platformws.Session, message []byte) error { type liveCatalogStub struct { bizlive.CatalogRepo - room *bizlive.Room - err error + room *bizlive.Room + 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 { return nil, s.err } + if s.room == nil || s.room.ID != id { + return nil, bizlive.ErrRoomNotFound + } copy := *s.room return ©, nil } 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 ©, nil + } return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil } type liveStreamStub struct { bizlive.StreamRepo - mu sync.Mutex - streams map[string]*bizlive.Stream - users map[uint]struct{} - registerCalls int + mu sync.Mutex + streams map[string]*bizlive.Stream + users map[uint]struct{} + registerCalls int + publisherRegisterCalls int } 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 } + +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 ©, 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) { s.mu.Lock() defer s.mu.Unlock() @@ -97,15 +144,49 @@ func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, return nil, bizlive.ErrSubscriberExists } 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 } +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 { streamUC := bizlive.NewStreamUsecase(streams) return &Manager{ hub: &liveHubStub{}, streams: streamUC, roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC), + chat: bizchat.NewUsecase(&liveChatStub{}), rooms: make(map[string]*room), peers: make(map[*melody.Session]*peer), observers: make(map[*melody.Session]struct{}), @@ -132,6 +213,11 @@ func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) { if len(streams.users) != 2 { 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) { @@ -147,7 +233,7 @@ func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) { func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) { 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{}} 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 { 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) { @@ -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) { oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{}) if err != nil { diff --git a/internal/server/handler/live.go b/internal/server/handler/live.go index b2f2792..a0b3c9c 100644 --- a/internal/server/handler/live.go +++ b/internal/server/handler/live.go @@ -83,7 +83,7 @@ func (h *Live) Like(c *gin.Context) { // Do not create runtime stream state for arbitrary room IDs. The stream // repository is process-local, so validating the catalog first also keeps // bogus likes from accumulating forever. - room, err := h.catalog.GetRoom(c.Request.Context(), id) + room, err := h.catalog.GetPublicRoom(c.Request.Context(), id) if err != nil { businessFail(c, err) return diff --git a/internal/service/live/catalog.go b/internal/service/live/catalog.go index e263bea..4ce818c 100644 --- a/internal/service/live/catalog.go +++ b/internal/service/live/catalog.go @@ -10,7 +10,18 @@ import ( type CatalogService struct { 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 { @@ -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) { 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 { return nil, 0, err } diff --git a/internal/service/live/catalog_test.go b/internal/service/live/catalog_test.go new file mode 100644 index 0000000..bdfc4fb --- /dev/null +++ b/internal/service/live/catalog_test.go @@ -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) + } +} diff --git a/internal/utils/serial/serial.go b/internal/utils/serial/serial.go new file mode 100644 index 0000000..d68b171 --- /dev/null +++ b/internal/utils/serial/serial.go @@ -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 +} diff --git a/internal/utils/serial/serial_test.go b/internal/utils/serial/serial_test.go new file mode 100644 index 0000000..95c57d8 --- /dev/null +++ b/internal/utils/serial/serial_test.go @@ -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) + } +} diff --git a/tmp/live_acceptance/database.go b/tmp/live_acceptance/database.go new file mode 100644 index 0000000..31a0439 --- /dev/null +++ b/tmp/live_acceptance/database.go @@ -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{}) +} diff --git a/tmp/live_acceptance/main.go b/tmp/live_acceptance/main.go new file mode 100644 index 0000000..514ba8e --- /dev/null +++ b/tmp/live_acceptance/main.go @@ -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 ") + } + 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, + } +} diff --git a/tmp/live_acceptance/payment_mock.go b/tmp/live_acceptance/payment_mock.go new file mode 100644 index 0000000..5c9d062 --- /dev/null +++ b/tmp/live_acceptance/payment_mock.go @@ -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) +} diff --git a/tmp/live_acceptance/setup.go b/tmp/live_acceptance/setup.go new file mode 100644 index 0000000..1194cff --- /dev/null +++ b/tmp/live_acceptance/setup.go @@ -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 +} diff --git a/tmp/live_acceptance/verify.go b/tmp/live_acceptance/verify.go new file mode 100644 index 0000000..2f94faa --- /dev/null +++ b/tmp/live_acceptance/verify.go @@ -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 +} diff --git a/tmp/live_acceptance/verify_account.go b/tmp/live_acceptance/verify_account.go new file mode 100644 index 0000000..6b97d18 --- /dev/null +++ b/tmp/live_acceptance/verify_account.go @@ -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, + }, ®istered)) + 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") +} diff --git a/tmp/live_acceptance/verify_catalog.go b/tmp/live_acceptance/verify_catalog.go new file mode 100644 index 0000000..4d6eaf2 --- /dev/null +++ b/tmp/live_acceptance/verify_catalog.go @@ -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") +} diff --git a/tmp/live_acceptance/verify_live.go b/tmp/live_acceptance/verify_live.go new file mode 100644 index 0000000..61b2e37 --- /dev/null +++ b/tmp/live_acceptance/verify_live.go @@ -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") +} diff --git a/tmp/live_acceptance/verify_recharge.go b/tmp/live_acceptance/verify_recharge.go new file mode 100644 index 0000000..e6c6c7a --- /dev/null +++ b/tmp/live_acceptance/verify_recharge.go @@ -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") +} diff --git a/tmp/live_acceptance/websocket.go b/tmp/live_acceptance/websocket.go new file mode 100644 index 0000000..d46a7de --- /dev/null +++ b/tmp/live_acceptance/websocket.go @@ -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") +} diff --git a/web/src/modules/live/README.md b/web/src/modules/live/README.md new file mode 100644 index 0000000..dc3e9ce --- /dev/null +++ b/web/src/modules/live/README.md @@ -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 +``` diff --git a/web/src/modules/live/api/live.js b/web/src/modules/live/api/live.js index dcc152d..472f899 100644 --- a/web/src/modules/live/api/live.js +++ b/web/src/modules/live/api/live.js @@ -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 getRooms = (params) => get('/live/admin/rooms', params) -export const getPublicRooms = (params) => get('/live/rooms', params) -export const getRoom = (id) => get(`/live/rooms/${id}`) -export const getChatHistory = (id, params) => get(`/live/rooms/${id}/messages`, params) +export const getRoomDetail = (id) => get(`/live/admin/rooms/${id}`) export const createRoom = (data) => post('/live/admin/rooms', data) export const updateRoom = (id, data) => put(`/live/admin/rooms/${id}`, data) 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 getGifts = (params) => get('/live/admin/gifts', params) -export const getPublicGifts = () => get('/live/gifts') export const createGift = (data) => post('/live/admin/gifts', data) export const updateGift = (id, data) => put(`/live/admin/gifts/${id}`, data) export const deleteGift = (id) => remove(`/live/admin/gifts/${id}`) export const setGiftStatus = (id, enabled) => patch(`/live/admin/gifts/${id}/status`, { enabled }) 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 getRechargePlans = () => get('/live/recharge/plans') export const saveRechargePlans = (plans) => put('/live/admin/recharge/plans', { plans }) export const getOrders = (params) => get('/live/admin/orders', params) 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 getAssetUser = (id) => get(`/live/admin/users/${id}`) diff --git a/web/src/modules/live/utils/clipboard.js b/web/src/modules/live/utils/clipboard.js new file mode 100644 index 0000000..a356865 --- /dev/null +++ b/web/src/modules/live/utils/clipboard.js @@ -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 }) + } +} diff --git a/web/src/modules/live/utils/clipboard.test.js b/web/src/modules/live/utils/clipboard.test.js new file mode 100644 index 0000000..b080ded --- /dev/null +++ b/web/src/modules/live/utils/clipboard.test.js @@ -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) +}) diff --git a/web/src/modules/live/utils/roomLinks.js b/web/src/modules/live/utils/roomLinks.js new file mode 100644 index 0000000..037b27a --- /dev/null +++ b/web/src/modules/live/utils/roomLinks.js @@ -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() } +} diff --git a/web/src/modules/live/utils/roomLinks.test.js b/web/src/modules/live/utils/roomLinks.test.js new file mode 100644 index 0000000..9279435 --- /dev/null +++ b/web/src/modules/live/utils/roomLinks.test.js @@ -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 })) + } +}) diff --git a/web/src/modules/live/view/diagnostics.vue b/web/src/modules/live/view/diagnostics.vue index 0f37325..e8da1b2 100644 --- a/web/src/modules/live/view/diagnostics.vue +++ b/web/src/modules/live/view/diagnostics.vue @@ -83,8 +83,10 @@ import { ArrowLeft, CircleCheck, CircleClose, Clock, CopyDocument, Delete, Docum import { ElMessage } from 'element-plus' import { useRoute, useRouter } from 'vue-router' import { useUserStore } from '@/pinia/modules/user' -import { getLiveStreams, getRoom } from '../api/live' +import { getLiveStreams, getRoomDetail } from '../api/live' import { useLiveRTC } from '../composables/useLiveRTC' +import { getRoomLinks } from '../utils/roomLinks' +import { copyLiveText } from '../utils/clipboard' defineOptions({ name: 'LiveDiagnostics' }) @@ -106,9 +108,12 @@ const tests = reactive({ viewer: { status: 'idle', detail: '等待检测观众下行链路' }, }) -const base = () => `${window.location.origin}${window.location.pathname}#` -const viewerURL = computed(() => `${base()}/live/room/${route.params.id}`) -const studioURL = computed(() => `${base()}/live/studio/${route.params.id}`) +const roomLinks = computed(() => { + try { return getRoomLinks(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 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)) @@ -147,7 +152,7 @@ const sleep = (duration) => new Promise((resolve) => window.setTimeout(resolve, const loadRoom = async () => { 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 || '房间接口不可用') room.value = response.data return response.data @@ -158,6 +163,7 @@ const testLinks = async () => { tests.link.detail = '正在请求房间接口和运行状态' try { const item = await loadRoom() + if (!viewerURL.value || !studioURL.value) throw new Error('PC 站点地址配置无效,无法生成房间链接') const streams = await getLiveStreams() runtime.value = (streams.data?.streams || []).find((stream) => stream.streamId === item.streamId) || null if (!item.enabled) throw new Error('房间接口正常,但直播间已停用') @@ -280,8 +286,15 @@ const report = () => ({ publisher: { state: publisherRTC.state.value, diagnostics: { ...publisherRTC.diagnostics }, events: publisherRTC.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 copy = async (value) => { await navigator.clipboard.writeText(value); ElMessage.success('链接已复制') } +const copyReport = async () => { + 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 clearLogs = () => { publisherRTC.events.value = []; viewerRTC.events.value = [] } const stopConnections = () => { publisherRTC.disconnect(); viewerRTC.disconnect() } diff --git a/web/src/modules/live/view/room.vue b/web/src/modules/live/view/room.vue deleted file mode 100644 index 835d15c..0000000 --- a/web/src/modules/live/view/room.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - diff --git a/web/src/modules/live/view/rooms.vue b/web/src/modules/live/view/rooms.vue index 95ef2c6..e31ba78 100644 --- a/web/src/modules/live/view/rooms.vue +++ b/web/src/modules/live/view/rooms.vue @@ -25,7 +25,7 @@ - +
@@ -46,18 +46,30 @@ + + + + + @@ -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; } .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-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; } } diff --git a/web/src/modules/live/view/sends.vue b/web/src/modules/live/view/sends.vue index dfd469b..799f120 100644 --- a/web/src/modules/live/view/sends.vue +++ b/web/src/modules/live/view/sends.vue @@ -20,7 +20,7 @@ diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 1bace1e..1359a73 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -73,7 +73,6 @@ "/src/modules/live/view/diagnostics.vue": "LiveDiagnostics", "/src/modules/live/view/gifts.vue": "LiveGifts", "/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/sends.vue": "LiveSends", "/src/modules/live/view/settings.vue": "LiveSettings", diff --git a/web/src/router/index.js b/web/src/router/index.js index 877406b..7c98b27 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -30,12 +30,6 @@ const routes = [ component: () => import('@/view/system/security/forceChangePassword.vue'), meta: { title: '修改密码' } }, - { - path: '/live/room/:id', - name: 'LiveViewer', - component: () => import('@/modules/live/view/room.vue'), - meta: { title: '直播间', client: true } - }, { path: '/live/studio/:id', name: 'LiveStudio',