明确comand_id语义

This commit is contained in:
zhx 2026-05-12 20:07:49 +08:00
parent 441237fab7
commit 8955a0d118
34 changed files with 342 additions and 503 deletions

View File

@ -44,7 +44,7 @@
- 外部 HTTP JSON 入口在 `gateway-service`
- `gateway-service``/api/v1` 业务响应必须使用 `{code,message,request_id,data}` envelopehealthcheck 不套业务响应格式。
- `request_id` 是链路追踪字段,必须透传到内部 gRPC `RequestMeta.request_id`;不要把它当幂等键使用。
- `request_id`后端生成的链路追踪字段,必须透传到内部 gRPC `RequestMeta.request_id`;不要把它当幂等键使用。
- 外部实时消息入口在腾讯云 IM SDK本仓库只提供 `/api/v1/im/usersig` 签发登录票据。
- 内部服务通信统一 gRPC + protobufGo 代码通过 `hyapp.local/api/proto/...` 引用生成包。
- 当前处于开发阶段不保留历史协议Proto/API/DB 可以按当前事实直接调整。

View File

@ -101,7 +101,7 @@ Authorization: Bearer <access_token>
- 客户端先经 gateway 调 `JoinRoom`,让用户进入 `room-service` presence。
- 客户端再用腾讯云 IM SDK 登录并加入 `room_id` 对应的群组。
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id``room-service` 由该 actor 收敛 owner 和默认 host外部 CreateRoom 请求体不接收 `owner_user_id``host_user_id`
- 房间 HTTP 命令支持请求体 `command_id` 作为幂等键;`request_id` 只做链路追踪
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等
- `room-service``CreateRoom` 时通过腾讯云 IM REST API 创建同名群组。
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后,通过腾讯云 IM REST API 发送房间系统自定义消息。
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission`
@ -180,7 +180,7 @@ services/cron-service/ background scheduling and owner-service batch trigger
## Current Limits
- 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
- 房间命令未传 `command_id`gateway 会自动生成新值;客户端 HTTP 重试同一业务动作时必须复用原 `command_id` 才能命中 room-service command log 幂等
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id``X-Request-ID` 不需要也不应该由前端传入
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`
- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。
- `room-service` App 已启动 `room_outbox` 补偿 worker该状态只表示腾讯云 IM 系统消息补偿结果activity/audit 后续必须使用独立消费位点。

View File

@ -25,7 +25,7 @@ VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
服务职责:
- `gateway-service`:提供 App HTTP API做鉴权、参数校验、request_id/command_id 透传,调用 `wallet-service`
- `gateway-service`:提供 App HTTP API做鉴权、参数校验、生成 `request_id`、透传客户端 `command_id`,调用 `wallet-service`
- `wallet-service`:拥有 VIP 等级配置、用户 VIP 状态、VIP 购买订单、金币扣费、资源组发放、VIP 事件 outbox。
- `user-service`:只在用户资料聚合时读取 VIP 摘要,不拥有 VIP 状态。
- `room-service`:需要展示 VIP 标识时读取用户资料/VIP 投影,不参与 VIP 购买主链路。

View File

