对接
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
- generic [ref=e3]:
|
||||
- banner [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- link "宇宙大派队 宇宙大派队" [ref=e6] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img "宇宙大派队" [ref=e7]
|
||||
- generic [ref=e8]: 宇宙大派队
|
||||
- navigation [ref=e9]:
|
||||
- link "首页" [ref=e10] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- link "热门直播" [ref=e11] [cursor=pointer]:
|
||||
- /url: "#/hot"
|
||||
- link "公会合作" [ref=e12] [cursor=pointer]:
|
||||
- /url: "#/guild"
|
||||
- link "联系我们" [ref=e13] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- link " 登录" [ref=e14] [cursor=pointer]:
|
||||
- /url: "#"
|
||||
- generic [ref=e15]:
|
||||
- text: 登录
|
||||
- main [ref=e16]:
|
||||
- generic [ref=e17]:
|
||||
- heading "🔥 热门直播" [level=1] [ref=e18]
|
||||
- paragraph [ref=e19]: 大家都在看 · 即刻加入狂欢,发现当前最火热的直播间
|
||||
- tablist "直播分区" [ref=e20]:
|
||||
- tab "全部" [selected] [ref=e21] [cursor=pointer]
|
||||
- tab "游戏竞技" [ref=e22] [cursor=pointer]
|
||||
- tab "音乐区" [ref=e23] [cursor=pointer]
|
||||
- tab "闲聊电台" [ref=e24] [cursor=pointer]
|
||||
- tab "舞蹈才艺" [ref=e25] [cursor=pointer]
|
||||
- tab "娱乐直播" [ref=e26] [cursor=pointer]
|
||||
- generic [ref=e27]: 当前分区暂无直播间
|
||||
- contentinfo [ref=e28]:
|
||||
- generic [ref=e30]:
|
||||
- paragraph [ref=e31]: 公司名称:泉州宇宙派队网络科技有限公司
|
||||
- paragraph [ref=e32]: 地址:福建省泉州市丰泽区菲莉大厦1801、1802、1803室
|
||||
- paragraph [ref=e33]: 客服邮箱:326522981@qq.com
|
||||
- paragraph [ref=e34]: 商务合作:13328578621
|
||||
- paragraph [ref=e35]:
|
||||
- link "闽ICP备2024031087号" [ref=e36] [cursor=pointer]:
|
||||
- /url: https://beian.miit.gov.cn/
|
||||
- text:
|
||||
+1
-1
@@ -134,7 +134,7 @@ admin:
|
||||
- http
|
||||
- sql
|
||||
cors:
|
||||
mode: whitelist
|
||||
mode: allow-all
|
||||
whitelist: []
|
||||
# Example:
|
||||
# whitelist:
|
||||
|
||||
+34
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
## WebSocket
|
||||
|
||||
连接现有 WebSocket 地址(默认 `/ws`)。观看直播允许匿名握手;登录用户可携带 GVA JWT,以使用聊天和开播能力:
|
||||
连接现有 WebSocket 地址(后端默认 `/ws`,生产反向代理地址为 `/api/ws`)。观看直播允许匿名握手;PC 登录后的 HttpOnly Cookie 会随同源握手自动发送,分享链接中不携带凭证。非浏览器客户端也可携带 GVA JWT:
|
||||
|
||||
```text
|
||||
wss://host/ws?token=<x-token>
|
||||
@@ -16,7 +16,7 @@ wss://host/ws?token=<x-token>
|
||||
{"type":"Join","payload":{"role":"publisher","streamId":"room-1"}}
|
||||
```
|
||||
|
||||
角色支持 `publisher`、`subscriber`、`observer`。`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=<x-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 主机候选。生产环境可以配置:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://127.0.0.1:18080"
|
||||
wsURL = "ws://127.0.0.1:18080/ws"
|
||||
outputDir = ".tmp-live-acceptance"
|
||||
adminPassword = "LocalAdmin_2026!"
|
||||
userPassword = "LocalAccept_2026!"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
http *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Database string `json:"database"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Admin account `json:"admin"`
|
||||
Anchor account `json:"anchor"`
|
||||
Viewer account `json:"viewer"`
|
||||
Other account `json:"other"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
RoomID uint `json:"roomId"`
|
||||
ReplayID uint `json:"replayRoomId"`
|
||||
DisabledID uint `json:"disabledRoomId"`
|
||||
BrowserID uint `json:"browserRoomId,omitempty"`
|
||||
GiftID uint `json:"giftId"`
|
||||
Checks []string `json:"setupChecks"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatal("usage: go run ./tmp/live_acceptance <setup|verify|publisher|mock-payment|browser-room>")
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "setup":
|
||||
err = setup()
|
||||
case "verify":
|
||||
err = verify()
|
||||
case "publisher":
|
||||
err = holdPublisher()
|
||||
case "mock-payment":
|
||||
err = serveMockPayments()
|
||||
case "browser-room":
|
||||
err = setupBrowserRoom()
|
||||
default:
|
||||
err = errors.New("unknown command")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newClient() *apiClient {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &apiClient{http: &http.Client{Jar: jar, Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *apiClient) call(method, path string, body, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequest(method, baseURL+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("x-token", c.token)
|
||||
}
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result envelope
|
||||
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("%s %s status=%d: %w", method, path, response.StatusCode, err)
|
||||
}
|
||||
if result.Code != 0 || response.StatusCode >= 400 {
|
||||
return fmt.Errorf("%s %s status=%d code=%d: %s", method, path, response.StatusCode, result.Code, result.Msg)
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(result.Data, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func login(user account, admin bool) (*apiClient, error) {
|
||||
c := newClient()
|
||||
path := "/live/client/auth/login"
|
||||
if admin {
|
||||
path = "/base/login"
|
||||
}
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.call("POST", path, map[string]any{"username": user.Username, "password": user.Password}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Token == "" {
|
||||
return nil, errors.New("login returned no token")
|
||||
}
|
||||
c.token = result.Token
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func readFixtures() (*fixtures, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(outputDir, "fixtures.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var f fixtures
|
||||
if err = json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
|
||||
if err != nil || f.Database != absolute || f.BaseURL != baseURL {
|
||||
return nil, errors.New("fixture points outside the isolated acceptance environment")
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func saveJSON(name string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(outputDir, name), append(data, '\n'), 0600)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func check(condition bool, format string, args ...any) {
|
||||
if !condition {
|
||||
panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
func roomPath(id uint) string { return fmt.Sprintf("/live/rooms/%d", id) }
|
||||
|
||||
func adminRoomPath(id uint) string { return fmt.Sprintf("/live/admin/rooms/%d", id) }
|
||||
|
||||
func makeRoom(name string, categoryID, anchorID uint, replay string, enabled bool) dto.LiveRoomRequest {
|
||||
return dto.LiveRoomRequest{
|
||||
Name: name, CategoryID: categoryID, AnchorUserID: anchorID,
|
||||
CoverURL: "/uploads/acceptance-cover.jpg", Intro: "Local isolated acceptance room",
|
||||
ReplayURL: replay, Enabled: enabled,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export async function copyLiveText(value, {
|
||||
clipboard = navigator.clipboard,
|
||||
documentRef = document,
|
||||
} = {}) {
|
||||
const text = String(value)
|
||||
if (clipboard?.writeText) {
|
||||
try { await clipboard.writeText(text); return }
|
||||
catch { /* Older or restricted browsers can still support a selection copy. */ }
|
||||
}
|
||||
const previousFocus = documentRef.activeElement
|
||||
const input = documentRef.createElement('textarea')
|
||||
input.value = text
|
||||
input.readOnly = true
|
||||
input.style.position = 'fixed'
|
||||
input.style.left = '-9999px'
|
||||
documentRef.body.appendChild(input)
|
||||
try {
|
||||
input.select()
|
||||
if (!documentRef.execCommand('copy')) throw new Error('Clipboard copy failed')
|
||||
} finally {
|
||||
input.remove()
|
||||
previousFocus?.focus?.({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { copyLiveText } from './clipboard.js'
|
||||
|
||||
function legacyDocument(success) {
|
||||
const input = { style: {}, select() { this.selected = true }, remove() { this.removed = true } }
|
||||
return {
|
||||
input,
|
||||
activeElement: { focus() {} },
|
||||
createElement: () => input,
|
||||
body: { appendChild() {} },
|
||||
execCommand: () => success,
|
||||
}
|
||||
}
|
||||
|
||||
test('copies an absolute link through the modern clipboard', async () => {
|
||||
let copied
|
||||
await copyLiveText('https://live.example.com/#/studio/2', {
|
||||
clipboard: { async writeText(value) { copied = value } },
|
||||
documentRef: {},
|
||||
})
|
||||
assert.equal(copied, 'https://live.example.com/#/studio/2')
|
||||
})
|
||||
|
||||
test('uses a selection fallback when clipboard access is denied', async () => {
|
||||
const documentRef = legacyDocument(true)
|
||||
await copyLiveText('viewer-link', {
|
||||
clipboard: { async writeText() { throw new Error('denied') } },
|
||||
documentRef,
|
||||
})
|
||||
assert.equal(documentRef.input.value, 'viewer-link')
|
||||
assert.equal(documentRef.input.selected, true)
|
||||
assert.equal(documentRef.input.removed, true)
|
||||
})
|
||||
|
||||
test('does not report success when both browser copy mechanisms fail', async () => {
|
||||
const documentRef = legacyDocument(false)
|
||||
await assert.rejects(copyLiveText('viewer-link', { clipboard: null, documentRef }), /copy failed/)
|
||||
assert.equal(documentRef.input.removed, true)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export function getRoomLinks(roomId, {
|
||||
origin = window.location.origin,
|
||||
clientBaseUrl = import.meta.env?.VITE_LIVE_CLIENT_URL,
|
||||
development = import.meta.env?.DEV,
|
||||
} = {}) {
|
||||
const id = Number(roomId)
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Invalid live room ID')
|
||||
|
||||
const clientBase = String(clientBaseUrl || '').trim()
|
||||
const url = new URL(clientBase || '/', origin)
|
||||
if (!clientBase && development) url.port = '5173'
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('Invalid live client URL')
|
||||
}
|
||||
url.search = ''
|
||||
if (!/\.html?$/i.test(url.pathname)) url.pathname = `${url.pathname.replace(/\/+$/, '')}/`
|
||||
url.hash = `/live/${id}`
|
||||
const viewer = url.toString()
|
||||
url.hash = `/studio/${id}`
|
||||
return { viewer, studio: url.toString() }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { getRoomLinks } from './roomLinks.js'
|
||||
|
||||
test('production links target the PC routes, not the administration path', () => {
|
||||
assert.deepEqual(getRoomLinks(2, { origin: 'https://live.example.com', development: false }), {
|
||||
viewer: 'https://live.example.com/#/live/2',
|
||||
studio: 'https://live.example.com/#/studio/2',
|
||||
})
|
||||
})
|
||||
|
||||
test('custom client origins and base paths discard unrelated query and hash state', () => {
|
||||
assert.deepEqual(getRoomLinks('12', {
|
||||
origin: 'https://admin.example.com',
|
||||
clientBaseUrl: 'https://client.example.com/player?token=unused#/old',
|
||||
}), {
|
||||
viewer: 'https://client.example.com/player/#/live/12',
|
||||
studio: 'https://client.example.com/player/#/studio/12',
|
||||
})
|
||||
})
|
||||
|
||||
test('development links use the PC dev server and support an explicit port override', () => {
|
||||
assert.equal(getRoomLinks(1, { origin: 'http://localhost:8080', development: true }).viewer, 'http://localhost:5173/#/live/1')
|
||||
assert.equal(getRoomLinks(1, {
|
||||
origin: 'http://localhost:8080',
|
||||
development: true,
|
||||
clientBaseUrl: 'http://localhost:5174/',
|
||||
}).studio, 'http://localhost:5174/#/studio/1')
|
||||
})
|
||||
|
||||
test('client base supports a relative path and a static HTML entrypoint', () => {
|
||||
assert.equal(getRoomLinks(3, {
|
||||
origin: 'https://live.example.com',
|
||||
clientBaseUrl: '/pc/index.html',
|
||||
}).viewer, 'https://live.example.com/pc/index.html#/live/3')
|
||||
})
|
||||
|
||||
test('invalid room IDs, executable schemes and URL credentials are rejected', () => {
|
||||
for (const id of [0, -1, 1.2, '2/other', undefined]) {
|
||||
assert.throws(() => getRoomLinks(id, { origin: 'https://live.example.com' }))
|
||||
}
|
||||
for (const clientBaseUrl of ['javascript:alert(1)', 'https://user:password@example.com/']) {
|
||||
assert.throws(() => getRoomLinks(2, { origin: 'https://live.example.com', clientBaseUrl }))
|
||||
}
|
||||
})
|
||||
@@ -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() }
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<main class="live-client-page">
|
||||
<div class="viewer-stage">
|
||||
<video v-if="room?.streamUrl" ref="videoRef" :src="room.streamUrl" controls autoplay playsinline />
|
||||
<video v-else-if="rtc.remoteStream.value" ref="videoRef" :srcObject="rtc.remoteStream.value" autoplay playsinline controls />
|
||||
<video v-else-if="room?.replayUrl && room?.status !== 'live'" :src="getUrl(room.replayUrl)" controls autoplay playsinline />
|
||||
<el-image v-else-if="room?.coverUrl" :src="getUrl(room.coverUrl)" fit="cover" class="viewer-cover" />
|
||||
<div v-if="rtc.message.value" class="viewer-state"><el-icon><VideoPause /></el-icon><strong>{{ rtc.message.value }}</strong><el-button v-if="rtc.state.value === 'auth-required'" type="primary" @click="goLogin">去登录</el-button></div>
|
||||
<header class="viewer-top"><button class="viewer-back" title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div class="viewer-room"><el-avatar :size="42" :src="getUrl(room?.anchorAvatar)">{{ room?.anchorName?.slice(0, 1) }}</el-avatar><div><strong>{{ room?.name || '直播间' }}</strong><span>{{ room?.anchorName || '主播' }} · {{ room?.viewerCount || 0 }} 人观看</span></div></div></header>
|
||||
</div>
|
||||
<aside class="viewer-sidebar"><div class="viewer-info"><h1>{{ room?.name }}</h1><p>{{ room?.intro || '主播暂未填写直播简介。' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div><div class="gift-panel"><div class="gift-heading"><strong>送礼物</strong><span>余额 {{ number(balance) }} 钻石</span></div><div class="gift-grid"><button v-for="gift in gifts" :key="gift.id" :class="{ active: selectedGift?.id === gift.id }" @click="selectedGift = gift"><span v-if="!isImage(gift.icon)">{{ gift.icon }}</span><img v-else :src="getUrl(gift.icon)"><strong>{{ gift.name }}</strong><small>{{ gift.price }} 钻石</small></button></div><div class="gift-send"><el-select v-model="quantity"><el-option v-for="tier in selectedGift?.tiers || [1]" :key="tier" :label="`x${tier}`" :value="tier" /></el-select><el-button type="primary" :disabled="!selectedGift" @click="submitGift">赠送</el-button></div></div><section class="chat-panel"><div class="chat-heading"><strong>聊天室</strong><span>{{ rtc.chatMessages.value.length }} 条消息</span></div><div class="chat-list"><div v-for="item in rtc.chatMessages.value" :key="`${item.id}-${item.createdAt}`" class="chat-item"><el-avatar :size="24" :src="getUrl(item.avatar)">{{ (item.nickname || item.username || '用').slice(0, 1) }}</el-avatar><div><strong>{{ item.nickname || item.username || `用户 ${item.userId}` }}</strong><small>{{ formatChatTime(item.createdAt) }}</small><p v-if="item.type === 'gift'">送出 {{ item.giftIcon }} {{ item.giftName }} x{{ item.quantity }}</p><p v-else>{{ item.content }}</p></div></div><el-empty v-if="!rtc.chatMessages.value.length" description="暂无聊天消息" :image-size="45" /></div><div class="chat-compose"><el-input v-model="chatDraft" maxlength="100" show-word-limit placeholder="说点什么" @keyup.enter="sendChatMessage" /><el-button type="primary" :disabled="!chatDraft.trim()" @click="sendChatMessage">发送</el-button></div></section></aside>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowLeft, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getAssetUser, getChatHistory, getPublicGifts, getRoom, sendGift } from '../api/live'
|
||||
import { useLiveRTC } from '../composables/useLiveRTC'
|
||||
defineOptions({ name: 'LiveViewer' })
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), gifts = ref([]), selectedGift = ref(null), quantity = ref(1), balance = ref(0), chatDraft = ref(''), videoRef = ref()
|
||||
const number = (v) => Number(v || 0).toLocaleString('zh-CN'), isImage = (v) => /^(https?:|\/|data:)/.test(String(v || ''))
|
||||
watch(() => rtc.remoteStream.value, async (stream) => { await nextTick(); if (videoRef.value && stream) { videoRef.value.srcObject = stream; videoRef.value.play().catch(() => {}) } })
|
||||
const load = async () => { const id = Number(route.params.id); if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo(); const [roomRes, giftRes, chatRes] = await Promise.all([getRoom(id), getPublicGifts(), getChatHistory(id, { limit: 100 })]); if (roomRes.code === 0) room.value = roomRes.data; if (giftRes.code === 0) { gifts.value = giftRes.data.list || []; selectedGift.value = gifts.value[0]; quantity.value = selectedGift.value?.tiers?.[0] || 1 } if (chatRes.code === 0) rtc.chatMessages.value = chatRes.data.list || []; if (userStore.token && userStore.userInfo?.ID) { const asset = await getAssetUser(userStore.userInfo.ID); if (asset.code === 0) balance.value = asset.data.diamondBalance } if (room.value && !room.value.streamUrl && room.value.status === 'live') rtc.connect({ mode: 'subscriber', id: room.value.streamId }).catch(() => {}) }
|
||||
const submitGift = async () => { if (!selectedGift.value) return; const total = selectedGift.value.price * quantity.value; if (total > balance.value) return ElMessage.warning('钻石余额不足'); const res = await sendGift(room.value.id, { giftId: selectedGift.value.id, quantity: quantity.value }); if (res.code === 0) { balance.value -= total; ElMessage.success(`已送出 ${quantity.value} 个${selectedGift.value.name}`) } }
|
||||
const sendChatMessage = () => { if (!userStore.token) return goLogin(); if (!rtc.sendChat(chatDraft.value)) return ElMessage.warning(room.value?.status === 'live' ? '正在连接聊天室,请稍后再试' : '直播未开始,暂时不能发言'); chatDraft.value = '' }
|
||||
const formatChatTime = (value) => value ? new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
const goBack = () => window.history.length > 1 ? router.back() : router.push('/'), goLogin = () => router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>
|
||||
.live-client-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #101014; color: #fff; }.viewer-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; background: #08080b; }.viewer-stage video,.viewer-cover { width: 100%; height: 100vh; object-fit: contain; }.viewer-cover { opacity: .42; filter: blur(2px); }.viewer-top { position: absolute; z-index: 2; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 14px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 65%), transparent); }.viewer-back { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 12%); color: #fff; cursor: pointer; }.viewer-room { display: flex; align-items: center; gap: 10px; }.viewer-room div { display: grid; gap: 4px; }.viewer-room strong { font-size: 15px; }.viewer-room span { color: rgb(255 255 255 / 70%); font-size: 11px; }.viewer-state { position: absolute; z-index: 2; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; background: rgb(0 0 0 / 26%); }.viewer-state .el-icon { font-size: 42px; color: rgb(255 255 255 / 60%); }.viewer-sidebar { min-height: 100vh; background: #fff; color: #2a2025; }.viewer-info { padding: 25px 22px; border-bottom: 1px solid #f0e4e8; }.viewer-info h1 { margin: 0; font-size: 20px; letter-spacing: 0; }.viewer-info p { min-height: 42px; margin: 10px 0; color: #7a6d72; font-size: 13px; line-height: 1.6; }.gift-panel { padding: 20px 18px; }.gift-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }.gift-heading span { color: #a37e8c; font-size: 12px; }.gift-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }.gift-grid button { display: grid; min-width: 0; place-items: center; gap: 4px; padding: 10px 5px; border: 1px solid #f0e0e6; border-radius: 6px; background: #fff; color: inherit; cursor: pointer; }.gift-grid button.active { border-color: #e95f91; background: #fff4f8; }.gift-grid button > span,.gift-grid button > img { width: 36px; height: 36px; font-size: 28px; object-fit: cover; }.gift-grid strong { overflow: hidden; max-width: 100%; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.gift-grid small { color: #d54c7e; font-size: 10px; }.gift-send { display: grid; grid-template-columns: 1fr 110px; gap: 8px; margin-top: 14px; }
|
||||
.chat-panel { display: grid; grid-template-rows: auto minmax(150px, 1fr) auto; min-height: 300px; padding: 18px; border-top: 1px solid #f0e4e8; }
|
||||
.chat-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.chat-heading span { color: #a37e8c; font-size: 11px; }
|
||||
.chat-list { min-height: 0; max-height: 340px; overflow: auto; }
|
||||
.chat-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 0; }
|
||||
.chat-item > div { min-width: 0; }
|
||||
.chat-item strong { font-size: 11px; }
|
||||
.chat-item small { margin-left: 6px; color: #b2a2a8; font-size: 10px; }
|
||||
.chat-item p { margin: 3px 0 0; color: #615158; font-size: 12px; line-height: 1.45; word-break: break-word; }
|
||||
.chat-compose { display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 8px; margin-top: 10px; }
|
||||
@media (max-width: 850px) { .live-client-page { display: block; }.viewer-stage,.viewer-stage video,.viewer-cover { min-height: 56vh; height: 56vh; }.viewer-sidebar { min-height: 44vh; }.viewer-info { padding: 17px; }.gift-panel { padding: 16px; } }
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-table-column label="状态" min-width="82"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusText(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="!compactTable" label="观看人数" min-width="82" prop="viewerCount" align="right" />
|
||||
<el-table-column v-if="!compactTable" label="创建时间" min-width="150"><template #default="{ row }">{{ format(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="share" :icon="Share">复制分享链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }"><el-button link type="primary" :icon="VideoCamera" @click="openStudio(row)">开播</el-button><el-button link type="primary" :icon="Connection" @click="openDiagnostics(row)">测试</el-button><el-button link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button><el-dropdown trigger="click" @command="handleRoomAction($event, row)"><el-button link type="primary" :icon="MoreFilled" title="更多操作" aria-label="更多操作" /><template #dropdown><el-dropdown-menu><el-dropdown-item command="studio" :icon="CopyDocument">复制主播链接</el-dropdown-item><el-dropdown-item command="viewer" :icon="Share">复制观众链接</el-dropdown-item><el-dropdown-item command="links" :icon="Connection">查看链接</el-dropdown-item><el-dropdown-item command="delete" :icon="Delete" divided class="room-delete-action">删除直播间</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @change="load" /></div>
|
||||
</div>
|
||||
@@ -46,18 +46,30 @@
|
||||
<el-form-item label="启用直播间"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="linksDialog" title="直播间链接" width="min(640px, calc(100vw - 32px))">
|
||||
<p class="room-links-name">{{ linkedRoom?.name }} <span>#{{ linkedRoom?.id }}</span></p>
|
||||
<div v-for="item in linkItems" :key="item.key" class="room-link-row">
|
||||
<label :for="`room-link-${item.key}`">{{ item.label }}</label>
|
||||
<el-input :id="`room-link-${item.key}`" :model-value="roomLinks[item.key]" readonly @focus="$event.target.select()" />
|
||||
<el-tooltip :content="`复制${item.label}`"><el-button :icon="CopyDocument" :aria-label="`复制${item.label}`" @click="copyLink(linkedRoom, item.key)" /></el-tooltip>
|
||||
<el-tooltip :content="`打开${item.label}`"><el-button :icon="TopRight" :aria-label="`打开${item.label}`" @click="openLink(roomLinks[item.key])" /></el-tooltip>
|
||||
</div>
|
||||
<template #footer><el-button @click="linksDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Connection, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { Connection, CopyDocument, Delete, Edit, MoreFilled, Plus, Refresh, Search, Share, Sort, TopRight, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import ImageUploader from '../components/ImageUploader.vue'
|
||||
import VideoUploader from '../components/VideoUploader.vue'
|
||||
import { createRoom, deleteRoom, getAssetUsers, getCategories, getRooms, saveRoomSort, updateRoom } from '../api/live'
|
||||
import { getRoomLinks } from '../utils/roomLinks'
|
||||
import { copyLiveText } from '../utils/clipboard'
|
||||
|
||||
defineOptions({ name: 'LiveRooms' })
|
||||
const query = reactive({ name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' })
|
||||
@@ -67,6 +79,8 @@ const compactTable = computed(() => width.value < 1180)
|
||||
const page = ref(1), pageSize = ref(10), total = ref(0), editingId = ref(0), editingStatus = ref('')
|
||||
const blank = () => ({ name: '', categoryId: undefined, coverUrl: '', intro: '', sort: 0, anchorUserId: undefined, streamUrl: '', replayUrl: '', enabled: true })
|
||||
const form = reactive(blank())
|
||||
const linksDialog = ref(false), linkedRoom = ref(null), roomLinks = ref({ viewer: '', studio: '' })
|
||||
const linkItems = [{ key: 'studio', label: '主播链接' }, { key: 'viewer', label: '观众链接' }]
|
||||
const rules = { name: [{ required: true, message: '请输入直播间名称', trigger: 'blur' }], categoryId: [{ required: true, message: '请选择分类', trigger: 'change' }], anchorUserId: [{ required: true, message: '请选择主播', trigger: 'change' }], coverUrl: [{ required: true, message: '请上传直播封面', trigger: 'change' }] }
|
||||
const statusText = (value) => ({ live: '直播中', offline: '未开播', disabled: '已禁用' }[value] || value)
|
||||
const statusType = (value) => ({ live: 'success', offline: 'info', disabled: 'danger' }[value] || 'info')
|
||||
@@ -77,14 +91,48 @@ const search = () => { page.value = 1; load() }
|
||||
const reset = () => { Object.assign(query, { name: '', anchorName: '', categoryId: undefined, status: '', orderBy: 'sort_asc' }); search() }
|
||||
const openCreate = () => { editingId.value = 0; editingStatus.value = ''; Object.assign(form, blank()); drawer.value = true }
|
||||
const openEdit = (row) => { editingId.value = row.id; editingStatus.value = row.status; Object.assign(form, { name: row.name, categoryId: row.categoryId, coverUrl: row.coverUrl, intro: row.intro, sort: row.sort, anchorUserId: row.anchorUserId, streamUrl: row.streamUrl, replayUrl: row.replayUrl, enabled: row.enabled }); drawer.value = true }
|
||||
const submit = async () => { await formRef.value?.validate(); saving.value = true; try { const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form); if (res.code === 0) { ElMessage.success('直播间已保存'); drawer.value = false; load() } } finally { saving.value = false } }
|
||||
const submit = async () => {
|
||||
await formRef.value?.validate()
|
||||
saving.value = true
|
||||
try {
|
||||
const res = editingId.value ? await updateRoom(editingId.value, form) : await createRoom(form)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('直播间已保存')
|
||||
drawer.value = false
|
||||
showLinks(res.data || { ...form, id: editingId.value })
|
||||
load()
|
||||
}
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
const removeRow = async (row) => { await ElMessageBox.confirm(`确认删除直播间“${row.name}”吗?`, '删除直播间', { type: 'warning' }); const res = await deleteRoom(row.id); if (res.code === 0) { ElMessage.success('已删除'); load() } }
|
||||
const persistSort = async () => { const values = Object.fromEntries(rows.value.map((item) => [item.id, item.sort])); const res = await saveRoomSort(values); if (res.code === 0) { ElMessage.success('排序已保存'); load() } }
|
||||
const shareUrl = (row, studio = false) => `${window.location.origin}${window.location.pathname}#/live/${studio ? 'studio' : 'room'}/${row.id}`
|
||||
const copyLink = async (row) => { await navigator.clipboard.writeText(shareUrl(row)); ElMessage.success('观看链接已复制') }
|
||||
const openStudio = (row) => window.open(shareUrl(row, true), '_blank')
|
||||
const showLinks = (row) => {
|
||||
try {
|
||||
roomLinks.value = getRoomLinks(row.id)
|
||||
linkedRoom.value = row
|
||||
linksDialog.value = true
|
||||
} catch { ElMessage.error('无法生成直播间链接,请检查 PC 站点地址配置') }
|
||||
}
|
||||
const copyLink = async (row, kind) => {
|
||||
try {
|
||||
await copyLiveText(getRoomLinks(row.id)[kind])
|
||||
ElMessage.success(kind === 'studio' ? '主播链接已复制' : '观众链接已复制')
|
||||
} catch {
|
||||
showLinks(row)
|
||||
ElMessage.warning('复制失败,请在链接窗口中选中地址复制')
|
||||
}
|
||||
}
|
||||
const openLink = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
||||
const openStudio = (row) => {
|
||||
try { openLink(getRoomLinks(row.id).studio) }
|
||||
catch { ElMessage.error('无法生成主播链接,请检查 PC 站点地址配置') }
|
||||
}
|
||||
const openDiagnostics = (row) => window.open(`${window.location.origin}${window.location.pathname}#/live/diagnostics/${row.id}`, '_blank', 'noopener')
|
||||
const handleRoomAction = (command, row) => command === 'share' ? copyLink(row) : removeRow(row)
|
||||
const handleRoomAction = (command, row) => {
|
||||
if (command === 'studio' || command === 'viewer') return copyLink(row, command)
|
||||
if (command === 'links') return showLinks(row)
|
||||
if (command === 'delete') return removeRow(row)
|
||||
}
|
||||
onMounted(() => { loadOptions(); load() })
|
||||
</script>
|
||||
|
||||
@@ -94,4 +142,7 @@ onMounted(() => { loadOptions(); load() })
|
||||
.anchor-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }.anchor-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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; } }
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getAssetUser, getGiftSends, getGifts, getRoom } from '../api/live'
|
||||
import { getAssetUser, getGiftSends, getGifts, getRoomDetail } from '../api/live'
|
||||
import { ElMessage } from 'element-plus'
|
||||
defineOptions({ name: 'LiveSends' })
|
||||
const query = reactive({ roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }), dateRange = ref([]), gifts = ref([]), rows = ref([]), page = ref(1), pageSize = ref(10), total = ref(0), loading = ref(false), summary = ref({ count: 0, diamonds: 0, amountCents: 0 })
|
||||
@@ -31,7 +31,7 @@ const loadGifts = async () => { const res = await getGifts({ page: 1, pageSize:
|
||||
const search = () => { page.value = 1; load() }, reset = () => { Object.assign(query, { roomName: '', anchorName: '', userKeyword: '', giftId: undefined, orderBy: 'created_at_desc' }); dateRange.value = []; search() }
|
||||
onMounted(() => { loadGifts(); load() })
|
||||
const openUser = async (id) => { linkedRoom.value = null; const res = await getAssetUser(id); if (res.code === 0) { linkedUser.value = res.data; drawer.value = true } }
|
||||
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoom(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
|
||||
const openRoom = async (id) => { linkedUser.value = null; const res = await getRoomDetail(id); if (res.code === 0) { linkedRoom.value = res.data; drawer.value = true } else ElMessage.error(res.msg || '直播间不存在') }
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@use '../styles/live.scss';
|
||||
|
||||
@@ -5,33 +5,91 @@
|
||||
<div v-if="!media" class="studio-placeholder"><el-icon><VideoCamera /></el-icon><strong>摄像头预览将在这里显示</strong></div>
|
||||
<div class="studio-status"><span :class="`is-${rtc.state.value}`" />{{ statusText }}</div>
|
||||
<header><button title="返回" @click="goBack"><el-icon><ArrowLeft /></el-icon></button><div><strong>{{ room?.name || '主播开播台' }}</strong><span>{{ room?.streamId }}</span></div></header>
|
||||
<footer><button :class="{ off: !audioEnabled }" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live" class="go-live" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">结束直播</button></footer>
|
||||
<footer><button :class="{ off: !audioEnabled }" :disabled="!media" title="麦克风" @click="toggleAudio"><el-icon><Microphone v-if="audioEnabled" /><Mute v-else /></el-icon></button><button :class="{ off: !videoEnabled }" :disabled="!media" title="摄像头" @click="toggleVideo"><el-icon><VideoCamera v-if="videoEnabled" /><VideoPause v-else /></el-icon></button><button v-if="!live && !starting" class="go-live" :disabled="loading || Boolean(permissionError) || !media" @click="startLive">开始直播</button><button v-else class="end-live" @click="stopLive">{{ live ? '结束直播' : '取消连接' }}</button></footer>
|
||||
</section>
|
||||
<aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ live ? '直播中' : '未开播' }}</strong></div></div></section></aside>
|
||||
<aside class="studio-panel"><div class="studio-room"><el-image :src="getUrl(room?.coverUrl)" fit="cover" /><div><h1>{{ room?.name }}</h1><p>{{ room?.intro || '暂无直播简介' }}</p><el-tag>{{ room?.categoryName }}</el-tag></div></div><el-alert v-if="permissionError" :title="permissionError" type="error" :closable="false" show-icon /><section><h2>直播设备</h2><el-form label-position="top"><el-form-item label="摄像头"><el-select v-model="cameraId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择摄像头" @change="refreshMedia"><el-option v-for="item in cameras" :key="item.deviceId" :label="item.label || '摄像头'" :value="item.deviceId" /></el-select></el-form-item><el-form-item label="麦克风"><el-select v-model="microphoneId" :disabled="!canPublish || live || starting || mediaLoading" placeholder="选择麦克风" @change="refreshMedia"><el-option v-for="item in microphones" :key="item.deviceId" :label="item.label || '麦克风'" :value="item.deviceId" /></el-select></el-form-item></el-form><el-button v-if="mediaError" :disabled="!canPublish" :loading="mediaLoading" @click="refreshMedia">重试设备</el-button></section><section><h2>实时状态</h2><div class="studio-metrics"><div><span>观看人数</span><strong>{{ rtc.viewerCount.value }}</strong></div><div><span>推流状态</span><strong>{{ statusText }}</strong></div></div></section></aside>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { ArrowLeft, Microphone, Mute, VideoCamera, VideoPause } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import { getRoom } from '../api/live'
|
||||
import { getRoomDetail } from '../api/live'
|
||||
import { useLiveRTC } from '../composables/useLiveRTC'
|
||||
defineOptions({ name: 'LiveStudio' })
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = ref(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false), permissionError = ref('')
|
||||
const route = useRoute(), router = useRouter(), userStore = useUserStore(), rtc = useLiveRTC(), room = ref(null), preview = ref(), media = shallowRef(null), cameras = ref([]), microphones = ref([]), cameraId = ref(''), microphoneId = ref(''), audioEnabled = ref(true), videoEnabled = ref(true), live = ref(false)
|
||||
const loading = ref(true), starting = ref(false), mediaLoading = ref(false), roomError = ref(''), mediaError = ref('')
|
||||
let disposed = false
|
||||
const canPublish = computed(() => Boolean(!loading.value && !roomError.value && room.value?.enabled && !room.value.streamUrl && userStore.userInfo?.ID && Number(room.value.anchorUserId) === Number(userStore.userInfo.ID)))
|
||||
const permissionError = computed(() => {
|
||||
if (roomError.value) return roomError.value
|
||||
if (loading.value) return ''
|
||||
if (!room.value?.enabled) return '该直播间已停用,不能开播。'
|
||||
if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) return '当前账号不是该直播间绑定的主播。'
|
||||
if (room.value.streamUrl) return '该直播间使用外部直播流,请通过已配置的推流端开播。'
|
||||
return mediaError.value
|
||||
})
|
||||
const statusText = computed(() => ({ idle: '准备开播', connecting: '连接中', negotiating: '建立推流', connected: '直播中', error: rtc.message.value, closed: '连接已关闭' }[rtc.state.value] || rtc.message.value || '准备开播'))
|
||||
const enumerate = async () => { const devices = await navigator.mediaDevices.enumerateDevices(); cameras.value = devices.filter((item) => item.kind === 'videoinput'); microphones.value = devices.filter((item) => item.kind === 'audioinput'); if (!cameraId.value) cameraId.value = cameras.value[0]?.deviceId || ''; if (!microphoneId.value) microphoneId.value = microphones.value[0]?.deviceId || '' }
|
||||
const refreshMedia = async () => { if (live.value) return ElMessage.warning('直播中不能切换设备'); media.value?.getTracks().forEach((track) => track.stop()); try { media.value = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true }); await nextTick(); if (preview.value) preview.value.srcObject = media.value; await enumerate() } catch (error) { permissionError.value = '无法使用摄像头或麦克风,请检查浏览器权限和设备占用情况。'; throw error } }
|
||||
const startLive = async () => { if (permissionError.value || !media.value) return ElMessage.warning('请先允许使用摄像头和麦克风'); if (Number(room.value?.anchorUserId) !== Number(userStore.userInfo?.ID)) return ElMessage.error('只有该直播间绑定的主播可以开播'); try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }); live.value = true; ElMessage.success('正在开始直播') } catch { live.value = false } }
|
||||
const stopLive = async () => { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }); rtc.disconnect(); live.value = false; ElMessage.success('直播已结束') }
|
||||
const refreshMedia = async () => {
|
||||
if (!canPublish.value || live.value || starting.value || mediaLoading.value) return
|
||||
mediaError.value = ''
|
||||
media.value?.getTracks().forEach((track) => track.stop())
|
||||
media.value = null
|
||||
mediaLoading.value = true
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error('media unavailable')
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: cameraId.value ? { deviceId: { exact: cameraId.value }, width: { ideal: 1280 }, height: { ideal: 720 } } : true, audio: microphoneId.value ? { deviceId: { exact: microphoneId.value } } : true })
|
||||
if (disposed || !canPublish.value) { stream.getTracks().forEach((track) => track.stop()); return }
|
||||
media.value = stream
|
||||
stream.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value })
|
||||
stream.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value })
|
||||
await nextTick()
|
||||
if (preview.value) preview.value.srcObject = media.value
|
||||
await enumerate()
|
||||
} catch {
|
||||
mediaError.value = '无法使用摄像头或麦克风,请检查 HTTPS、浏览器权限和设备占用情况。'
|
||||
} finally { mediaLoading.value = false }
|
||||
}
|
||||
const startLive = async () => {
|
||||
if (!canPublish.value || permissionError.value || !media.value || starting.value) return
|
||||
starting.value = true
|
||||
try { await rtc.connect({ mode: 'publisher', id: room.value.streamId, media: media.value }) }
|
||||
catch { starting.value = false; live.value = false }
|
||||
}
|
||||
const stopLive = async () => {
|
||||
try { await ElMessageBox.confirm('确认结束本场直播吗?', '结束直播', { type: 'warning' }) }
|
||||
catch { return }
|
||||
rtc.disconnect()
|
||||
starting.value = false
|
||||
live.value = false
|
||||
ElMessage.success('直播已结束')
|
||||
}
|
||||
const toggleAudio = () => { audioEnabled.value = !audioEnabled.value; media.value?.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value }) }, toggleVideo = () => { videoEnabled.value = !videoEnabled.value; media.value?.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value }) }
|
||||
const goBack = () => router.back()
|
||||
onMounted(async () => { const res = await getRoom(Number(route.params.id)); if (res.code !== 0) return; room.value = res.data; if (!room.value.enabled) permissionError.value = '该直播间已停用,不能开播。'; else if (Number(room.value.anchorUserId) !== Number(userStore.userInfo?.ID)) permissionError.value = '当前账号不是该直播间绑定的主播。'; await refreshMedia().catch(() => {}) })
|
||||
onBeforeUnmount(() => media.value?.getTracks().forEach((track) => track.stop()))
|
||||
watch(() => rtc.state.value, (state) => {
|
||||
if (state === 'connected') { live.value = true; starting.value = false }
|
||||
else if (['idle', 'error', 'closed', 'kicked', 'auth-required'].includes(state)) { live.value = false; starting.value = false }
|
||||
})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (userStore.token && !userStore.userInfo?.ID) await userStore.GetUserInfo()
|
||||
if (!userStore.userInfo?.ID) { roomError.value = '账号信息加载失败,请重新登录。'; return }
|
||||
const res = await getRoomDetail(Number(route.params.id))
|
||||
if (disposed) return
|
||||
if (res.code !== 0 || !res.data) { roomError.value = res.msg || '直播间信息加载失败。'; return }
|
||||
room.value = res.data
|
||||
} catch { roomError.value = '直播间信息加载失败,请稍后重试。' }
|
||||
finally { loading.value = false }
|
||||
if (!disposed && canPublish.value) await refreshMedia()
|
||||
})
|
||||
onBeforeUnmount(() => { disposed = true; rtc.disconnect(); media.value?.getTracks().forEach((track) => track.stop()) })
|
||||
</script>
|
||||
<style scoped>
|
||||
.studio-page { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; background: #111116; color: #fff; }.studio-stage { position: relative; min-width: 0; min-height: 100vh; overflow: hidden; }.studio-stage video { width: 100%; height: 100vh; background: #09090c; object-fit: contain; }.studio-stage header { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 12px; padding: 18px 22px; background: linear-gradient(rgb(0 0 0 / 70%), transparent); }.studio-stage header button,.studio-stage footer button { display: grid; width: 42px; height: 42px; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / 15%); color: #fff; cursor: pointer; }.studio-stage header div { display: grid; gap: 3px; }.studio-stage header span { color: rgb(255 255 255 / 60%); font-size: 11px; }.studio-placeholder { position: absolute; inset: 0; display: grid; place-content: center; gap: 12px; color: rgb(255 255 255 / 55%); text-align: center; }.studio-placeholder .el-icon { margin: auto; font-size: 50px; }.studio-status { position: absolute; top: 22px; right: 22px; display: flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 4px; background: rgb(0 0 0 / 50%); font-size: 12px; }.studio-status span { width: 8px; height: 8px; border-radius: 50%; background: #999; }.studio-status .is-connected { background: #ff417f; box-shadow: 0 0 0 4px rgb(255 65 127 / 20%); }.studio-stage footer { position: absolute; bottom: 25px; left: 50%; display: flex; align-items: center; gap: 12px; transform: translateX(-50%); }.studio-stage footer button.off { background: #a83842; }.studio-stage footer .go-live,.studio-stage footer .end-live { width: auto; min-width: 130px; padding: 0 24px; border-radius: 22px; background: #ec5b8d; font-weight: 600; }.studio-stage footer .end-live { background: #d94a53; }.studio-panel { min-height: 100vh; padding: 22px; background: #fff; color: #2a2025; }.studio-room { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 14px; padding-bottom: 20px; border-bottom: 1px solid #f0e4e8; }.studio-room .el-image { width: 100px; height: 72px; border-radius: 5px; }.studio-room h1 { margin: 0; font-size: 17px; letter-spacing: 0; }.studio-room p { display: -webkit-box; overflow: hidden; margin: 7px 0; color: #807176; font-size: 12px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }.studio-panel section { padding: 20px 0; border-bottom: 1px solid #f0e4e8; }.studio-panel h2 { margin: 0 0 14px; font-size: 14px; }.studio-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.studio-metrics div { padding: 14px; background: #fff4f8; }.studio-metrics span { display: block; color: #9a7d87; font-size: 11px; }.studio-metrics strong { display: block; margin-top: 5px; color: #d74679; font-size: 19px; }
|
||||
@media (max-width: 850px) { .studio-page { display: block; }.studio-stage,.studio-stage video { min-height: 62vh; height: 62vh; }.studio-panel { min-height: 38vh; }.studio-stage footer { bottom: 18px; } }
|
||||
.studio-stage footer button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user