diff --git a/docs/幸运礼物外接接口对接文档.md b/docs/幸运礼物外接接口对接文档.md new file mode 100644 index 00000000..cb6fa94e --- /dev/null +++ b/docs/幸运礼物外接接口对接文档.md @@ -0,0 +1,230 @@ +# 幸运礼物外接接口对接文档 + +> 文档版本:2026-07-15 +> 接入方式:服务端对服务端(Server-to-Server) +> 金额单位:对接 App 的金币最小整数单位 + +## 1. 接口用途 + +外部 App 在自己的账务系统完成幸运礼物扣费后,调用本接口获取一次最终抽奖结果。 + +平台负责: + +- 按 `app_code` 隔离规则、奖池、RTP 和抽奖流水。 +- 按 `app_code + request_id` 保证抽奖幂等。 +- 返回最终 `reward_amount` 和倍率。 + +对接 App 负责: + +- 调用前完成用户扣费。 +- 成功收到结果后,在自己的账务系统中向用户发放 `reward_amount`。 +- 对超时或网络失败使用原 `request_id` 重试,不得重新生成业务请求 ID。 + +HyApp 不会操作外部 App 的用户钱包。`reward_status=granted` 表示抽奖结果已经生成并交给调用方,不表示奖励已在外部 App 入账。 + +## 2. 接口地址 + +| 类型 | 地址 | +|---|---| +| Base URL | `https://lucky-api.global-interaction.com` | +| 送礼接口 | `POST https://lucky-api.global-interaction.com/api/v1/lucky-gifts/send` | +| 就绪检查 | `GET https://lucky-api.global-interaction.com/healthz/ready` | + +就绪检查: + +```text +GET https://lucky-api.global-interaction.com/healthz/ready +``` + +2026-07-15 实测就绪检查返回 HTTP 200,送礼请求可到达幸运礼物网关。 + +## 3. 调用前置条件 + +1. 向平台申请唯一 `app_code`,并加入幸运礼物网关白名单。 +2. 确认该 App 的幸运礼物规则已启用。 +3. 对接 App 必须先扣费,再调用抽奖接口。 +4. 本接口只允许对接 App 服务端调用,禁止将请求能力下发到 App 或 Web 客户端。 + +当前版本的 HTTP 入口使用 `app_code` 白名单,尚未启用 Authorization Token 或请求签名,因此必须仅由已开通的对接 App 服务端调用。 + +## 4. 发送幸运礼物 + +### 4.1 请求 + +```http +POST /api/v1/lucky-gifts/send HTTP/1.1 +Host: lucky-api.global-interaction.com +Content-Type: application/json +X-Request-Id: trace-20260715-000001 +``` + +`X-Request-Id` 为可选的链路追踪 ID。如果不传,平台会自动生成。它只用于日志排查,不是抽奖幂等键。 + +### 4.2 请求体 + +```json +{ + "app_code": "partner_app", + "request_id": "gift-order-20260715-000001", + "external_user_id": "user-10001", + "gift_count": 3, + "unit_amount": 100, + "currency": "COIN", + "metadata": { + "external_order_id": "order-1000001", + "gift_id": "lucky-rose", + "room_id": "room-20001" + } +} +``` + +### 4.3 字段说明 + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|---|---|---:|---|---| +| `app_code` | string | 是 | 去除首尾空格后 1–32 字符 | 平台分配的 App 编码;按小写存储和校验。 | +| `request_id` | string | 是 | 去除首尾空格后 1–128 字符 | 对接 App 生成的业务幂等 ID;建议直接使用送礼订单号或 UUID。 | +| `external_user_id` | string | 是 | 去除首尾空格后 1–128 字符 | 用户在对接 App 中的稳定唯一 ID。 | +| `gift_count` | int64 | 是 | `> 0` | 本次已扣费的礼物数量。 | +| `unit_amount` | int64 | 是 | `> 0` | 单个礼物的金币整数价格。 | +| `currency` | string | 否 | 只支持 `COIN` | 缺省或空字符串时按 `COIN` 处理。 | +| `metadata` | object | 否 | JSON object | 对接方的展示或排查元数据;不参与金额、规则或幂等判定。 | + +服务端按下式计算本次金额: + +```text +total_amount = gift_count * unit_amount +``` + +请求体不得传入 `total_amount`、`pool_id` 或 `paid_at_ms`。奖池由平台规则选择,扣费完成时间由网关按 UTC 服务端时间写入。接口会拒绝所有未定义的顶层字段。 + +一个请求只返回一个聚合抽奖结果;`gift_count` 不会产生多个独立响应。 + +### 4.4 成功响应 + +HTTP Status:`200 OK` + +```json +{ + "code": 0, + "message": "ok", + "request_id": "trace-20260715-000001", + "data": { + "draw_id": "external_lucky_draw_xxxxxxxxxx", + "request_id": "gift-order-20260715-000001", + "app_code": "partner_app", + "external_user_id": "user-10001", + "gift_count": 3, + "unit_amount": 100, + "total_amount": 300, + "reward_amount": 600, + "multiplier_ppm": 2000000, + "reward_status": "granted", + "rule_version": 1, + "created_at_ms": 1784073600000 + } +} +``` + +### 4.5 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | int | 业务结果码;`0` 表示请求成功。 | +| `message` | string | 结果说明。 | +| 外层 `request_id` | string | HTTP 链路追踪 ID,来自 `X-Request-Id` 或由平台生成。 | +| `data.draw_id` | string | 平台生成的唯一抽奖 ID,对账时使用。 | +| `data.request_id` | string | 请求体中的业务幂等 ID。 | +| `data.app_code` | string | 归一化后的 App 编码。 | +| `data.external_user_id` | string | 对接 App 的用户 ID。 | +| `data.gift_count` | int64 | 礼物数量。 | +| `data.unit_amount` | int64 | 单件礼物金币价格。 | +| `data.total_amount` | int64 | 服务端计算的已扣费总额。 | +| `data.reward_amount` | int64 | 对接 App 应向用户发放的金币整数金额;以该字段为最终结算依据。 | +| `data.multiplier_ppm` | int64 | 倍率 ppm;`1000000` 表示 1x,`2000000` 表示 2x。 | +| `data.reward_status` | string | 当前为 `granted`,表示抽奖已完成且结果已交给对接 App 处理。 | +| `data.rule_version` | int64 | 本次抽奖命中的规则版本。 | +| `data.created_at_ms` | int64 | 抽奖结果创建时间,Unix epoch milliseconds,UTC。 | + +未中奖时 `reward_amount` 和 `multiplier_ppm` 为 `0`。当前 JSON 序列化会省略数值为 `0` 的结果字段,对接方必须将缺失的 `reward_amount` 或 `multiplier_ppm` 按 `0` 处理,不得将字段缺失视为请求失败。 + +## 5. 幂等与重试 + +幂等键固定为: + +```text +app_code + request_id +``` + +- 首次请求成功后,平台保存抽奖结果。 +- 使用相同 `app_code + request_id` 重试时,直接返回首次结果,不会重新抽奖。 +- 同一 `request_id` 不得对应不同用户、数量或金额。平台在幂等命中时会返回首次结果,不会用新参数覆盖。 +- 超时、连接中断或 HTTP 5xx 时,使用原请求体和原 `request_id` 重试。 +- 客户端超时建议设置为大于网关内部 3 秒处理超时,并对重试使用有上限的退避。 + +如果多次重试后仍无法确认结果,请保留 `app_code`、`request_id`、外层 `request_id` 和扣费流水,由平台根据抽奖记录对账。 + +## 6. 错误响应 + +### 6.1 统一格式 + +```json +{ + "code": 40000, + "message": "request_id is required", + "request_id": "trace-20260715-000001" +} +``` + +### 6.2 错误码 + +| HTTP Status | `code` | 场景 | 对接方动作 | +|---:|---:|---|---| +| 400 | 40000 | JSON 无效、出现未定义字段、必填字段缺失、金额非正数或币种不支持 | 修正请求,不要原样重试。 | +| 403 | 40300 | `app_code` 未加入当前环境白名单 | 联系平台开通或检查环境。 | +| 404 | 无统一业务码 | 请求路径或 HTTP 方法错误 | 检查 Base URL、路径和请求方法。 | +| 500 | 50000 | 抽奖服务超时或内部处理失败 | 保持原 `request_id` 重试;不得重新扣费。 | + +业务成功必须同时满足 HTTP Status 为 `200` 且响应 `code` 为 `0`。 + +## 7. cURL 示例 + +```bash +curl --request POST \ + 'https://lucky-api.global-interaction.com/api/v1/lucky-gifts/send' \ + --header 'Content-Type: application/json' \ + --header 'X-Request-Id: trace-20260715-000001' \ + --data '{ + "app_code": "partner_app", + "request_id": "gift-order-20260715-000001", + "external_user_id": "user-10001", + "gift_count": 3, + "unit_amount": 100, + "currency": "COIN", + "metadata": { + "external_order_id": "order-1000001", + "gift_id": "lucky-rose", + "room_id": "room-20001" + } + }' +``` + +## 8. 标准处理流程 + +1. 对接 App 生成全局唯一的 `request_id`。 +2. 对接 App 在自有账务系统扣除 `gift_count * unit_amount`。 +3. 对接 App 调用幸运礼物接口。 +4. 平台按 `app_code + request_id` 创建或返回已有抽奖结果。 +5. 对接 App 在响应成功后按 `reward_amount` 入账,并保存 `draw_id`。 +6. 任何未知结果都使用原 `request_id` 重试;禁止重复扣费或重新生成抽奖业务号。 + +## 9. 联调检查清单 + +- [ ] `app_code` 已加入幸运礼物网关白名单。 +- [ ] 规则已启用,并确认金币单位一致。 +- [ ] 正常送礼可获得 HTTP 200 / `code=0`。 +- [ ] 重复发送相同 `request_id` 可获得相同 `draw_id` 和结果。 +- [ ] 模拟超时后只重试抽奖请求,不重复扣费。 +- [ ] 未中奖响应中缺失的数值字段可正确按 `0` 处理。 +- [ ] 奖励按 `reward_amount` 在外部 App 入账,并持久化 `draw_id`。 +- [ ] 上线前完成小额真实扣费、抽奖、返奖和幂等重试验收。 diff --git a/docs/新版VIP权限策略_Flutter客户端对接文档.md b/docs/新版VIP权限策略_Flutter客户端对接文档.md new file mode 100644 index 00000000..6ebc57ab --- /dev/null +++ b/docs/新版VIP权限策略_Flutter客户端对接文档.md @@ -0,0 +1,1052 @@ +# 新版 VIP 权限策略 Flutter 客户端对接文档 + +## 1. VIP 权限策略 + +### 1.1 权限来源 + +- VIP 等级只用于套餐名称、价格、有效期和等级展示。 +- Flutter 只使用 `GET /api/v1/vip/me` 返回的 `data.state.effective_benefits` 判断当前用户是否具有某项权益。 +- Flutter 不得使用 `VIP等级 >= N`、`unlock_level`、`app_code` 或本地权益表推导权限。 +- `/api/v1/vip/packages` 中的 `packages[].benefits` 是该套餐当前配置的完整权益集合,只用于套餐页和升级引导,不能作为用户已拥有权益。 +- 写操作最终以对应业务接口的服务端鉴权结果为准。Flutter 的权益判断只用于入口显隐、锁态和交互预判。 + +### 1.2 Flutter 权益状态 + +```dart +class VipEntitlementState { + final VipProgramConfig program; + final VipIdentity effectiveVip; + final String effectiveSource; // paid / trial / none + final Map benefitsByCode; + final VipUserSettings settings; + final int evaluatedAtMs; + + bool has(String benefitCode) { + return program.status == 'active' && + effectiveVip.active && + benefitsByCode[benefitCode]?.status == 'active'; + } + + bool allowsRoomEntryNotice() { + return has('room_entry_notice') && settings.roomEntryNoticeEnabled; + } + + bool allowsOnlineGlobalNotice() { + return has('online_global_notice') && + settings.onlineGlobalNoticeEnabled; + } +} +``` + +`benefitsByCode` 必须由 `state.effective_benefits` 转换。未知 `benefit_code` 保留在状态中但不自动开放功能,不得导致解析失败或页面崩溃。 + +### 1.3 权益刷新 + +| 场景 | Flutter 处理 | +|---|---| +| 登录成功、切换账号、切换 App | 清空原 VIP 状态,调用 `/vip/me` | +| App 冷启动、回到前台 | 调用 `/vip/me` 校准过期、体验卡回落和后台配置变更 | +| 进入 VIP 页面 | 并发调用 `/vip/packages` 和 `/vip/me` | +| 购买、佩戴体验卡、卸下体验卡成功 | 使用响应中的 `state` 原子替换本地状态 | +| 收到 `vip_notice` | 按 `event_id` 去重,再调用 `/vip/me` | +| `config_version` 不一致 | 用户权限以 `/vip/me.state` 为准,重新请求 `/vip/packages`,不得合并不同版本的权益数组 | +| VIP 到期倒计时结束 | 可更新页面显示,但执行功能前仍以服务端接口结果为准 | + +### 1.4 Fami 初始等级权益 + +下表只用于识别初始配置。Flutter 实际必须渲染 `/vip/packages` 返回的数据。 + +| 等级 | 本级初始新增权益 | +|---|---| +| VIP1 | `vip_badge_identity`、`vip_medal`、`avatar_frame`、`vip_title` | +| VIP2 | `chat_bubble`、`vip_gift`、`entry_effect`、`visitor_history` | +| VIP3 | `voice_wave`、`profile_card`、`custom_room_background`、`room_image_message` | +| VIP4 | `animated_avatar`、`animated_room_cover`、`mic_skin`、`room_border`、`vehicle` | +| VIP5 | `colored_room_name`、`colored_nickname`、`colored_id`、`gift_tray_skin` | +| VIP6 | `hide_profile_data`、`custom_avatar_frame`、`leaderboard_invisible`、`daily_coin_rebate` | +| VIP7 | `custom_profile_card`、`anonymous_profile_visit`、`custom_gift` | +| VIP8 | `room_entry_notice`、`custom_pretty_id`、`custom_vehicle` | +| VIP9 | `anti_kick`、`anti_mute`、`online_global_notice` | + +## 2. 通用协议 + +- API 前缀:`/api/v1`。 +- 请求头:`Authorization: Bearer `。 +- `app_code`、`user_id` 从登录态解析,Flutter 不传。 +- 时间字段统一使用 Unix epoch milliseconds。 +- 业务切日使用 UTC,Flutter 仅在展示时转换本地时间。 +- 成功响应统一格式: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": {} +} +``` + +- `request_id` 只用于链路追踪。 +- 带 `command_id` 的接口,网络重试必须复用原 `command_id`;新的业务动作必须生成新值。 + +## 3. VIP 套餐 + +### 接口地址 + +`GET /api/v1/vip/packages` + +### 参数 + +无。 + +### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_packages", + "data": { + "current_vip": { + "level": 3, + "name": "VIP3", + "active": true, + "started_at_ms": 1780000000000, + "expires_at_ms": 1782592000000, + "program_type": "tiered_privilege_v1", + "config_version": 7 + }, + "packages": [ + { + "level": 4, + "name": "VIP4", + "status": "active", + "price_coin": 4000, + "duration_ms": 2592000000, + "reward_resource_group_id": 0, + "reward_items": [], + "can_purchase": true, + "sort_order": 40, + "recharge_gate_required": false, + "required_recharge_coin_amount": 0, + "user_recharge_coin_amount": 0, + "purchase_locked_reason": "", + "benefits": [ + { + "benefit_code": "custom_room_background", + "name": "自定义房间背景", + "benefit_type": "function", + "unlock_level": 3, + "status": "active", + "trial_enabled": true, + "resource_id": 0, + "resource_type": "", + "execution_scope": "room", + "auto_equip": false, + "sort_order": 110, + "metadata_json": "" + } + ], + "config_version": 7 + } + ], + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1", + "level_count": 9, + "same_level_expiry_policy": "extend_remaining", + "upgrade_expiry_policy": "replace_from_now", + "downgrade_purchase_policy": "reject", + "benefit_inheritance_policy": "target_only", + "grant_mode": "trial_card", + "trial_card_enabled": true, + "status": "active", + "config_version": 7 + }, + "state": { + "paid_vip": { + "level": 3, + "name": "VIP3", + "active": true, + "expires_at_ms": 1782592000000 + }, + "equipped_trial_card": null, + "effective_vip": { + "level": 3, + "name": "VIP3", + "active": true, + "expires_at_ms": 1782592000000 + }, + "effective_source": "paid", + "effective_benefits": [ + { + "benefit_code": "custom_room_background", + "status": "active" + } + ], + "evaluated_at_ms": 1780000000000, + "user_settings": { + "room_entry_notice_enabled": true, + "online_global_notice_enabled": true, + "updated_at_ms": 0 + } + } + } +} +``` + +## 4. 当前 VIP 权限状态 + +### 接口地址 + +`GET /api/v1/vip/me` + +### 参数 + +无。 + +### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_vip_me", + "data": { + "level": 8, + "name": "VIP8", + "active": true, + "started_at_ms": 1780100000000, + "expires_at_ms": 1782692000000, + "program_type": "tiered_privilege_v1", + "config_version": 7, + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1", + "level_count": 9, + "same_level_expiry_policy": "extend_remaining", + "upgrade_expiry_policy": "replace_from_now", + "downgrade_purchase_policy": "reject", + "benefit_inheritance_policy": "target_only", + "grant_mode": "trial_card", + "trial_card_enabled": true, + "status": "active", + "config_version": 7 + }, + "state": { + "paid_vip": { + "level": 3, + "name": "VIP3", + "active": true, + "started_at_ms": 1780000000000, + "expires_at_ms": 1782592000000, + "program_type": "tiered_privilege_v1", + "config_version": 7 + }, + "equipped_trial_card": { + "trial_card_id": "vip_trial_card_xxx", + "entitlement_id": "ent_xxx", + "resource_id": 108, + "user_id": 10001, + "level": 8, + "name": "VIP8", + "status": "active", + "equipped": true, + "duration_ms": 2592000000, + "effective_at_ms": 1780100000000, + "expires_at_ms": 1782692000000, + "remaining_duration_ms": 2591000000 + }, + "effective_vip": { + "level": 8, + "name": "VIP8", + "active": true, + "started_at_ms": 1780100000000, + "expires_at_ms": 1782692000000, + "program_type": "tiered_privilege_v1", + "config_version": 7 + }, + "effective_source": "trial", + "effective_benefits": [ + { + "benefit_code": "room_entry_notice", + "name": "进房高亮通知", + "benefit_type": "function", + "status": "active", + "trial_enabled": true, + "resource_id": 0, + "resource_type": "", + "execution_scope": "room", + "auto_equip": false, + "sort_order": 290, + "metadata_json": "" + } + ], + "evaluated_at_ms": 1780101000000, + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1", + "config_version": 7 + }, + "user_settings": { + "app_code": "fami", + "user_id": 10001, + "room_entry_notice_enabled": true, + "online_global_notice_enabled": true, + "updated_at_ms": 0 + } + } + } +} +``` + +- `effective_source`:`paid`、`trial`、`none`。 +- 当前展示身份只读 `state.effective_vip`。 +- 当前权限只读 `state.effective_benefits`。 +- `daily_coin_rebate` 不会因体验卡进入 `effective_benefits`。 + +## 5. 购买、续期、升级 + +### 接口地址 + +`POST /api/v1/vip/purchase` + +### 参数 + +```json +{ + "command_id": "vip_purchase_10001_4_1780100000000", + "level": 4 +} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `command_id` | 是 | 最长 128 字节,同一次购买重试必须复用 | +| `level` | 是 | 必须来自 `/vip/packages` | + +### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_purchase", + "data": { + "order_id": "vip_order_xxx", + "transaction_id": "tx_xxx", + "vip": { + "level": 4, + "name": "VIP4", + "active": true, + "started_at_ms": 1780100000000, + "expires_at_ms": 1782692000000, + "program_type": "tiered_privilege_v1", + "config_version": 7 + }, + "coin_spent": 4000, + "coin_balance_after": 6000, + "reward_items": [], + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1", + "upgrade_expiry_policy": "replace_from_now", + "config_version": 7 + }, + "state": { + "effective_source": "paid", + "paid_vip": { + "level": 4, + "name": "VIP4", + "active": true, + "expires_at_ms": 1782692000000 + }, + "equipped_trial_card": null, + "effective_vip": { + "level": 4, + "name": "VIP4", + "active": true, + "expires_at_ms": 1782692000000 + }, + "effective_benefits": [ + { + "benefit_code": "custom_room_background", + "status": "active" + } + ], + "evaluated_at_ms": 1780100000000 + } + } +} +``` + +- 同级购买:从原截止时间继续累加。 +- Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。 +- 低等级购买:服务端拒绝。 +- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`。 + +## 6. VIP 体验卡 + +### 6.1 体验卡列表 + +#### 接口地址 + +`GET /api/v1/users/me/resources?resource_type=vip_trial_card` + +#### 参数 + +| 参数 | 位置 | 必填 | 值 | +|---|---|---|---| +| `resource_type` | query | 是 | `vip_trial_card` | + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_trial_cards", + "data": { + "items": [ + { + "entitlement_id": "ent_card_a", + "resource_id": 101, + "resource": { + "resource_id": 101, + "resource_code": "vip_trial_card_3", + "resource_type": "vip_trial_card", + "name": "VIP3体验卡", + "status": "active", + "asset_url": "", + "preview_url": "", + "animation_url": "", + "metadata_json": "{\"vip_level\":3}", + "sort_order": 30 + }, + "status": "active", + "quantity": 1, + "remaining_quantity": 1, + "effective_at_ms": 1780100000000, + "expires_at_ms": 1781309600000, + "source_grant_id": "grant_a", + "created_at_ms": 1780100000000, + "updated_at_ms": 1780100000000, + "equipped": false + } + ], + "total": 1 + } +} +``` + +### 6.2 佩戴体验卡 + +#### 接口地址 + +`POST /api/v1/vip/trial-cards/{entitlement_id}/equip` + +#### 参数 + +| 参数 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `entitlement_id` | path | 是 | 使用体验卡列表中的实例 ID | + +无 body。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_trial_equip", + "data": { + "trial_card": { + "trial_card_id": "vip_trial_card_b", + "entitlement_id": "ent_card_b", + "resource_id": 102, + "user_id": 10001, + "level": 4, + "name": "VIP4", + "status": "active", + "equipped": true, + "duration_ms": 1728000000, + "effective_at_ms": 1780200000000, + "expires_at_ms": 1781928000000, + "remaining_duration_ms": 1700000000 + }, + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1", + "trial_card_enabled": true + }, + "state": { + "effective_source": "trial", + "effective_vip": { + "level": 4, + "name": "VIP4", + "active": true, + "expires_at_ms": 1781928000000 + }, + "effective_benefits": [] + }, + "server_time_ms": 1780228000000 + } +} +``` + +### 6.3 卸下体验卡 + +#### 接口地址 + +`DELETE /api/v1/vip/trial-cards/equipped` + +#### 参数 + +无。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_trial_unequip", + "data": { + "unequipped": true, + "program_config": { + "app_code": "fami", + "program_type": "tiered_privilege_v1" + }, + "state": { + "effective_source": "paid", + "paid_vip": { + "level": 3, + "name": "VIP3", + "active": true + }, + "equipped_trial_card": null, + "effective_vip": { + "level": 3, + "name": "VIP3", + "active": true + }, + "effective_benefits": [] + }, + "server_time_ms": 1780229000000 + } +} +``` + +- 体验卡可自由切换,不比较等级高低。 +- 切换和卸下不会暂停、延长或删除体验卡。 +- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。 +- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。 + +## 7. VIP 通知开关 + +### 7.1 查询开关 + +#### 接口地址 + +`GET /api/v1/vip/settings` + +#### 参数 + +无。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_settings_get", + "data": { + "settings": { + "app_code": "fami", + "user_id": 10001, + "room_entry_notice_enabled": true, + "online_global_notice_enabled": false, + "updated_at_ms": 1780300000000 + }, + "evaluated_at_ms": 1780300001000 + } +} +``` + +### 7.2 修改开关 + +#### 接口地址 + +`PATCH /api/v1/vip/settings` + +#### 参数 + +局部更新,至少传一个字段: + +```json +{ + "room_entry_notice_enabled": false, + "online_global_notice_enabled": true +} +``` + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_settings_patch", + "data": { + "settings": { + "app_code": "fami", + "user_id": 10001, + "room_entry_notice_enabled": false, + "online_global_notice_enabled": true, + "updated_at_ms": 1780300010000 + }, + "server_time_ms": 1780300010000 + } +} +``` + +## 8. 每日 VIP 金币返现 + +### 8.1 查询当前返现 + +#### 接口地址 + +`GET /api/v1/vip/coin-rebates/current` + +#### 参数 + +无。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_rebate_current", + "data": { + "found": true, + "rebate": { + "rebate_id": "vip_rebate_xxx", + "user_id": "10001", + "task_day": "2026-07-15", + "vip_level": 6, + "vip_name": "VIP6", + "coin_amount": 100, + "status": "claimable", + "available_at_ms": 1784073600000, + "expires_at_ms": 1784160000000, + "config_version": 8, + "claimed_at_ms": 0, + "wallet_transaction_id": "", + "created_at_ms": 1784073605000, + "updated_at_ms": 1784073605000 + }, + "server_time_ms": 1784073610000 + } +} +``` + +无资格时 `found=false`、`rebate=null`。 + +### 8.2 批量查询返现状态 + +#### 接口地址 + +`POST /api/v1/vip/coin-rebates/statuses` + +#### 参数 + +```json +{ + "rebate_ids": ["vip_rebate_xxx", "vip_rebate_yyy"], + "page": 1, + "page_size": 20 +} +``` + +- `rebate_ids` 最多 100 个。 +- 不传 `rebate_ids` 时按 `page/page_size` 查询历史。 +- `page_size` 最大 100。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_rebate_statuses", + "data": { + "rebates": [ + { + "rebate_id": "vip_rebate_xxx", + "task_day": "2026-07-15", + "vip_level": 6, + "vip_name": "VIP6", + "coin_amount": 100, + "status": "claimable", + "available_at_ms": 1784073600000, + "expires_at_ms": 1784160000000, + "claimed_at_ms": 0, + "wallet_transaction_id": "" + } + ], + "total": 1, + "server_time_ms": 1784073610000 + } +} +``` + +### 8.3 领取返现 + +#### 接口地址 + +`POST /api/v1/vip/coin-rebates/{rebate_id}/claim` + +#### 参数 + +| 参数 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `rebate_id` | path | 是 | 从 current、statuses 或系统消息取得 | + +```json +{ + "command_id": "vip_rebate_claim_10001_20260715" +} +``` + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_rebate_claim", + "data": { + "rebate": { + "rebate_id": "vip_rebate_xxx", + "task_day": "2026-07-15", + "vip_level": 6, + "vip_name": "VIP6", + "coin_amount": 100, + "status": "claimed", + "claimed_at_ms": 1784073700000, + "wallet_transaction_id": "wallet_tx_xxx" + }, + "transaction_id": "wallet_tx_xxx", + "coin_balance": { + "asset_type": "COIN", + "available_amount": 20100, + "frozen_amount": 0, + "version": 18 + }, + "server_time_ms": 1784073700000 + } +} +``` + +## 9. 上线全服通知 + +### 接口地址 + +`POST /api/v1/vip/online-notice` + +### 参数 + +```json +{ + "command_id": "vip_online__2026-07-15" +} +``` + +- `command_id` 最长 128 字节。 +- 同一进程同一 UTC 日复用同一个 `command_id`。 +- `process_boot_id` 只保存在内存中,不写磁盘。 + +### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_online_notice", + "data": { + "event_id": "vip_online_notice:10001:sha256_digest", + "group_id": "hy_fami_bc_g_v2", + "status": "pending", + "created": true, + "evaluated_at_ms": 1784073610000 + } +} +``` + +- `status=pending` 表示事件已进入异步投递链路。 +- 幂等命中时 `created=false`,不是失败。 +- 同一进程每天触发一次;杀掉进程后新进程当天可以再次触发。 + +## 10. 进房 VIP 通知 + +### 接口地址 + +`POST /api/v1/rooms/join` + +### 参数 + +```json +{ + "room_id": "room_1001", + "command_id": "join_10001_room_1001_1784073600000", + "role": "audience", + "password": "", + "response_mode": "full" +} +``` + +### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_join", + "data": { + "result": { + "applied": true, + "room_version": 18, + "server_time_ms": 1784073600000 + }, + "room": {}, + "viewer": {}, + "seats": [], + "profiles": [], + "im": { + "group_id": "room_1001", + "need_join_group": true + }, + "effective_vip": { + "program_type": "tiered_privilege_v1", + "level": 8, + "name": "VIP8", + "room_entry_notice_enabled": true + }, + "server_time_ms": 1784073600000 + } +} +``` + +本人进房首屏使用 HTTP 返回的 `data.effective_vip`。其他用户的进房效果使用当前房间 IM 消息。 + +## 11. VIP 装扮资源 + +### 11.1 我的资源列表 + +#### 接口地址 + +`GET /api/v1/users/me/resources?resource_type={resource_type}` + +#### 参数 + +| 参数 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `resource_type` | query | 否 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble` 等资源类型 | + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_resources", + "data": { + "items": [ + { + "entitlement_id": "ent_xxx", + "resource_id": 108, + "resource": { + "resource_id": 108, + "resource_code": "frame_vip9", + "resource_type": "avatar_frame", + "name": "VIP头像框", + "status": "active", + "asset_url": "https://cdn.example/frame.png", + "preview_url": "https://cdn.example/frame-preview.png", + "animation_url": "https://cdn.example/frame.svga", + "metadata_json": "{}", + "sort_order": 10 + }, + "status": "active", + "expires_at_ms": 1786665600000, + "equipped": true + } + ], + "total": 1 + } +} +``` + +### 11.2 佩戴资源 + +#### 接口地址 + +`POST /api/v1/users/me/resources/{resource_id}/equip` + +#### 参数 + +| 参数 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `resource_id` | path | 是 | 资源 ID | + +无 body。 + +### 11.3 卸下资源 + +#### 接口地址 + +`DELETE /api/v1/users/me/resources/{resource_type}/unequip` + +#### 参数 + +| 参数 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `resource_type` | path | 是 | 资源类型 | + +无 body。 + +### 11.4 当前装扮 + +#### 接口地址 + +- `GET /api/v1/appearance` +- `GET /api/v1/users/by-id/{user_id}/appearance` + +#### 参数 + +第二个接口使用 path 参数 `user_id`。 + +#### 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_appearance", + "data": { + "user_id": "10001", + "avatar_frame": {}, + "profile_card": {}, + "vehicle": {}, + "mic_seat_animation": {}, + "equipped_badges": [], + "badges": [], + "resources": [], + "server_time_ms": 1784073600000 + } +} +``` + +## 12. 相关 IM + +### 12.1 VIP 激活通知 + +通道:腾讯云 IM C2C `TIMCustomElem`。 + +```json +{ + "Ext": "vip_notice", + "Desc": "VipActivated", + "Data": "{\"event_type\":\"VipActivated\",\"event_id\":\"evt_xxx\",\"app_code\":\"fami\",\"transaction_id\":\"tx_xxx\",\"command_id\":\"vip_purchase_10001_4_1780100000000\",\"user_id\":\"10001\",\"source\":\"vip_purchase\",\"action\":\"upgrade\",\"previous_level\":3,\"previous_expires_at_ms\":1781396000000,\"level\":4,\"vip_name\":\"VIP4\",\"started_at_ms\":1780100000000,\"expires_at_ms\":1782692000000,\"program_type\":\"tiered_privilege_v1\",\"config_version\":7,\"expiry_policy\":\"replace_from_now\",\"created_at_ms\":1780100000000}" +} +``` + +Flutter 按 `event_id` 去重,然后调用 `GET /api/v1/vip/me`,不能直接使用 IM 内容生成权限。 + +### 12.2 进房通知 + +通道:当前房间腾讯云 IM 群 `TIMCustomElem`,不发送到全服群。 + +```json +{ + "Ext": "room_system_message", + "Desc": "room_user_joined", + "Data": "{\"event_id\":\"evt_join_xxx\",\"room_id\":\"room_1001\",\"event_type\":\"room_user_joined\",\"actor_user_id\":10001,\"target_user_id\":10001,\"room_version\":18,\"role\":\"audience\",\"nickname\":\"Nina\",\"avatar\":\"https://cdn.example/avatar.png\",\"display_user_id\":\"100042\",\"pretty_display_user_id\":\"888042\",\"vip_program_type\":\"tiered_privilege_v1\",\"effective_vip_level\":\"8\",\"effective_vip_name\":\"VIP8\",\"room_entry_notice_enabled\":\"true\"}" +} +``` + +仅当 `event_type=room_user_joined` 且 `room_entry_notice_enabled=true` 时展示 VIP 进房效果。使用 `event_id` 去重。 + +### 12.3 上线全服通知 + +通道:全局播报群 `TIMCustomElem`。 + +| 字段 | 值 | +|---|---| +| `Ext` | `im_broadcast` | +| `Desc` | `vip_online_notice` | +| `Data` | 下方 JSON 字符串 | +| `CloudCustomData` | 与 `Data` 相同 | + +```json +{ + "event_id": "vip_online_notice:10001:sha256_digest", + "broadcast_type": "vip_online_notice", + "scope": "global", + "app_code": "fami", + "user_id": 10001, + "sender_user_id": 10001, + "nickname": "Nina", + "avatar": "https://cdn.example/avatar.png", + "avatar_frame_url": "https://cdn.example/frame.png", + "avatar_frame_resource_id": 108, + "avatar_frame_code": "frame_vip9", + "avatar_frame_asset_url": "https://cdn.example/frame.png", + "avatar_frame_preview_url": "https://cdn.example/frame-preview.png", + "avatar_frame_animation_url": "https://cdn.example/frame-animation.svga", + "display_user_id": "888042", + "raw_display_user_id": "100042", + "pretty_display_user_id": "888042", + "vip_program_type": "tiered_privilege_v1", + "vip_level": 9, + "vip_name": "VIP9", + "message": "欢迎进入Fami,祝你有美好的一天", + "action": { + "type": "none" + }, + "sent_at_ms": 1784073610500 +} +``` + +Flutter 使用 `event_id` 去重,直接使用事件内的用户、VIP 和头像框快照渲染。 + +### 12.4 金币返现系统消息 + +接口:`GET /api/v1/messages?section=system&page_size=20&page_token=`。 + +```json +{ + "message_id": "msg_vip_rebate_xxx", + "title": "VIP金币返现", + "summary": "尊敬的VIP用户,平台今日返现100金币,请及时领取。", + "body": "尊敬的VIP用户,平台今日返现100金币,请及时领取。", + "icon_url": "", + "image_url": "", + "action_type": "vip_coin_rebate_claim", + "action_param": "{\"coin_amount\":100,\"expires_at_ms\":1784160000000,\"rebate_id\":\"vip_rebate_xxx\",\"task_day\":\"2026-07-15\"}", + "read": false, + "sent_at_ms": 1784073600000 +} +``` + +Flutter 将 `action_param` 解析为 JSON,打开页面后调用 `/vip/coin-rebates/statuses` 获取真实领取状态。 + +### 12.5 IM 登录与全局群 + +接口:`GET /api/v1/im/usersig`。 + +Flutter 使用返回的 UserSig 登录腾讯云 IM,并加入响应 `join_groups` 中 `type=global_broadcast` 的群组后,再消费 `vip_online_notice`。 diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index fd2be535..d1b3b74e 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -188,7 +188,9 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store, prettyid.RegisterRoutes(appProtected, h.PrettyID) job.RegisterRoutes(protected, h.Job) levelconfig.RegisterRoutes(appProtected, h.LevelConfig) - opscenter.RegisterRoutes(appProtected, h.OpsCenter) + // Ops Center 是跨 App 工作台:它必须先读取应用目录,再按 app_code 扇出查询。 + // 这里仅沿用登录、审计和模块内 lucky-gift 权限,不能要求尚未选出的 X-App-Code。 + opscenter.RegisterRoutes(protected, h.OpsCenter) upload.RegisterRoutes(appProtected, h.Upload) search.RegisterRoutes(protected, h.Search) sevendaycheckin.RegisterRoutes(appProtected, h.SevenDayCheckIn) diff --git a/server/admin/internal/router/router_test.go b/server/admin/internal/router/router_test.go new file mode 100644 index 00000000..7f8fc91d --- /dev/null +++ b/server/admin/internal/router/router_test.go @@ -0,0 +1,46 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/modules/opscenter" + "hyapp-admin-server/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { + gin.SetMode(gin.TestMode) + auth := service.NewAuthService("ops-center-router-test-secret", time.Hour) + token, _, err := auth.GenerateAccessToken(7, "operator", nil) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + engine := New(config.Config{}, auth, nil, Handlers{OpsCenter: opscenter.New(nil, nil)}) + request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops-center/apps", nil) + request.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + + // Ops Center 先读取跨 App 目录,再按 app_code 查询每个应用的数据;启动接口若要求 + // X-App-Code,就会形成“先拿到 App 才能发 header、先发 header 才能拿 App”的死锁。 + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + if body := response.Body.String(); !strings.Contains(body, `"app_code":"yumi"`) || !strings.Contains(body, `"app_code":"aslan"`) { + t.Fatalf("bootstrap app catalog missing fixed apps: %s", body) + } + + // 从 appProtected 移出只解除 App 选择依赖;路由仍必须位于 protected 下,不能变成匿名应用目录。 + unauthorized := httptest.NewRecorder() + engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops-center/apps", nil)) + if unauthorized.Code != http.StatusUnauthorized { + t.Fatalf("unauthorized status = %d body=%s", unauthorized.Code, unauthorized.Body.String()) + } +}