@ -2350,7 +2350,7 @@
function headersFor(source, withJson = true) {
const c = cfg();
const headers = { 'X-Request-ID': commandId('req') };
const headers = {};
if (withJson) headers['Content-Type'] = 'application/json';
if (c.appCode) headers['X-App-Code'] = c.appCode;
const token = source === 'admin' ? c.adminToken : c.accessToken;
@ -2459,7 +2459,7 @@
if (p.endsWith('/users/me/country/change')) return { country: c.country || 'US' };
if (p.endsWith('/users/me/display-id/change')) return { new_display_user_id: c.displayUserId };
if (p.endsWith('/users/me/display-id/pretty/apply')) return { pretty_display_user_id: c.displayUserId };
if (p.endsWith('/rooms/create')) return { mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
if (p.endsWith('/rooms/create')) return { command_id: commandId('cmd_create'), mode: 'voice', room_name: 'Smoke Room', room_avatar: '', room_description: 'local smoke room' };
if (p.endsWith('/rooms/join')) return { command_id: commandId('cmd_join'), room_id: c.roomId, role: 'audience' };
if (p.endsWith('/rooms/heartbeat')) return { command_id: commandId('cmd_heartbeat'), room_id: c.roomId };
if (p.endsWith('/rooms/leave')) return { command_id: commandId('cmd_leave'), room_id: c.roomId };

View File

@ -536,6 +536,7 @@ definitions:
$ref: "#/definitions/RequestMeta"
command_id:
type: string
description: 客户端或操作端前端按单次写动作生成的幂等命令 ID同一动作重试必须复用。
message_type:
type: string
enum:

View File

@ -4,7 +4,7 @@ info:
version: "1.0.0"
description: |
gateway-service 对外暴露 HTTP + JSON API。
`/api/v1/**` 业务接口统一返回 `code/message/request_id/data` 外壳,并通过 `X-Request-ID` 贯穿 HTTP 响应和内部 gRPC RequestMeta。
`/api/v1/**` 业务接口统一返回 `code/message/request_id/data` 外壳`request_id` 和响应 `X-Request-ID` 均由服务端生成,并透传到内部 gRPC RequestMeta。
`/healthz/**` 是健康检查接口,不使用业务响应外壳。
host: localhost:13000
basePath: /
@ -52,12 +52,6 @@ securityDefinitions:
in: header
description: 使用 `Bearer <JWT>`JWT 使用 HS256claims 中的 `user_id` 是数值型用户 ID 的字符串,避免 64 位 ID 精度丢失。
parameters:
RequestIDHeader:
name: X-Request-ID
in: header
required: false
type: string
description: 可选追踪 ID未传时 gateway 自动生成,并返回在响应 header 和 JSON `request_id`。该字段不是幂等键;需要幂等的写命令使用各自命令 ID其中创建房间由 gateway 生成内部 `command_id`。
DisplayUserIDPath:
name: display_user_id
in: path
@ -187,7 +181,6 @@ paths:
description: 账号就是用户当前有效短号。
operationId: loginAccountPassword
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -215,7 +208,6 @@ paths:
summary: 三方登录或注册
operationId: loginThirdParty
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -245,7 +237,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -275,7 +266,6 @@ paths:
summary: 刷新 token
operationId: refreshToken
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -303,7 +293,6 @@ paths:
summary: 登出并失效 refresh session
operationId: logout
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -330,7 +319,6 @@ paths:
operationId: listRegistrationCountries
description: 公开读接口,只返回 user-service 判定的 `enabled=true` 国家,并按 `sort_order,country_code` 排序。
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.countries` 返回当前开放国家。
@ -346,7 +334,6 @@ paths:
operationId: getAppBootstrap
description: 公开读接口只返回版本、维护、feature flags、底部 tab 和配置版本号,不返回 banner、礼物或资源大列表。
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回 App 启动状态机需要的轻量配置摘要。
@ -360,7 +347,6 @@ paths:
operationId: listAppH5Links
description: 公开读接口,返回后台管理系统 APP配置/H5配置 维护的 H5 地址。未配置 URL 的入口仍按固定 key 返回空字符串。
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.items` 返回固定 H5 入口集合。
@ -377,7 +363,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 签发成功,`data.user_id` 使用内部长 `user_id` 的十进制字符串。
@ -401,7 +386,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -442,7 +426,6 @@ paths:
腾讯 RTC 控制台配置的房间/媒体事件回调入口。gateway 校验 `SdkAppId` 与 `Sign` 后,只消费房间退出 `104`、开始推送音频 `203`、停止推送音频 `204`。
该接口不使用业务 envelope成功或可忽略业务错误返回 `{"code":0}`。
parameters:
- $ref: "#/parameters/RequestIDHeader"
- $ref: "#/parameters/TencentRTCSDKAppIDHeader"
- $ref: "#/parameters/TencentRTCSignHeader"
- name: app_code
@ -484,7 +467,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: file
in: formData
required: true
@ -513,7 +495,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: file
in: formData
required: true
@ -540,7 +521,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -568,7 +548,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -623,7 +602,6 @@ paths:
summary: 通过当前展示短号解析用户
operationId: resolveDisplayUserID
parameters:
- $ref: "#/parameters/RequestIDHeader"
- $ref: "#/parameters/DisplayUserIDPath"
responses:
"200":
@ -648,7 +626,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: user_ids
in: query
required: true
@ -676,7 +653,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回当前用户短号状态。
@ -700,7 +676,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回当前用户身份开关。
@ -722,7 +697,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回我的页首屏渲染摘要。
@ -748,7 +722,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -779,7 +752,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -810,7 +782,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -842,7 +813,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -874,7 +844,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -907,7 +876,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: tab
in: query
required: false
@ -956,7 +924,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: tab
in: query
required: true
@ -997,7 +964,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.has_room=false` 表示当前用户尚未创建房间;有房间时 `data.room.im_group_id` 显式返回腾讯 IM 房间群 ID。
@ -1019,7 +985,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.has_current_room=false` 表示无需恢复。
@ -1041,7 +1006,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: room_id
in: query
required: true
@ -1073,12 +1037,11 @@ paths:
operationId: createRoom
description: |
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`room-service 由该 actor 确定 owner 和默认 host。
请求体不接收 `room_id`、`command_id`、`owner_user_id` 或 `host_user_id`gateway 生成内部 room_id 和 command_id。
请求体不接收 `room_id`、`owner_user_id` 或 `host_user_id``command_id` 由客户端按创建动作生成,gateway 生成内部 room_id。
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1114,7 +1077,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1145,7 +1107,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1180,7 +1141,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1214,7 +1174,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1248,7 +1207,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1282,7 +1240,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1316,7 +1273,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1350,7 +1306,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1384,7 +1339,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1418,7 +1372,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1452,7 +1405,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1486,7 +1438,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1520,7 +1471,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1554,7 +1504,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1588,7 +1537,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1622,7 +1570,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1654,7 +1601,6 @@ paths:
summary: App 资源列表
operationId: listResources
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: resource_type
in: query
required: false
@ -1699,7 +1645,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: resource_type
in: query
required: false
@ -1739,7 +1684,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -1772,7 +1716,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: scene
in: query
required: true
@ -1809,7 +1752,6 @@ paths:
summary: App 资源组详情
operationId: getResourceGroup
parameters:
- $ref: "#/parameters/RequestIDHeader"
- $ref: "#/parameters/ResourceGroupIDPath"
responses:
"200":
@ -1829,7 +1771,6 @@ paths:
summary: App 礼物列表
operationId: listGifts
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: keyword
in: query
required: false
@ -1868,7 +1809,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: resource_type
in: query
required: false
@ -1896,7 +1836,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- $ref: "#/parameters/ResourceIDPath"
- name: body
in: body
@ -1928,7 +1867,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功。
@ -1950,7 +1888,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: section
in: query
required: true
@ -1987,7 +1924,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -2015,7 +1951,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: message_id
in: path
required: true
@ -2043,7 +1978,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: message_id
in: path
required: true
@ -2070,7 +2004,6 @@ paths:
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
@ -3351,7 +3284,7 @@ definitions:
properties:
command_id:
type: string
description: 客户端幂等命令 ID同一业务重试必须复用。
description: 客户端按用户动作生成的幂等命令 ID同一动作重试必须复用。
target_user_id:
type: string
description: 目标用户 IDgateway 会校验用户存在且 active。
@ -3567,7 +3500,7 @@ definitions:
properties:
command_id:
type: string
description: 钱包命令幂等键;客户端重试同一转账必须复用该值
description: 钱包命令幂等键;客户端按用户转账动作生成,同一动作重试必须复用
target_user_id:
type: integer
format: int64
@ -3622,9 +3555,13 @@ definitions:
CreateRoomRequest:
type: object
required:
- command_id
- mode
- room_name
properties:
command_id:
type: string
description: 房间创建命令幂等键;客户端按创建房间动作生成,同一动作的 HTTP 重试必须复用。
seat_count:
type: integer
format: int32
@ -3657,6 +3594,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用。
room_name:
type: string
minLength: 1
@ -3687,7 +3625,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
role:
type: string
RoomHeartbeatRequest:
@ -3699,7 +3637,7 @@ definitions:
type: string
command_id:
type: string
description: 心跳命令幂等键;建议客户端每次心跳生成新值HTTP 重试同一次心跳复用该值
description: 心跳命令幂等键;客户端按单次心跳动作生成HTTP 重试同一次心跳必须复用
LeaveRoomRequest:
type: object
properties:
@ -3707,7 +3645,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
MicUpRequest:
type: object
properties:
@ -3715,7 +3653,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
seat_no:
type: integer
format: int32
@ -3726,7 +3664,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3740,7 +3678,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3759,7 +3697,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一确认事件必须复用该值
description: 房间命令幂等键;客户端按单次确认事件生成HTTP 重试同一确认事件必须复用。
target_user_id:
type: integer
format: int64
@ -3782,7 +3720,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
seat_no:
type: integer
format: int32
@ -3795,7 +3733,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
enabled:
type: boolean
SetRoomAdminRequest:
@ -3805,7 +3743,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3818,7 +3756,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3829,7 +3767,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3842,7 +3780,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3853,7 +3791,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64
@ -3864,7 +3802,7 @@ definitions:
type: string
command_id:
type: string
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等
description: 房间命令幂等键;客户端按用户动作生成,同一动作的 HTTP 重试必须复用
target_user_id:
type: integer
format: int64

View File

@ -483,8 +483,10 @@ definitions:
properties:
request_id:
type: string
description: 后端生成的链路追踪 ID不参与幂等。
command_id:
type: string
description: 客户端按用户写动作生成的幂等命令 ID同一动作重试必须复用。
actor_user_id:
type: integer
format: int64

View File

@ -530,7 +530,7 @@ POST /api/v1/bd-leader/invitations/agency
POST /api/v1/wallet/coin-seller/transfer
```
App API 必须使用 `/api/v1` envelope。用户身份来自 gateway 鉴权,`request_id` 只做链路追踪,不做幂等键;业务命令必须使用客户端生成或服务端下发`command_id`
App API 必须使用 `/api/v1` envelope。用户身份来自 gateway 鉴权,`request_id` 由后端生成且只做链路追踪,不做幂等键;业务命令必须使用客户端按用户动作生成的 `command_id`
### Internal gRPC Sketch

View File

@ -75,7 +75,7 @@
| `payment_ref` | 文本 | `usdt_purchase` 必填,建议填链上 tx hash、收款单号或财务凭证号 |
| `evidence_ref` | 文本 | 凭证附件或后台上传后的文件 ID首版建议两种类型都必填 |
| `reason` | 文本 | 必填,写清楚进货或补偿原因 |
| `command_id` | 文本 | 前端生成或 admin-server 生成,用于幂等 |
| `command_id` | 文本 | 操作端前端按单次授信动作生成,同一动作重试必须复用 |
金额存储规则:
@ -419,4 +419,3 @@ USDT 进货金额属于财务口径,不能只放前端日志。
- 相同 `command_id` 但 payload 不同返回幂等冲突。
- wallet 事务失败时不写进货记录、不改余额。
- admin 审计记录包含 transaction_id、类型、金币数、USDT 金额和操作人。

View File

@ -1,345 +1,144 @@
# Room Outbox Compensation Worker Development Guide
# Room Outbox Worker
本文档定义 `room-service` 的 outbox 补偿 worker 如何接入进程生命周期、如何配置、如何重试,以及如何验证腾讯云 IM 同步失败后可由 `room_outbox` 补投。目标是收口 Phase 1 的房间系统消息可靠性,不扩展活动消费和 MQ 架构
本文档记录当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 outbox腾讯云 IM、activity-service 等外部投递全部由 outbox worker 异步执行
## Scope
## 边界
本阶段只做:
| Item | Result |
| 项目 | 当前规则 |
| --- | --- |
| App 生命周期接入 | `room-service` 启动时启动 outbox worker下线时先停止扫描新批次再关闭 gRPC/MySQL/Redis |
| worker 配置 | 支持启停、扫描间隔、批量大小、单条投递超时和固定间隔重试策略 |
| 腾讯云 IM 补偿 | 同步 `PublishRoomEvent` 失败不回滚房间状态pending outbox 后续补投到腾讯云 IM |
| 可验证测试 | 单测覆盖同步失败后状态已提交、outbox pending、worker 成功补投并标记 delivered |
| 主链路 | 不同步调用腾讯云 IM不同步投递 activity-service |
| 持久化 | 命令变更、command log、room_outbox 在同一提交链路内完成 |
| worker | 读取 `room_outbox`,投递到配置的 outbox publisher fanout |
| 腾讯云 IM | `RoomCreated` 由 worker 确保群组存在,其他房间事件由 worker 发送群系统消息 |
| activity-service | 由 room outbox worker 异步投递,失败进入 room_outbox 重试 |
| 幂等 | 房间命令靠 `command_id`outbox 投递靠 `event_id` |
本阶段不做:
不做的事
| Not Included | Reason |
| 不做 | 原因 |
| --- | --- |
| activity-service 消费主循环 | 活动消费需要独立消费位点,不能复用 IM 补偿状态 |
| MQ/Redis Stream | 当前 MySQL outbox 已能满足 Phase 1 补偿 |
| 精确一次投递 | 腾讯云 IM 和进程崩溃窗口只能保证 at-least-once客户端按 `event_id` 去重 |
| 多 sink 状态表 | 先把腾讯云 IM 补偿闭环跑通activity/audit 后续新增自己的 consumption 表 |
| 指数退避和 poison 队列 | 当前 `room_outbox` 没有 `next_retry_at_ms` 或 poison 状态V1 用固定间隔重试 |
| 在 HTTP/gRPC 请求里等待 IM REST | 外部慢调用会拖住房间串行命令队列 |
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox不属于房间权威状态 |
| 直接用 Redis 做 outbox | MySQL 是恢复和补偿的持久来源 |
| 精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once消费端必须按 `event_id` 去重 |
## Current State
当前代码已有基础能力:
| Component | Current Fact |
| --- | --- |
| `room-service/internal/room/service/pipeline.go` | 命令成功后先写 command log/outbox再尝试同步 `PublishRoomEvent`;同步成功 best-effort 标记 `delivered`,同步失败不回滚状态 |
| `room-service/internal/room/service/outbox_worker.go` | 已有 `RunOutboxWorker(ctx, options)` 持续循环和 `ProcessPendingOutbox(ctx, options)` 单批处理 |
| `room-service/internal/storage/mysql/repository.go` | 已有 `ClaimPendingOutbox``MarkOutboxDelivered``MarkOutboxFailed` |
| `room-service/internal/integration/tencent_im.go` | 已能把 `EventEnvelope` 转换成腾讯云 IM 房间系统事件 |
| `room-service/internal/app/app.go` | 已在进程生命周期中启动 presence stale worker 和 outbox worker下线时先停 worker 再关闭 gRPC/MySQL/Redis |
当前风险:
- 代码接入前已经残留的 `pending` 事件仍会被补投V1 接受 at-least-once客户端按 `event_id` 去重。
- `room_outbox.status` 统一为 `pending/delivering/delivered/retryable/failed`,不再使用旧的 `published` 命名。
- 多个 room-service 实例通过 `ClaimPendingOutbox` 抢占同一批事件;未过期的 `delivering` 记录不会被其他 worker 重复抢占。
## Target Semantics
房间命令成功后的语义:
## 写命令语义
```text
Room Cell command applied
-> write command log
-> write room_outbox status=pending
-> persist snapshot if needed
-> replace in-memory RoomState
-> try synchronous PublishRoomEvent
-> if sync publish success, best-effort mark that event delivered
-> if sync publish fails, keep pending for worker
gateway -> room-service command
-> idempotency check
-> ensure Redis lease and Room Cell
-> mutate cloned RoomState
-> optional wallet debit before room gift state is committed
-> SaveMutation(command log + room_outbox + room meta)
-> snapshot when version hits snapshot_every_n
-> update list/presence projections best effort
-> return response
outbox worker
-> claim pending/retryable rows
-> publish to composite outbox publisher
-> delivered / retryable / failed
```
补偿 worker 语义:
主链路成功只表示房间状态已经落库;不表示腾讯云 IM 或 activity-service 已经完成投递。客户端实时表现应以房间响应、房间 snapshot 和 IM 系统消息共同补偿。
```text
claim room_outbox where status in (pending, retryable)
-> status=delivering, set worker_id and lock_until_ms
-> publish one EventEnvelope to configured OutboxPublisher
-> success: status=delivered, last_error=NULL
-> failure: status=retryable, retry_count=retry_count+1, last_error=<short error>
-> next scan retries failed retryable records
```
## 数据状态
重要边界
`room_outbox.status` 使用:
- worker 不执行房间命令,不修改 Room Cell不写 command log不写 snapshot。
- worker 只处理已经持久化的 outbox envelope。
- worker 投递失败不能影响 `room-service` ready腾讯云 IM 抖动时 room 状态服务仍应接收房间命令。
- `request_id` 仍然只做追踪outbox 去重键是 `event_id`,房间命令幂等键是 `command_id`
- `delivered` 在本阶段表示腾讯云 IM 补偿通道已处理,不表示 activity/audit 已消费。
| 状态 | 含义 |
| --- | --- |
| `pending` | 已落库,尚未被 worker 处理 |
| `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 |
| `delivered` | 所有配置的 outbox publisher 已处理成功 |
| `retryable` | 上次投递失败,等待 `next_retry_at_ms` 后重试 |
| `failed` | 达到最大重试次数,进入死信,需要人工处理或后台工具重放 |
## Configuration
旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态,新链路只写上表状态。
`services/room-service/internal/config/config.go` 增加:
## 配置
```go
type OutboxWorkerConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
RetryStrategy string `yaml:"retry_strategy"`
}
```
挂到 room-service 配置:
```go
type Config struct {
// existing fields...
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
}
```
默认值:
| Field | Default | Meaning |
| --- | ---: | --- |
| `enabled` | `true` | 本地和生产默认启动补偿 worker测试可显式关闭 |
| `poll_interval` | `1s` | 每轮扫描 pending outbox 的间隔 |
| `batch_size` | `100` | 每轮最多处理的 outbox 记录数 |
| `publish_timeout` | `3s` | 单条事件调用腾讯云 IM 的最大时间 |
| `retry_strategy` | `fixed_interval` | V1 只支持固定间隔重试,失败记录下一轮继续扫描 |
配置文件示例:
`services/room-service/configs/*.yaml` 当前配置:
```yaml
snapshot_every_n: 10
outbox_worker:
enabled: true
poll_interval: "1s"
batch_size: 100
publish_timeout: "3s"
retry_strategy: "fixed_interval"
retry_strategy: "exponential_backoff"
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"
```
配置规则:
- `poll_interval <= 0` 时使用默认值,不能导致 busy loop。
- `batch_size <= 0` 时使用默认值,不能全表扫描。
- `publish_timeout <= 0` 时使用默认值,不能让单条外部调用无限阻塞 worker。
- `retry_strategy` 只接受空值或 `fixed_interval`;未知值启动失败,避免配置写错后静默改变投递语义。
- `config.yaml``config.docker.yaml``config.tencent.example.yaml` 必须同步增加该配置。
## App Lifecycle
`room-service/internal/app.App` 需要新增 worker 生命周期字段:
```go
type App struct {
// existing fields...
workerCancel context.CancelFunc
workerWG sync.WaitGroup
}
```
启动顺序:
```text
1. 初始化 MySQL repository、Redis directory、wallet client、Tencent IM publisher
2. 创建 room service 和 gRPC server
3. Run 标记 gRPC serving
4. 创建 worker context
5. 启动 presence stale worker
6. 如果 outbox_worker.enabled=true启动 outbox worker
7. 进入 grpcServer.Serve
```
关闭顺序:
```text
1. MarkDraining
2. cancel worker context停止扫描新 outbox 批次
3. worker 当前正在处理的单条事件受 publish_timeout 控制,结束后退出
4. workerWG.Wait 等待后台 worker 停止
5. GracefulStop gRPC等待已进入命令完成持久化
6. 关闭 wallet gRPC、MySQL、Redis
```
关闭规则:
- 不能在 worker 退出前关闭 MySQL否则可能无法标记投递结果。
- worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。
- 如果进程被强杀,已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后下次启动继续补偿。
## Worker API
`room/service` 增加持续循环方法:
```go
type OutboxWorkerOptions struct {
PollInterval time.Duration
BatchSize int
PublishTimeout time.Duration
RetryStrategy string
}
func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptions)
```
`RunOutboxWorker` 只负责循环、节流和退出:
```text
normalize options
process one batch immediately
start ticker
for each tick:
if ctx done: return
ProcessPendingOutbox(ctx, options)
```
`ProcessPendingOutbox` 建议改成 options 版本:
```go
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error
```
处理规则:
- 每轮调用 `repository.ClaimPendingOutbox(ctx, worker_id, batch_size, lock_until_ms)`,把 `pending/retryable` 事件抢占为 `delivering`
- 每条记录单独创建 `context.WithTimeout(ctx, publish_timeout)`
- `PublishOutboxEvent` 成功后调用 `MarkOutboxDelivered`
- `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed`
- 单条失败不阻断同批后续记录。
- `ClaimPendingOutbox``MarkOutboxDelivered/Failed` 失败属于本轮 worker 错误,记录后等待下一轮。
## Sync Publish Interaction
为了避免 worker 对同步成功事件重复补投,命令链路需要在同步成功后 best-effort 标记 outbox
```text
if result.applied && result.syncEvent != nil:
err := syncPublisher.PublishRoomEvent(ctx, *result.syncEvent)
if err == nil:
repository.MarkOutboxDelivered(ctx, result.syncEvent.EventID)
if err != nil:
keep room_outbox pending
```
约束:
- `MarkOutboxDelivered` 失败不能让已经成功提交的房间命令返回失败;最多造成后续重复投递。
- 同步成功但进程在标记 delivered 前崩溃时worker 后续可能重复投递;客户端必须用 `event_id` 去重。
- 对 `RoomCreated``RoomHeatChanged``RoomRankChanged` 这类不推 IM 的事件,`PublishOutboxEvent` 返回 no-op success 后可标记 `delivered`,避免长期 pending。
## Retry Strategy
V1 采用固定间隔无限重试:
| Case | Behavior |
| 字段 | 规则 |
| --- | --- |
| 腾讯云 IM 网络错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
| envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 |
| 不需要推 IM 的事件类型 | no-op success标记 `delivered` |
| 进程退出 | 已抢占未完成的记录保持 `delivering``lock_until_ms` 过期后可被重新抢占 |
| `snapshot_every_n` | 开发和生产默认 `10`;命令日志仍保证恢复,不能依赖每条命令快照 |
| `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop |
| `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 |
| `publish_timeout` | 单条 outbox 投递 deadline避免外部系统拖死 worker |
| `retry_strategy` | 当前为 `exponential_backoff` |
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久污染日志 |
| `initial_backoff` | 第一次失败后的最小退避 |
| `max_backoff` | 指数退避上限 |
为什么不加最大重试次数:
## 重试和死信
- 房间系统消息是状态变更的外部表现,不能因为达到次数就丢弃。
- 当前 schema 没有 poison 状态,贸然新增半成品状态会让恢复和运营查询复杂化。
- `retry_count``last_error` 已足够驱动告警;后续需要 poison 队列时再单独设计 `failed/next_retry_at_ms`
## Observability
首版至少打结构化日志字段:
| Field | Meaning |
| --- | --- |
| `worker` | 固定为 `room_outbox` |
| `node_id` | 当前 room-service 节点 |
| `event_id` | outbox 事件 ID |
| `event_type` | RoomUserJoined、RoomGiftSent 等 |
| `room_id` | 事件所属房间 |
| `retry_count` | 当前失败次数 |
| `status` | pending、delivering、delivered、retryable 或 failed |
| `error` | 最近一次失败原因,不能包含密钥或完整腾讯响应体 |
指标后续可补:
失败处理:
```text
room_outbox_pending_total
room_outbox_publish_success_total
room_outbox_publish_failure_total
room_outbox_publish_latency_ms
room_outbox_oldest_pending_age_ms
publish failed
-> retry_count + 1
-> if retry_count >= max_retry_count: status=failed
-> else: status=retryable, next_retry_at_ms=now+backoff
```
## Implementation Order
`failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。
按以下顺序做,避免 worker 一启动就制造重复消息:
## 投递对象
1. 扩展 room-service 配置结构和三份 YAML增加 `outbox_worker` 配置。
2. 给 `room/service` 增加 `OutboxWorkerOptions``RunOutboxWorker`
3. 调整 `ProcessPendingOutbox`,支持 `batch_size`、单条 `publish_timeout`、单条失败不中断批次。
4. 调整命令链路:同步 `PublishRoomEvent` 成功后 best-effort `MarkOutboxDelivered(event_id)`;同步失败保留初始 `pending`worker 投递失败转为 `retryable`
5. 在 `app.Run` 启动 outbox worker`app.Close` cancel 并等待 worker 退出。
6. 增加单测覆盖同步失败补偿、重试失败转为 `retryable`、worker cancel 退出。
7. 更新 README 当前限制,说明 outbox worker 已接入生命周期。
当前 `CompositeOutboxPublisher` fanout 到多个 publisher
## Acceptance Tests
必须覆盖:
| Test | Expected Result |
| Publisher | 行为 |
| --- | --- |
| 同步 IM 失败后命令成功 | `JoinRoom``SendGift` 返回成功RoomSnapshot 已更新 |
| 同步 IM 失败后 outbox pending | 对应 `event_id` 仍为 `pending``retry_count` 未因同步失败被提前增加 |
| worker 补投成功 | fake outbox publisher 收到同一 `event_id`repository 标记 `delivered` |
| worker 补投失败 | 记录转为 `retryable``retry_count+1``last_error` 有值 |
| 下一轮成功 | 第一次失败、第二次成功后状态变为 `delivered` |
| 同步 IM 成功 | 对应 `event_id` best-effort 标记 `delivered`worker 不再扫描该事件 |
| worker cancel | cancel 后不再扫描新批次,当前投递受 `publish_timeout` 约束后退出 |
| 非 IM 事件 | `RoomCreated/RoomHeatChanged/RoomRankChanged` 不发送腾讯 IM但会标记 `delivered` |
| Tencent IM | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity | 将 room outbox 事件投递给 `activity-service` 的消费入口 |
已有测试 `TestRoomOutboxCompensationWhenSyncPublishFails` 可以作为基础,但需要补齐持续 worker 和同步成功去重场景
fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都会进入重试,因此消费端必须幂等处理重复事件。
## Verification Commands
## 阶段耗时日志
代码完成后至少运行
每条房间写命令都会输出 `room_command_timing`
```bash
go test ./services/room-service/internal/room/service
go test ./services/room-service/internal/config
go test ./services/room-service/internal/app
go test ./...
```
| 字段 | 含义 |
| --- | --- |
| `idempotency_ms` | command log 幂等检查耗时 |
| `lease_ms` | Redis lease / Room Cell 装载耗时 |
| `wallet_debit_ms` | 送礼扣费耗时;非送礼为 0 |
| `save_mutation_ms` | command log + outbox + 房间 meta 提交耗时 |
| `snapshot_ms` | 快照持久化耗时;未触发为 0 |
| `projection_ms` | 列表和 presence 投影耗时 |
| `activity_ms` | 主链路 activity 同步耗时;当前固定为 0 |
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时;当前固定为 0 |
| `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 |
| `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 |
涉及配置和生命周期后运行:
排查接口卡顿时先看 `total_ms`,再看各阶段字段。送礼慢通常优先看 `wallet_debit_ms`,普通房间命令慢优先看 `save_mutation_ms``snapshot_ms``projection_ms`
```bash
docker compose config
docker compose build room-service
```
## 验收
如果要验证本地启动链路
必须满足:
```bash
docker compose up -d mysql redis wallet-service room-service
docker compose ps
docker compose down
```
## Done Definition
完成标准:
```text
room command committed
-> outbox persisted
-> sync Tencent IM publish fails
-> HTTP/gRPC command response still succeeds
-> outbox remains pending
-> app-level worker scans pending
-> worker publishes same event_id to Tencent IM publisher
-> outbox status becomes delivered
-> process shutdown stops worker before closing MySQL
```
该闭环稳定后Phase 1 的“房间系统消息同步失败后可补投”才算完成。
| 场景 | 验收 |
| --- | --- |
| IM 不可用 | 房间写命令仍成功outbox 进入 `retryable` 或最终 `failed` |
| activity 不可用 | 房间写命令仍成功outbox 进入重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 |
| 正常投递 | 同房间事件最终全部 `delivered` |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |

View File

@ -142,12 +142,12 @@
| PUT | `/api/v1/admin/gifts/{gift_id}` | admin | `updateGift` | updateGift |
| POST | `/api/v1/admin/gifts/{gift_id}/disable` | admin | `disableGift` | disableGift |
| POST | `/api/v1/admin/gifts/{gift_id}/enable` | admin | `enableGift` | enableGift |
| GET | `/api/v1/admin/game/platforms` | admin | `listGamePlatforms` | 查询游戏平台配置 |
| POST | `/api/v1/admin/game/platforms` | admin | `upsertGamePlatform` | 创建或更新游戏平台 |
| PATCH | `/api/v1/admin/game/platforms/{platform_code}` | admin | `updateGamePlatform` | 更新游戏平台 |
| GET | `/api/v1/admin/game/games` | admin | `listGameCatalog` | 查询游戏目录 |
| POST | `/api/v1/admin/game/games` | admin | `upsertGameCatalog` | 创建或更新游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}` | admin | `updateGameCatalog` | 更新游戏目录 |
| GET | `/api/v1/admin/game/platforms` | admin | `listPlatforms` | 查询游戏平台配置 |
| POST | `/api/v1/admin/game/platforms` | admin | `createPlatform` | 创建游戏平台 |
| PATCH | `/api/v1/admin/game/platforms/{platform_code}` | admin | `updatePlatform` | 更新游戏平台 |
| GET | `/api/v1/admin/game/games` | admin | `listCatalog` | 查询游戏目录 |
| POST | `/api/v1/admin/game/games` | admin | `createCatalog` | 创建游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}` | admin | `updateCatalog` | 更新游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}/status` | admin | `setGameStatus` | 设置游戏状态 |
| GET | `/api/v1/admin/hosts` | admin | `listHosts` | listHosts |
| GET | `/api/v1/admin/payment/recharge-bills` | admin | `listRechargeBills` | listRechargeBills |

View File

@ -462,7 +462,7 @@ activity_outbox
gateway 接入这些服务时只做:
- HTTP JSON 入参解析。
- 生成或透传 `request_id`
- 生成 `request_id` 并透传到内部调用
- 调内部 gRPC。
- 把 gRPC status + reason 映射为 HTTP envelope。

View File

@ -194,7 +194,7 @@ POST /api/v1/tasks/claim
"task_id": "123",
"task_type": "daily",
"task_day": "2026-05-09",
"command_id": "client-or-gateway-generated-id"
"command_id": "cmd_task_claim_550e8400"
}
```
@ -204,7 +204,7 @@ POST /api/v1/tasks/claim
- 必须未领取。
- daily 只能领取当前 `task_day` 的任务。
- exclusive 只能领取 `lifetime` 任务。
- `command_id` 幂等,重复请求返回同一结果。
- `command_id` 由客户端按领取动作生成,同一次动作重试必须复用,重复请求返回同一结果。
可选第二阶段增加:

View File

@ -444,7 +444,7 @@ POST /api/v1/game-callbacks/{platform_code}/{operation}
gateway 行为:
- 保留原始 body、query、header不提前解析成统一 JSON。
- 生成或透传 `request_id`
- 生成 `request_id` 并透传到内部调用
- 按 `platform_code` 转发给 game-service。
- 回调响应不套 App 业务 envelope因为第三方通常要求固定响应格式。

View File

@ -65,7 +65,7 @@ v1 不支持:
}
```
`request_id` 来自 `X-Request-ID`由 gateway 生成,并透传到内部 gRPC meta。
`request_id` 由 gateway 生成,并透传到内部 gRPC meta;客户端不需要提交 `X-Request-ID`
### Third Party Login Or Register

View File

@ -19,7 +19,7 @@
| 长连接和房间消息 | 腾讯云 IM 负责客户端长连接、群消息、公屏、离线/漫游和全球接入 | `DONE` |
| 真实语音 | 腾讯云 RTC 负责音频频道和音频流 | `PARTIAL` |
| 账务 | `wallet-service` 负责扣费语义,`room-service.SendGift` 扣费成功后才落房间表现 | `DONE` |
| 活动 | `activity-service` 应消费 room outbox,不侵入 Room Cell 主链路 | `PARTIAL` |
| 活动 | `activity-service` 只通过 room outbox 异步接收房间事件,不侵入 Room Cell 主链路 | `PARTIAL` |
| 用户资料 | `user-service` 负责用户主数据、短号、头像、国家等资料 | `DONE` |
## Foundation And Identity
@ -44,8 +44,8 @@
| 房间详情页聚合接口 | `DONE` | `/rooms/{room_id}/detail` 返回 room/viewer/seats/rank_top/online_count/permissions/im/rtc | 头像框、座驾、VIP、等级等展示 read model 仍需接入 |
| 房间在线用户分页 | `DONE` | `/rooms/{room_id}/online-users` 读取 `room_user_presence` 分页投影 | 大页码仍是 offset 分页,超大房间可升级 cursor |
| 房间礼物面板 | `DONE` | `/rooms/{room_id}/gift-panel` 聚合 COIN 余额、收礼人、礼物 tabs、礼物配置和数量预设 | all_mic/all_room/couple 账务拆单策略未开放 |
| 房间命令 `command_id` | `DONE` | 创建房间由 gateway 生成内部 `command_id`;其他写命令支持 body `command_id`,未传时 gateway 生成一次性值 | 客户端只在非创建类重试动作复用 command_id |
| `request_id` 追踪 | `DONE` | request_id 透传 RequestMeta不作为幂等键 | 日志和链路追踪系统可继续完善 |
| 房间命令 `command_id` | `DONE` | 写命令使用前端按用户动作生成的 `command_id` 做服务端幂等 | 同一次用户动作的 HTTP 重试必须复用 command_id |
| `request_id` 追踪 | `DONE` | gateway 生成 request_id 透传 RequestMeta不作为幂等键 | 日志和链路追踪系统可继续完善 |
| IM UserSig 签发 | `DONE` | `/api/v1/im/usersig` 使用 access token `user_id` 签发 | 真实腾讯云应用联调和告警 |
| RTC token 签发 | `DONE` | `JoinRoom` 响应内嵌首屏 RTC token`/api/v1/rtc/token` 保留为过期刷新和补偿入口 | 客户端 TRTC SDK 实际进房联调 |
| 腾讯 IM 回调入口 | `DONE` | `/api/v1/tencent-im/callback` 支持入群前和发言前守卫 | 腾讯云控制台回调 URL 和鉴权参数真实配置 |
@ -55,7 +55,7 @@
| Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- |
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot | 真实 IM 建群失败注入和线上重试策略联调 |
| 创建房间 | `DONE` | `CreateRoom` 校验 room_id、actor、mode`seat_count=0` 使用后台默认值,正数必须命中后台启用配置;写 meta/command/outbox/snapshot 后返回 | 真实 IM 建群由 outbox worker 异步完成,客户端 IM joinGroup 需要容忍短暂 group-not-ready 并重试 |
| 修改房间资料和座位数 | `DONE` | `UpdateRoomProfile` 由房间内 owner 执行;资料和座位数变更经过 Room Cell缩容要求被删除麦位空闲且未锁 | App 编辑页和真实 IM `room_profile_updated` 展示联调 |
| owner/host 收敛 | `DONE` | room-service 从 `actor_user_id` 默认 owner/host拒绝外部冒用 | 后续转主持需独立命令 |
| 进房 presence | `DONE` | `JoinRoom``OnlineUsers`gateway 返回首屏房间 DTO、`im_group_id` 和 RTC token | 客户端断线重连自动化测试 |
@ -74,21 +74,21 @@
| --- | --- | --- | --- |
| MySQL 持久化 | `DONE` | `rooms``room_snapshots``room_command_log``room_outbox` | 线上迁移脚本和容量评估 |
| Redis room lease | `DONE` | Redis directory 做 room owner lease保持单房间单活 | lease 续约/释放策略需要真实故障演练 |
| command log 幂等 | `DONE` | `HasCommand` 检查 command_id命令重复返回当前快照 | 客户端 command_id 规范需要写入客户端协议 |
| snapshot 频率 | `DONE` | `snapshot_every_n` 配置,默认每次保存 | 高并发房间可能要调大并验证恢复成本 |
| command log 幂等 | `DONE` | `HasCommand` 检查 command_id命令重复返回当前快照 | 客户端必须按用户动作生成并在重试时复用 command_id |
| snapshot 频率 | `DONE` | `snapshot_every_n` 配置,本地/生产默认 `10`,命令日志保证恢复 | 高并发房间可按恢复成本调到 20 并压测 |
| outbox 持久化 | `DONE` | 房间事件写 `room_outbox` | 事件消费侧 schema 演进策略需补 |
| outbox worker 生命周期 | `DONE` | app 生命周期启动/停止 room outbox worker | 外部消费者不止腾讯 IM 时需要 fanout 策略 |
| outbox 重试配置 | `DONE` | poll interval、batch size、publish timeout、fixed_interval | 指数退避、死信和告警未做 |
| outbox 重试配置 | `DONE` | poll interval、batch size、publish timeout、指数退避、最大重试和死信 | 后台死信查看、人工重放和告警规则需补 |
## Tencent IM Integration
| Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- |
| 客户端 IM UserSig | `DONE` | gateway 签发identifier 使用内部 `user_id` 十进制字符串 | 线上密钥管理和过期刷新体验 |
| room-service 建群 | `DONE` | `EnsureRoomGroup` 调腾讯 REST create_group | 腾讯云真实错误码覆盖和已存在群语义验证 |
| room-service 建群 | `DONE` | `RoomCreated` outbox 由 worker 调腾讯 REST create_group | 腾讯云真实错误码覆盖和已存在群语义验证 |
| 房间系统消息 | `DONE` | `PublishRoomEvent` 发送 TIMCustomElem事件包含 room_version | 客户端消息解析和版本去重 |
| 同步推失败不回滚 | `DONE` | 命令提交后同步 publish 失败保持 outbox pending | 需要监控 pending 积压 |
| outbox 补投 IM | `DONE` | outbox worker 后续 publish 并标记 published | 多消费者场景需要事件状态拆分 |
| 主链路不阻塞 IM | `DONE` | 命令提交 command log/outbox 后返回;腾讯 IM 只由 outbox worker 异步投递 | 需要监控 pending/retryable/failed 积压 |
| outbox 投递 IM | `DONE` | outbox worker publish 成功后标记 `delivered`,失败指数退避,超限进 `failed` | 多消费者场景需要更细粒度 sink 状态 |
| 入群前守卫 | `DONE` | `Group.CallbackBeforeApplyJoinGroup` -> `VerifyRoomPresence` | 腾讯云控制台配置和公网回调验收 |
| 发言前守卫 | `DONE` | `Group.CallbackBeforeSendMsg` -> `CheckSpeakPermission` | 公屏消息内容审核、频控未做 |
| 群历史补拉 | `PARTIAL` | 设计上依赖腾讯云 IM 群历史 | 客户端补拉窗口、服务端兜底 API 未实现 |
@ -135,14 +135,14 @@
| 本地礼物榜 | `DONE` | `LocalRank``gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
| 背包/道具 | `PARTIAL` | `user_resource_entitlements``user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox主事件同步 IM | 客户端去重和展示策略未验收 |
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox由 worker 异步投 IM/activity | 客户端去重和展示策略未验收 |
## Activity And Lucky Gift
| Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- |
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 尚未消费 room outbox |
| room outbox 消费边界 | `PARTIAL` | `consumer/room` 有接口骨架 | 真实 polling/MQ/offset/idempotency 未实现 |
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 具体活动规则和奖励结算未实现 |
| room outbox 消费边界 | `DONE` | room outbox worker 异步投递 activity-service失败跟随 room_outbox 重试/死信 | activity 内部消费幂等表和后台排障页需补 |
| 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw |
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
| LuckyGift Check | `TODO` | 文档有设计proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 |
@ -184,14 +184,14 @@
| 基础封禁 | `PARTIAL` | room 内 Kick 写 banuser-service 有用户状态 | 全局封禁、设备/IP 黑名单、后台操作未做 |
| 刷屏风控 | `TODO` | 当前只有 IM 发言权限守卫 | 需要消息频控、敏感词、内容安全 |
| 房间后台 | `TODO` | 当前无 admin API | 查房、关房、踢人、禁言、封禁、改配置 |
| 指标和告警 | `PARTIAL` | health 和日志存在,文档提到指标 | outbox 积压、IM/RTC 错误率、房间命令耗时、wallet 失败率未完整实现 |
| 指标和告警 | `PARTIAL` | health、结构化日志和 `room_command_timing` 已存在 | outbox 积压、IM/RTC 错误率、wallet 失败率告警未完整实现 |
## Client Required Work
| Feature | Status | Server Dependency | Remaining Work |
| --- | --- | --- | --- |
| 登录/注册 | `PARTIAL` | gateway/user-service 已有接口 | 客户端提交 Firebase ID token服务端接 Firebase verifier 后形成生产登录闭环 |
| CreateRoom/JoinRoom/LeaveRoom | `PARTIAL` | gateway/room-service 已有接口 | 非创建类 command_id、重试、状态覆盖 |
| CreateRoom/JoinRoom/LeaveRoom | `PARTIAL` | gateway/room-service 已有接口 | command_id、重试和状态覆盖联调 |
| 腾讯 IM 登录和进群 | `PARTIAL` | `/api/v1/im/usersig` 和回调守卫已存在 | SDK login、joinGroup、回调配置、历史补拉 |
| 腾讯 RTC 进房 | `PARTIAL` | `/api/v1/rtc/token` 已存在 | TRTC enterRoom、角色切换、断线重连 |
| 麦位 UI | `PARTIAL` | mic commands 已存在 | 上麦后推流、下麦停止推流、锁麦状态展示 |
@ -254,11 +254,11 @@
当前服务端已经完成或基本完成:
- 用户注册登录、短号、资料和国家修改底座。
- gateway `/api/v1` envelope、房间命令 HTTP 路由、创建房间内部 `command_id` 生成和非创建命令 `command_id` 透传。
- gateway `/api/v1` envelope、服务端 `request_id` 生成、房间命令 HTTP 路由和写命令 `command_id` 透传。
- room-service Room Cell、Create/Join/Leave、麦位上下/换位、禁言、踢人、SendGift。
- MySQL command log/snapshot/outbox、Redis lease、恢复和 stale presence 清理。
- 腾讯 IM UserSig、建群、系统消息、入群/发言回调守卫。
- room outbox worker 补偿投递
- room outbox worker 异步投递腾讯 IM/activity支持指数退避和死信
- wallet-service DebitGift 幂等扣费。
- gateway RTC token 签发和 `user_id/room_id` 映射。

View File

@ -18,8 +18,8 @@ docs/房间Cell状态机.md
| HTTP 命令入口 | `gateway-service` | 所有房间命令走 `/api/v1` envelope |
| 房间业务态 | `room-service` | Room Cell 持有 presence、麦位、禁言、踢人、快照和恢复 |
| 腾讯云 IM 登录票据 | `gateway-service` | `/api/v1/im/usersig` 返回 `sdk_app_id/user_id/user_sig/expire_at_ms` |
| 腾讯云 IM 群组 | `room-service` | `CreateRoom` 时确保 `room_id` 对应群组存在 |
| 房间系统消息 | `room-service` | 房间事件同步推腾讯云 IM失败后由 outbox 补投 |
| 腾讯云 IM 群组 | `room-service` | `RoomCreated` outbox 由 worker 异步确保 `room_id` 对应群组存在 |
| 房间系统消息 | `room-service` | 房间事件只写 outbox腾讯云 IM 由 outbox worker 异步投递 |
| 实时权限守卫 | `gateway-service` + `room-service` | 腾讯云 IM 入群前和群内发言前必须回查房间业务态 |
| 腾讯云 RTC 进房票据 | `gateway-service` | `JoinRoom` 成功后在响应中内嵌 RTC token`/api/v1/rtc/token` 只做补偿和刷新 |
@ -85,10 +85,10 @@ sequenceDiagram
C->>G: POST /api/v1/rooms/create(room_name, room_avatar?, room_description?)
G->>R: CreateRoom(actor_user_id, room profile)
R->>T: REST create_group(room_id)
R->>R: command log + snapshot + outbox
R-->>G: RoomSnapshot
G-->>C: room envelope
R-->>T: outbox worker create_group(room_id)
C->>G: GET /api/v1/im/usersig
G-->>C: SDKAppID + user_id + UserSig
@ -97,9 +97,9 @@ sequenceDiagram
C->>G: POST /api/v1/rooms/join
G->>R: JoinRoom(actor_user_id)
R->>R: update Room Cell presence
R->>T: REST send_group_msg(room_user_joined)
R-->>G: RoomSnapshot
G-->>C: initial room envelope + im_group_id + RTC token
R-->>T: outbox worker send_group_msg(room_user_joined)
C->>T: joinGroup(room_id)
T->>G: CallbackBeforeApplyJoinGroup
@ -177,7 +177,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room
### 1. CreateRoom
`CreateRoom` 必须在房间创建链路中确保腾讯云 IM 群组存在
`CreateRoom` 不等待腾讯云 IM 建群。它只提交房间权威状态和 `RoomCreated` outbox建群由 outbox worker 异步完成
同一个 `actor_user_id` 只能作为 owner 创建一个房间;加入、主持或管理别人的房间不受该 owner 限制影响。
主链路顺序:
@ -187,17 +187,17 @@ validate request
acquire Redis lease
check room meta
check owner has no room
EnsureRoomGroup(room_id)
initialize RoomState
write room meta + command log + snapshot + outbox
install Room Cell
return snapshot
```
如果 `EnsureRoomGroup` 失败:
如果后续 `EnsureRoomGroup` 失败:
- `CreateRoom` 不应该提交房间状态。
- 客户端可以重试同一个创建请求。
- `CreateRoom` 已经成功,不回滚房间状态。
- outbox worker 按指数退避重试,达到上限进入 `failed`
- 客户端 IM SDK 加群可能短暂遇到 group-not-ready需要按产品可接受窗口重试。
- 失败原因不应该泄露腾讯云密钥、UserSig 或完整公网响应。
### 2. JoinRoom
@ -221,7 +221,7 @@ return snapshot
- 用户不在房间内时幂等返回,不报错。
- 用户离房后 `VerifyRoomPresence` 必须返回 `present=false`
- 如果用户在麦上,离房必须同步释放麦位。
- 离房事件写 outbox并尝试推腾讯云 IM 系统消息。
- 离房事件写 outbox腾讯云 IM 系统消息由 worker 异步投递
### 4. Guard RPC
@ -319,7 +319,7 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
4. **补 JoinRoom 重连刷新**:重复 JoinRoom 刷新 `last_seen_at_ms`,但不重复发进房系统消息。
5. **补 LeaveRoom 麦位释放**:离房时如果在麦上,释放麦位并产生一致的房间事件。
6. **补 presence stale worker**:按 `last_seen_at_ms` 清理超时 presence`RoomUserLeft`
7. **补集成测试**:覆盖 UserSig、CreateRoom 建群、JoinRoom、回调守卫、LeaveRoom、重连恢复。
7. **补集成测试**:覆盖 UserSig、CreateRoom outbox 建群、JoinRoom、回调守卫、LeaveRoom、重连恢复。
## Acceptance Tests
@ -329,7 +329,7 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
| --- | --- |
| UserSig 签发 | 已登录用户拿到 `sdk_app_id/user_id/user_sig`,匿名用户失败 |
| UserSig 配置缺失 | 返回统一错误,不返回假票据 |
| CreateRoom 建群失败 | 房间状态不提交,命令可重试 |
| CreateRoom 后建群失败 | 房间状态已提交outbox 重试;超限后进入 `failed` |
| CreateRoom 成功 | MySQL meta/snapshot/command/outbox 都存在 |
| JoinRoom 成功 | presence 增加,返回 RoomSnapshot |
| 重复 JoinRoom | 刷新 `last_seen_at_ms`,不重复创建 presence |
@ -339,7 +339,7 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
| 发言前回调拒绝 | 被禁言、被踢、未进房或房间聊天关闭 |
| LeaveRoom 后发言 | `CheckSpeakPermission` 返回 `not_in_room` |
| room-service 接管恢复 | snapshot + command log 恢复后守卫 RPC 继续正确 |
| PublishRoomEvent 失败 | 房间状态已提交outbox 保留可补投事件 |
| outbox 投递失败 | 房间状态已提交outbox 进入 `retryable` 或最终 `failed` |
## Operational Checks

View File

@ -207,7 +207,7 @@ Response `data`:
Rules:
- `room_id` 必须来自房间列表、分享或恢复接口,不能由客户端拼业务含义。
- `command_id` 用于房间命令幂等;客户端重试同一次进房必须复用同一个值。
- `command_id` 由客户端按用户动作生成,用于房间命令幂等;重试同一次进房必须复用同一个值。
- `role` 只作为展示默认值,不能通过自报 `admin/host/owner` 获得权限。
- `im.group_id` 当前等于 `room_id`,但客户端必须使用服务端返回值,不能硬编码映射规则。
- JoinRoom 成功后才能调用腾讯 IM SDK `joinGroup`;列表里的 `im_group_id` 只用于准备,不代表入群授权。

View File

@ -110,7 +110,7 @@
赠送资源:
1. `server/admin` 鉴权并生成或透传 `command_id`
1. `server/admin` 鉴权并透传操作端前端按单次赠送动作生成的 `command_id`
2. `wallet-service` 锁定资源配置,确认 active、grantable、app_code 匹配。
3. 如果是 `coin`,在同一个钱包事务中写 `wallet_transactions/wallet_entries/wallet_outbox`
4. 如果是非金币资源,写 `resource_grants/resource_grant_items/user_resource_entitlements/resource_outbox`

View File

@ -72,6 +72,7 @@ Authorization: Bearer <access_token>
```json
{
"command_id": "cmd_create_room_550e8400",
"mode": "voice",
"room_name": "周末语音房",
"room_avatar": "",
@ -79,7 +80,7 @@ Authorization: Bearer <access_token>
}
```
正式客户端不应该传 `room_id``command_id`、`owner_user_id` 和 `host_user_id`。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id` 和 `command_id`room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`
正式客户端不应该传 `room_id``owner_user_id` 和 `host_user_id``command_id` 由客户端按创建房间动作生成,同一动作重试必须复用。gateway 必须从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`,并生成内部 `room_id`room-service 使用该 actor 确定 owner 和默认 host。`seat_count` 可省略,省略或传 `0` 时使用后台默认座位数 `15`;传正数时必须命中后台启用座位数。`room_name` 必填,`room_avatar` 为空时写入默认系统头像,房间展示资料统一落在 `RoomExt`
响应必须包含:
@ -203,7 +204,7 @@ room-service.VerifyRoomPresence(room_id, user_id, session_id)
| Requirement | Detail |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 创建房间身份收敛 | `CreateRoom` 的 owner/host 必须由 room-service 从 `RequestMeta.actor_user_id` 收敛,不接受客户端 body 自报 |
| command_id | 创建房间请求体不接收 `command_id`gateway 生成内部命令幂等键;其他房间命令仍支持稳定 `command_id`,用于 room-service command log 幂等 |
| command_id | 写命令请求体使用客户端按用户动作生成的稳定 `command_id`,用于 room-service command log 幂等 |
| 统一错误映射 | 房间不存在、被踢、无权限、重复命令要映射成稳定 HTTP envelope |
| 请求日志 | 记录 `request_id``user_id``room_id`、command type |
@ -310,7 +311,7 @@ room_presence_sweep_interval: "30s"
| Test | Expected Result |
| -------------------------- | ----------------------------------------------------------------- |
| 创建房间成功 | 返回 version=1owner 在 online_users 内 |
| 创建房间请求带客户端 ID | gateway 忽略 `room_id``command_id`,仍生成内部 ID |
| 创建房间请求带客户端 ID | gateway 忽略 `room_id`,使用客户端 `command_id` 并生成内部房间 ID |
| 普通用户 JoinRoom | presence 增加,广播进房事件 |
| 重复 JoinRoom | 不重复广播,不重复增加人数,刷新 last_seen |
| LeaveRoom | presence 移除,进群/发言守卫拒绝后续实时消息权限 |

View File

@ -15,10 +15,7 @@ const (
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader(HeaderRequestID)
if requestID == "" {
requestID = newRequestID()
}
requestID := newRequestID()
c.Set(ContextRequestID, requestID)
c.Header(HeaderRequestID, requestID)
c.Next()

View File

@ -193,7 +193,7 @@ func (h *Handler) SetAppConfigReader(reader appConfigReader) {
func meta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: commandIDOrNew(commandID),
CommandId: normalizeCommandID(commandID),
ActorUserId: auth.UserIDFromContext(request.Context()),
RoomId: roomID,
AppCode: appcode.FromContext(request.Context()),
@ -203,15 +203,9 @@ func meta(request *http.Request, roomID string, commandID string) *roomv1.Reques
}
}
func commandIDOrNew(commandID string) string {
commandID = strings.TrimSpace(commandID)
if commandID != "" {
// 客户端重试同一业务命令时必须复用该值room-service 才能命中幂等日志。
return commandID
}
// 自动生成只保证本次请求有 command_id不保证客户端重试仍命中同一个命令。
return idgen.New("cmd")
func normalizeCommandID(commandID string) string {
// command_id 是客户端按用户动作生成的写命令幂等键gateway 只裁剪空白,不自动补值。
return strings.TrimSpace(commandID)
}
// decode 统一处理 HTTP JSON 入参解析失败分支。

View File

@ -39,14 +39,11 @@ type ResponseEnvelope struct {
Data any `json:"data,omitempty"`
}
// WithRequestID 为每个业务 API 请求建立追踪 ID。
// 客户端传入 X-Request-ID 时沿用,未传时由 gateway 生成,后续 gRPC RequestMeta 复用同一个值
// WithRequestID 为每个业务 API 请求建立服务端追踪 ID。
// 入站 X-Request-ID 不作为可信输入,避免客户端把追踪字段误用成跨请求幂等锚点
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := strings.TrimSpace(request.Header.Get("X-Request-ID"))
if requestID == "" {
requestID = idgen.New("req")
}
requestID := idgen.New("req")
writer.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(request.Context(), requestIDContextKey{}, requestID)

View File

@ -56,7 +56,8 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
if walletClient.lastListResources == nil {
t.Fatal("wallet ListResources was not called")
}
if req := walletClient.lastListResources; req.GetRequestId() != "req-manager-resources" || req.GetAppCode() != "lalu" || req.GetResourceType() != "badge" || req.GetPage() != 2 || req.GetPageSize() != 20 || !req.GetActiveOnly() || !req.GetManagerGrantOnly() {
generatedRequestID := assertGeneratedRequestID(t, recorder, "req-manager-resources")
if req := walletClient.lastListResources; req.GetRequestId() != generatedRequestID || req.GetAppCode() != "lalu" || req.GetResourceType() != "badge" || req.GetPage() != 2 || req.GetPageSize() != 20 || !req.GetActiveOnly() || !req.GetManagerGrantOnly() {
t.Fatalf("wallet ListResources request mismatch: %+v", req)
}
@ -73,7 +74,7 @@ func TestListManagerGrantResourcesChecksCapabilityAndFiltersWalletRequest(t *tes
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if envelope.Code != httpkit.CodeOK || envelope.RequestID != "req-manager-resources" || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
if envelope.Code != httpkit.CodeOK || envelope.RequestID != generatedRequestID || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
t.Fatalf("response envelope mismatch: %+v", envelope)
}
if envelope.Data.Items[0].ResourceID != "101" || envelope.Data.Items[0].Name != "VIP Badge" {
@ -117,7 +118,7 @@ func TestBusinessUserLookupForwardsSceneAndActor(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if req := profileClient.lastBusinessLookup; req == nil || req.GetMeta().GetRequestId() != "req-business-lookup" || req.GetActorUserId() != 42 || req.GetScene() != "manager_resource_grant" || req.GetKeyword() != "123456" || req.GetPageSize() != 20 {
if req := profileClient.lastBusinessLookup; req == nil || req.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-business-lookup") || req.GetActorUserId() != 42 || req.GetScene() != "manager_resource_grant" || req.GetKeyword() != "123456" || req.GetPageSize() != 20 {
t.Fatalf("business lookup request mismatch: %+v", req)
}
var envelope struct {

View File

@ -207,7 +207,7 @@ func performOverviewRequest(t *testing.T, handler *Handler, requestID string) ma
t.Fatalf("decode overview response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || response.RequestID != requestID {
if response.Code != httpkit.CodeOK || !ok || response.RequestID != assertGeneratedRequestID(t, recorder, requestID) {
t.Fatalf("overview envelope mismatch: %+v", response)
}
return data

View File

@ -1089,7 +1089,7 @@ func (f *fakeObjectUploader) PutObject(_ context.Context, key string, reader io.
return "https://media.example.com/" + key, nil
}
func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
func TestRoutesWriteUnifiedEnvelopeAndGenerateRequestID(t *testing.T) {
client := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{regionID: 1001}
router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
@ -1104,7 +1104,8 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("X-Request-ID") != "req-test" {
generatedRequestID := recorder.Header().Get("X-Request-ID")
if generatedRequestID == "" || generatedRequestID == "req-test" {
t.Fatalf("response request header mismatch: got %q", recorder.Header().Get("X-Request-ID"))
}
@ -1112,10 +1113,10 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != httpkit.CodeOK || response.Message != "ok" || response.RequestID != "req-test" {
if response.Code != httpkit.CodeOK || response.Message != "ok" || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != "req-test" {
if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != generatedRequestID {
t.Fatalf("request_id was not propagated to room-service meta: %+v", client.lastCreate)
}
if client.lastCreate.GetMeta().GetActorUserId() != 42 {
@ -1127,8 +1128,8 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
if client.lastCreate.GetRoomShortId() != "100001" {
t.Fatalf("CreateRoom must use owner display_user_id as room_short_id: %+v", client.lastCreate)
}
if client.lastCreate.GetMeta().GetCommandId() == "" || client.lastCreate.GetMeta().GetCommandId() == "req-test" || client.lastCreate.GetMeta().GetCommandId() == "client-command-ignored" {
t.Fatalf("gateway-generated command_id must be non-empty and separate from request_id: %+v", client.lastCreate.GetMeta())
if client.lastCreate.GetMeta().GetCommandId() != "client-command-ignored" || client.lastCreate.GetMeta().GetCommandId() == generatedRequestID {
t.Fatalf("CreateRoom must use client action command_id and keep it separate from request_id: %+v", client.lastCreate.GetMeta())
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 {
t.Fatalf("CreateRoom must derive visible_region_id from user-service: profile=%+v create=%+v", profileClient.lastGet, client.lastCreate)
@ -1314,7 +1315,8 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
if meta.GetCommandId() != test.commandID {
t.Fatalf("command_id mismatch: got %q meta=%+v", meta.GetCommandId(), meta)
}
if meta.GetRequestId() != "req-"+test.name {
generatedRequestID := recorder.Header().Get("X-Request-ID")
if generatedRequestID == "" || generatedRequestID == "req-"+test.name || meta.GetRequestId() != generatedRequestID {
t.Fatalf("request_id must remain trace-only and unchanged: %+v", meta)
}
if meta.GetActorUserId() != 42 {
@ -1695,7 +1697,7 @@ func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
if queryClient.lastCurrent == nil {
t.Fatal("current room query was not sent")
}
if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != "req-current-room" {
if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-current-room") {
t.Fatalf("current room must use authenticated user and request meta: %+v", queryClient.lastCurrent)
}
var response httpkit.ResponseEnvelope
@ -1739,7 +1741,7 @@ func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
if queryClient.lastSnapshot == nil {
t.Fatal("room snapshot query was not sent")
}
if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != "req-room-snapshot" {
if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-room-snapshot") {
t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot)
}
var response httpkit.ResponseEnvelope
@ -1807,7 +1809,7 @@ func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != "req-countries" {
if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-countries") {
t.Fatalf("country query request metadata mismatch: %+v", countryClient.last)
}
var response httpkit.ResponseEnvelope
@ -1989,7 +1991,7 @@ func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 || hostClient.lastHost.GetMeta().GetRequestId() != "req-host-identity" {
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 || hostClient.lastHost.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-host-identity") {
t.Fatalf("host identity host request mismatch: %+v", hostClient.lastHost)
}
if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 {
@ -2092,14 +2094,15 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != httpkit.CodeOK || response.RequestID != "req-auth-third" {
generatedRequestID := assertGeneratedRequestID(t, recorder, "req-auth-third")
if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
req := userClient.lastLoginThirdParty
if req == nil {
t.Fatal("LoginThirdParty request was not sent")
}
if req.GetMeta().GetRequestId() != "req-auth-third" || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" {
@ -2338,7 +2341,7 @@ func TestSetPasswordRequiresTokenAndPropagatesActorUserID(t *testing.T) {
if userClient.lastSetPassword == nil || userClient.lastSetPassword.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated: %+v", userClient.lastSetPassword)
}
if userClient.lastSetPassword.GetMeta().GetRequestId() != "req-set-password" {
if userClient.lastSetPassword.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-set-password") {
t.Fatalf("request_id was not propagated: %+v", userClient.lastSetPassword.GetMeta())
}
}
@ -2360,7 +2363,7 @@ func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) {
if profileClient.lastUpdate == nil || profileClient.lastUpdate.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastUpdate)
}
if profileClient.lastUpdate.GetMeta().GetRequestId() != "req-profile" || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" {
if profileClient.lastUpdate.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile") || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" {
t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate)
}
}
@ -2420,7 +2423,7 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetAppCode() != "lalu" || messageClient.lastTabs.GetMeta().GetRequestId() != "req-message-tabs" {
if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetAppCode() != "lalu" || messageClient.lastTabs.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-message-tabs") {
t.Fatalf("message tabs request mismatch: %+v", messageClient.lastTabs)
}
@ -2495,7 +2498,7 @@ func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("task tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if taskClient.lastList == nil || taskClient.lastList.GetUserId() != 42 || taskClient.lastList.GetMeta().GetAppCode() != "lalu" || taskClient.lastList.GetMeta().GetRequestId() != "req-task-tabs" {
if taskClient.lastList == nil || taskClient.lastList.GetUserId() != 42 || taskClient.lastList.GetMeta().GetAppCode() != "lalu" || taskClient.lastList.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-task-tabs") {
t.Fatalf("task tabs request mismatch: %+v", taskClient.lastList)
}
@ -2567,7 +2570,7 @@ func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != "req-profile-batch" || len(profileClient.lastBatch.GetUserIds()) != 2 {
if profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-batch") || len(profileClient.lastBatch.GetUserIds()) != 2 {
t.Fatalf("batch user request mismatch: %+v", profileClient.lastBatch)
}
var response httpkit.ResponseEnvelope
@ -2605,7 +2608,7 @@ func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetRequestId() != "req-balances" {
if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-balances") {
t.Fatalf("wallet request mismatch: %+v", walletClient.last)
}
if len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "COIN" {
@ -2793,7 +2796,7 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete)
}
if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" {
if profileClient.lastComplete.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-onboarding") || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" {
t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete)
}
if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" {
@ -3058,7 +3061,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":10,"mode":"voice","room_name":"Lobby"}`)))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-scoped","seat_count":10,"mode":"voice","room_name":"Lobby"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-generated-room-id")
recorder := httptest.NewRecorder()
@ -3079,7 +3082,7 @@ func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
func TestCreateRoomAllowsMissingSeatCount(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","mode":"voice","room_name":"Lobby"}`)))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-default-seat","mode":"voice","room_name":"Lobby"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-create-default-seat")
recorder := httptest.NewRecorder()
@ -3112,7 +3115,7 @@ func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) {
if req == nil {
t.Fatal("UpdateRoomProfile must be forwarded")
}
if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != "req-profile-update" || req.GetMeta().GetActorUserId() != 42 {
if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-update") || req.GetMeta().GetActorUserId() != 42 {
t.Fatalf("UpdateRoomProfile meta mismatch: %+v", req.GetMeta())
}
if req.RoomName == nil || req.GetRoomName() != "New Room" || req.RoomAvatar == nil || req.GetRoomAvatar() != "https://cdn.example.com/new.png" || req.RoomDescription == nil || req.GetRoomDescription() != "new desc" || req.SeatCount == nil || req.GetSeatCount() != 20 {
@ -3148,9 +3151,10 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
name string
body string
}{
{name: "mode", body: `{"room_id":"room-1","seat_count":10,"room_name":"Lobby"}`},
{name: "room_name", body: `{"room_id":"room-1","seat_count":10,"mode":"voice"}`},
{name: "negative_seat_count", body: `{"room_id":"room-1","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
{name: "command_id", body: `{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`},
{name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby"}`},
{name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice"}`},
{name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
}
for _, test := range tests {
@ -3174,7 +3178,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
body := []byte(`{"room_id":"room-1","command_id":"cmd-create-upstream","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-upstream")
@ -3190,7 +3194,7 @@ func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) {
createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")),
}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-owner-2","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
body := []byte(`{"room_id":"room-owner-2","command_id":"cmd-create-owner-conflict","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-owner-conflict")
@ -3307,7 +3311,7 @@ func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
if data["user_sig"] == "" || int64(data["expire_at_ms"].(float64)) != time.Unix(1_700_000_000, 0).Add(2*time.Hour).UnixMilli() {
t.Fatalf("unexpected rtc usersig fields: %+v", data)
}
if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room_1001" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != "req-rtc-token" {
if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room_1001" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-token") {
t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence)
}
}
@ -3444,7 +3448,7 @@ func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent)
}
meta := roomClient.lastRTCEvent.GetMeta()
if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != "req-rtc-callback" || meta.GetAppCode() != "lalu" {
if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-callback") || meta.GetAppCode() != "lalu" {
t.Fatalf("RTC event meta mismatch: %+v", meta)
}
if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") {
@ -3500,7 +3504,7 @@ func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
router.ServeHTTP(recorder, request)
assertTencentCallback(t, recorder, http.StatusOK, 0)
if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != "req-im-join" {
if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-im-join") {
t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence)
}
@ -3600,7 +3604,8 @@ func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCod
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != code || response.RequestID != requestID {
generatedRequestID := assertGeneratedRequestID(t, recorder, requestID)
if response.Code != code || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
}
@ -3616,11 +3621,22 @@ func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, st
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != code || response.Message != message || response.RequestID != requestID {
generatedRequestID := assertGeneratedRequestID(t, recorder, requestID)
if response.Code != code || response.Message != message || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
}
func assertGeneratedRequestID(t *testing.T, recorder *httptest.ResponseRecorder, inboundRequestID string) string {
t.Helper()
requestID := recorder.Header().Get("X-Request-ID")
if requestID == "" || requestID == inboundRequestID {
t.Fatalf("request_id must be generated by gateway, got %q", requestID)
}
return requestID
}
func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, errorCode int) {
t.Helper()

View File

@ -500,6 +500,7 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
var body struct {
CommandID string `json:"command_id"`
SeatCount int32 `json:"seat_count"`
Mode string `json:"mode"`
RoomName string `json:"room_name"`
@ -510,12 +511,13 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
if !decode(writer, request, &body) {
return
}
body.CommandID = strings.TrimSpace(body.CommandID)
body.Mode = strings.TrimSpace(body.Mode)
body.RoomName = strings.TrimSpace(body.RoomName)
body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
if body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" {
// seat_count 可省略走后台默认值mode 和 room_name 仍是创建页最小必填信息。
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" {
// command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值mode 和 room_name 仍是创建页最小必填信息。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
@ -536,7 +538,7 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
roomID := appcode.NewScopedID(appcode.FromContext(request.Context()))
roomShortID := userResp.GetUser().GetDisplayUserId()
resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
Meta: meta(request, roomID, ""),
Meta: meta(request, roomID, body.CommandID),
SeatCount: body.SeatCount,
Mode: body.Mode,
VisibleRegionId: userResp.GetUser().GetRegionId(),

View File

@ -134,8 +134,8 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
taskID := body.normalizedTaskID()
taskType := body.normalizedTaskType()
taskDay := body.normalizedTaskDay()
commandID := commandIDOrNew(body.normalizedCommandID())
if taskID == "" || taskType == "" || taskDay == "" {
commandID := normalizeCommandID(body.normalizedCommandID())
if commandID == "" || taskID == "" || taskType == "" || taskDay == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}

View File

@ -57,6 +57,10 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
// 创建房间必须指定 room_id后续 lease、meta、snapshot 都依赖它。
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
if strings.TrimSpace(req.GetMeta().GetCommandId()) == "" {
// 创建房间同样写 command logcommand_id 必须来自客户端用户动作,不能用 request_id 代替。
return nil, xerr.New(xerr.InvalidArgument, "command_id is required")
}
if !roomid.ValidStringID(req.GetMeta().GetRoomId()) {
// 新房间必须能同时映射到腾讯云 IM GroupID 和 RTC strRoomId避免创建后无法进语音。
return nil, xerr.New(xerr.InvalidArgument, "room_id format is invalid")

View File

@ -159,7 +159,7 @@ func cloneProtoRank(items []state.RankItem) []*roomv1.RankItem {
// NewRequestMeta 是测试和 stub 组装命令元信息时的便捷函数。
func NewRequestMeta(roomID string, actorUserID int64) *roomv1.RequestMeta {
// 该函数只用于测试和 stub,真实入口应由 gateway-service 生成稳定 RequestMeta
// 该函数只用于测试和 stub;真实入口由 gateway 生成 request_id并透传客户端动作级 command_id
return &roomv1.RequestMeta{
AppCode: appcode.Default,
RequestId: idgen.New("req"),

View File

@ -680,6 +680,7 @@ func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) {
name string
req *roomv1.CreateRoomRequest
}{
{name: "command_id", req: &roomv1.CreateRoomRequest{Meta: &roomv1.RequestMeta{RoomId: "room-missing-command", ActorUserId: 42}, SeatCount: 10, Mode: "social", RoomName: "Test Room"}},
{name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 10, RoomName: "Test Room"}},
{name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 10, Mode: "social"}},
}

View File

@ -76,7 +76,17 @@ func (d *MemoryDirectory) EnsureOwner(_ context.Context, roomID string, nodeID s
return current, nil
}
// 没有 lease、lease 过期或当前节点续租时生成新 token 和过期时间。
if ok && current.ValidAt(now) && current.NodeID == nodeID {
// 同一节点续租只能延长过期时间,不能轮换 token。
// Room Cell 命令在进入 mailbox 前拿到 lease提交前会用 token 做 fencing
// 如果后台 sweep 续租时换 token就会把同节点正在执行的命令误判为旧 owner。
current.ExpiresAt = now.Add(ttl)
current.RoomID = roomID
d.leases[roomID] = current
return current, nil
}
// 没有 lease 或 lease 已过期时生成新 token这代表新的执行权实例。
next := Lease{
RoomID: roomID,
NodeID: nodeID,

View File

@ -0,0 +1,61 @@
package router
import (
"context"
"testing"
"time"
)
func TestMemoryDirectorySameOwnerRenewalPreservesLeaseToken(t *testing.T) {
ctx := context.Background()
directory := NewMemoryDirectory()
now := time.Unix(100, 0).UTC()
first, err := directory.EnsureOwner(ctx, "room-1", "node-a", now, 10*time.Second)
if err != nil {
t.Fatalf("first EnsureOwner failed: %v", err)
}
renewed, err := directory.EnsureOwner(ctx, "room-1", "node-a", now.Add(time.Second), 10*time.Second)
if err != nil {
t.Fatalf("renew EnsureOwner failed: %v", err)
}
if renewed.LeaseToken != first.LeaseToken {
// 同节点续租不能换 token否则 Room Cell 命令进入 mailbox 后会被后台续租误伤。
t.Fatalf("same owner renewal rotated token: first=%s renewed=%s", first.LeaseToken, renewed.LeaseToken)
}
if !renewed.ExpiresAt.After(first.ExpiresAt) {
t.Fatalf("same owner renewal did not extend expiry: first=%s renewed=%s", first.ExpiresAt, renewed.ExpiresAt)
}
ok, err := directory.VerifyOwner(ctx, "room-1", "node-a", first.LeaseToken, now.Add(2*time.Second))
if err != nil {
t.Fatalf("VerifyOwner failed: %v", err)
}
if !ok {
t.Fatal("original token should remain valid after same owner renewal")
}
}
func TestMemoryDirectoryExpiredLeaseCreatesNewToken(t *testing.T) {
ctx := context.Background()
directory := NewMemoryDirectory()
now := time.Unix(100, 0).UTC()
first, err := directory.EnsureOwner(ctx, "room-1", "node-a", now, time.Second)
if err != nil {
t.Fatalf("first EnsureOwner failed: %v", err)
}
taken, err := directory.EnsureOwner(ctx, "room-1", "node-b", now.Add(2*time.Second), 10*time.Second)
if err != nil {
t.Fatalf("takeover EnsureOwner failed: %v", err)
}
if taken.NodeID != "node-b" {
t.Fatalf("expired lease should be taken by node-b, got %+v", taken)
}
if taken.LeaseToken == first.LeaseToken {
// 过期接管代表新的执行权实例,必须换 token 来挡住旧 owner 的迟到提交。
t.Fatal("expired lease takeover must create a new token")
}
}

View File

@ -30,7 +30,21 @@ if not ok then
return ARGV[3]
end
if decoded["node_id"] == ARGV[4] or tonumber(decoded["expires_at_ms"]) <= tonumber(ARGV[1]) then
local expires_at_ms = tonumber(decoded["expires_at_ms"]) or 0
if decoded["node_id"] == ARGV[4] and expires_at_ms > tonumber(ARGV[1]) then
local next_ok, next_decoded = pcall(cjson.decode, ARGV[3])
if not next_ok then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
decoded["room_id"] = next_decoded["room_id"]
decoded["expires_at_ms"] = next_decoded["expires_at_ms"]
local refreshed = cjson.encode(decoded)
redis.call("SET", KEYS[1], refreshed, "PX", ARGV[2])
return refreshed
end
if expires_at_ms <= tonumber(ARGV[1]) then
redis.call("SET", KEYS[1], ARGV[3], "PX", ARGV[2])
return ARGV[3]
end
@ -156,6 +170,8 @@ func (d *RedisDirectory) Lookup(ctx context.Context, roomID string) (Lease, bool
// EnsureOwner 在租约不存在、过期或归当前节点时授予本节点执行权。
func (d *RedisDirectory) EnsureOwner(ctx context.Context, roomID string, nodeID string, now time.Time, ttl time.Duration) (Lease, error) {
// next 是当前节点希望写入的租约Lua 会决定是否真正采用它。
// 同节点续租时 Lua 会保留现有 lease_token只刷新 expires_at_ms
// 避免后台 worker 续租把正在执行的同节点命令误判为旧 owner。
next := Lease{
RoomID: roomID,
NodeID: nodeID,