47 KiB
可配置 VIP 权限体系 Flutter 对接方案(Fami / Lalu)
本文定义 Flutter 与当前已落地 VIP 后端的对接契约。客户端不要重复实现 VIP 状态机,也不要按 App、VIP 等级或最低解锁等级推导权限。界面和文案统一使用“VIP”,不再使用“SVIP”。
1. 通用约定
- 地址前缀:
/api/v1,实际 host 按环境配置。 - 所有接口需携带
Authorization: Bearer <access_token>。app_code和user_id从登录态解析,Flutter 不在 body 中传。 - 所有成功响应使用统一外壳:
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
request_id由后端生成,只用于追踪,不是幂等键。购买幂等键是command_id。- 所有
*_at_ms/duration_ms都是 Unix epoch milliseconds。业务切日使用 UTC,Flutter 只在展示层转本地时间。 - 客户端展示当前身份只读
state.effective_vip,权益资格只读state.effective_benefits。不要根据等级、unlock_level或 App 名自行继承权益;进房/上线通知还要结合state.user_settings。
2. 客户端权限控制方案
2.1 结论
客户端只把“VIP 等级”用于套餐名称、价格、有效期和等级对比展示;所有功能权限一律使用稳定的 benefit_code 判断:
后台等级配置 -> wallet 计算当前 effective_benefits -> Flutter 权益快照 -> 页面预判
-> owner service 最终鉴权
权限分三层,三层职责不能混用:
| 层级 | 负责内容 | 是否是最终权限依据 |
|---|---|---|
| VIP 配置与 wallet 快照 | 按当前 App、付费 VIP/体验卡、开关和配置版本计算 effective_benefits |
是,提供资格事实 |
| Flutter 权益中心 | 控制入口显隐、锁态、升级引导、装扮渲染,减少无效请求 | 否,只是体验层预判 |
| room/user/wallet/notice 等 owner service | 在写操作发生时重新校验权益、业务状态和平台权限 | 是,接口结果永远优先 |
禁止以下实现:
vipLevel >= 8就允许进房通知,或vipLevel >= 9就认为不可踢。- 根据
unlock_level在客户端累计低等级权益。 if (appCode == 'fami')写一套权限分支、lalu再写另一套。- 用本地缓存阻止房主发起踢人/禁言,或用缓存绕过服务端拒绝。
- 收到 VIP IM 后直接拼装权限,不再查询
/vip/me。
成熟会员/订阅产品通常采用“服务端维护 entitlement(当前资格),客户端缓存当前资格并做 UI 预判,敏感能力由服务端再次鉴权”的模式。Google Play 官方建议在安全后端管理购买状态并在授予权益前验证购买;Apple 的 StoreKit 也提供 current entitlements,并给出服务端判定服务资格的模型。这里沿用同一边界,但资格来源是本项目的 wallet VIP 状态,而不是让 Flutter 直接解释订单:
- Google Play:在安全后端管理购买生命周期与权益
- Google Play:授予权益前验证购买
- Apple StoreKit:currentEntitlements
- Apple StoreKit:服务端判定 entitlement
2.2 Flutter 统一权益中心
建议建立一个按登录 App 隔离的 VipEntitlementStore。下面是数据结构示意,不要求照搬类名:
class VipEntitlementSnapshot {
final VipProgramConfig program;
final VipIdentity effectiveVip;
final String effectiveSource; // paid / trial / none
final Map<String, VipBenefit> benefitsByCode;
final VipUserSettings settings;
final int evaluatedAtMs;
bool has(String code) {
return program.status == 'active' &&
effectiveVip.active &&
benefitsByCode[code]?.status == 'active';
}
bool allowsRoomEntryNotice() {
return has('room_entry_notice') && settings.roomEntryNoticeEnabled;
}
bool allowsOnlineGlobalNotice() {
return has('online_global_notice') &&
settings.onlineGlobalNoticeEnabled;
}
}
benefitsByCode 只能由 /vip/me.data.state.effective_benefits 建立。客户端的已知权益注册表只描述“这个 code 由哪个模块渲染/调用”,不能配置“最低 VIP 等级”:
final benefitHandlers = <String, VipBenefitHandler>{
'custom_room_background': roomBackgroundHandler,
'daily_coin_rebate': coinRebateHandler,
'room_entry_notice': roomEntryNoticeHandler,
'online_global_notice': onlineNoticeHandler,
};
遇到服务端新下发、旧客户端不认识的 benefit_code 时:保留在快照中、上报一次兼容性埋点,但不自动开放未知功能、不崩溃。这样后台可先配置新权益,旧版本客户端会安全忽略。
2.3 页面与写操作的统一决策
| 场景 | Flutter 预处理 | 最终处理 |
|---|---|---|
| VIP 中心等级对比 | 使用 packages[].benefits 展示每个套餐当前完整权益 |
仅展示,不计算用户权限 |
| 功能入口 | store.has(code) 决定正常态/锁态和升级引导 |
点击后的写接口再次鉴权 |
| 装扮权益 | 同时检查权益和服务端资源字段;素材为空时使用无图兜底 | 保存/佩戴仍调用资源 owner 接口 |
| 进房通知 | 本人的设置页读 store;房间内展示读 Join/房间 IM 快照 | room-service 在 Join 时校验 |
| 防踢、防禁言 | 不根据目标用户本地 VIP 快照禁用管理按钮 | 正常调用房管接口,处理 PERMISSION_DENIED |
| 金币返现 | daily_coin_rebate 可用于显示入口,金额/状态不在本地推导 |
current/statuses/claim 接口决定资格和入账 |
| 上线全服通知 | 权益和开关满足时发起 /vip/online-notice |
activity/notice 链路异步投递 |
对于锁定入口,“升级到哪一级”也不能使用本地最低等级表。应按 sort_order 遍历当前 /vip/packages,找到第一个 status=active、can_purchase=true 且 benefits 包含目标 code 的套餐;找不到时只显示“暂未开放”,不要给出错误等级承诺。
3. 每一级 VIP 如何展示和控制
3.1 Fami P1 初始权益矩阵
下表是初始化配置中的“本级新增权益”,用于产品和客户端理解页面分组,不是客户端授权表。后台保存的是每一级完整权益集合,运营调整后必须以 packages[level].benefits 为准。
| 等级 | 初始化新增权益 |
|---|---|
| VIP1 | vip_badge_identity VIP标识、vip_medal VIP勋章、avatar_frame VIP头像框、vip_title VIP称号 |
| VIP2 | chat_bubble VIP聊天气泡、vip_gift VIP礼物、entry_effect VIP进场通知、visitor_history 查看访客 |
| VIP3 | voice_wave VIP声波纹、profile_card VIP资料卡皮肤、custom_room_background 自定义房间背景、room_image_message 房间发图 |
| VIP4 | animated_avatar 动态头像、animated_room_cover 动态房间封面、mic_skin 麦克风皮肤、room_border 房间边框、vehicle VIP座驾 |
| VIP5 | colored_room_name VIP彩色房间名称、colored_nickname VIP彩色昵称、colored_id VIP彩色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 上线全服通知 |
展示等级详情时直接循环该套餐的 benefits,可按 benefit_type 或 execution_scope 分组;不要把上表写进 Flutter。高等级是否包含低等级权益,由后台下发的完整集合决定。
3.2 Lalu 与其他 App
/vip/packages和/vip/me返回当前登录态对应 App 的配置,Flutter 不传app_code。- Lalu 当前是
legacy_timed,Fami 当前是tiered_privilege_v1;客户端只展示program_config,不按该值重写购买状态机。 - 等级数量使用
program_config.level_count,可购买等级使用packages实际数组。不能假设所有 App 都有 VIP1–VIP9。 - 套餐是否可买使用
can_purchase;购买后的等级、期限、权益和当前展示身份使用购买响应中的state。
4. 权益快照生命周期与缓存
4.1 刷新时机
| 事件 | 客户端动作 |
|---|---|
| 登录成功、切换账号或切换 App | 清空旧 App store,调用 /vip/me |
| App 冷启动、回前台 | 调 /vip/me 校准过期、后台停用和体验卡回落 |
| 进入 VIP 中心 | 并发调用 /vip/packages 与 /vip/me |
| 购买、佩戴体验卡、卸下体验卡成功 | 立即用响应 state 原子替换 store |
收到 vip_notice |
用 event_id 去重,然后调用 /vip/me;若版本变更再刷新 packages |
state.program_config.config_version 与套餐版本不一致 |
用户权限先以 state 为准,重新请求 packages,不能跨版本合并权益数组 |
| 进入房间 | 本人首屏读 Join 响应;其他人的进房效果读当前房间 IM,不查询对方 /vip/me |
内存快照是当前会话的主缓存。磁盘缓存只可用于会员徽章等首帧占位,必须立刻后台校准;防踢、防禁言、返现、发送图片等敏感功能不能在离线状态仅凭磁盘快照授权。
客户端不要用手机定时器直接删除权益。可以用 expires_at_ms 做倒计时和提前显示过期态,但恢复联网或执行写操作时,最终结果仍以服务端为准。
4.2 config_version 规则
/vip/me.state.program_config.config_version:当前用户权益快照所依据的版本。/vip/packages.program_config.config_version和各 package 的config_version:套餐列表所依据的版本。- 收到更高版本的购买响应或
vip_notice.config_version时,刷新/vip/me和/vip/packages。 - 版本不一致时,绝不能把旧 packages 的权益补进新 state,也不能把新 packages 当作用户已拥有权益。
5. 当前接口与消息总览
5.1 HTTP
| 目的 | 方法与地址 | 客户端状态影响 |
|---|---|---|
| 套餐与每级完整权益 | GET /api/v1/vip/packages |
更新套餐目录,不直接授权 |
| 当前 VIP 与生效权益 | GET /api/v1/vip/me |
原子替换 VIP store |
| 购买/续期/升级 | POST /api/v1/vip/purchase |
成功后使用响应 state |
| 体验卡背包 | GET /api/v1/users/me/resources?resource_type=vip_trial_card |
只更新卡列表 |
| 佩戴体验卡 | POST /api/v1/vip/trial-cards/{entitlement_id}/equip |
成功后使用响应 state |
| 卸下体验卡 | DELETE /api/v1/vip/trial-cards/equipped |
成功后使用响应 state |
| VIP 用户开关 | GET/PATCH /api/v1/vip/settings |
更新 user_settings,不改变权益集 |
| 金币返现 | /api/v1/vip/coin-rebates/* |
使用服务端记录和余额回执 |
| 上线全服通知 | POST /api/v1/vip/online-notice |
只创建异步播报事件 |
5.2 腾讯云 IM 与 inbox
| 事件 | 通道与标识 | 客户端处理 |
|---|---|---|
| 购买/直接激活 VIP | C2C TIMCustomElem:Ext=vip_notice、Desc=VipActivated |
event_id 去重,刷新 /vip/me,不能直接授权 |
| 后台发 Fami 体验卡 | 当前无专用 C2C | 进入背包时刷新资源列表,不假装自动佩戴 |
| 用户进入房间 | 当前房间群 TIMCustomElem:Ext=room_system_message、Desc=room_user_joined |
使用事件内 VIP 快照渲染本次进房效果 |
| VIP 上线全服通知 | 全局播报群 TIMCustomElem:Ext=im_broadcast、Desc=vip_online_notice |
event_id 去重并渲染事件快照 |
| VIP 金币返现 | 后端 system inbox:action_type=vip_coin_rebate_claim |
解析 action_param 后调用 statuses 恢复真实状态 |
当前没有“任意 VIP 权限变化”的通用 IM。客户端必须依靠上述刷新生命周期校准,不能把 IM 是否到达当作权益是否生效的依据。
6. VIP 套餐
接口地址
GET /api/v1/vip/packages
参数
无 query/body 参数。
返回值
以下 JSON 仅保留对接所需的代表字段;后端会按本节字段表返回完整对象。
{
"code": "OK",
"message": "ok",
"request_id": "req_01",
"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": "",
"created_at_ms": 0,
"updated_at_ms": 0
}
],
"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,
"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
}
}
}
}
packages 只返回后台已启用的等级。Flutter 必须动态渲染,不得硬编码 9 个套餐。Fami 初始 VIP1–VIP9 均为 disabled,运营配置价格并启用前,packages 为空是正常结果。
相关 IM
无。
场景处理
- 购买按钮以
can_purchase为准;false时可用purchase_locked_reason决定禁用态。 benefits已是该等级的完整集合。unlock_level仅是配置来源信息,不参与 Flutter 权益计算。current_vip是兼容字段,新代码优先使用state.effective_vip。
7. 当前 VIP 状态
接口地址
GET /api/v1/vip/me
参数
无 query/body 参数。
返回值
以下为结构示例,state 和 program_config 实际返回完整字段。
{
"code": "OK",
"message": "ok",
"request_id": "req_02",
"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,
"grant_source": "admin_grant",
"source_grant_id": "grant_xxx",
"created_at_ms": 1780100000000,
"updated_at_ms": 1780100000000
},
"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",
"unlock_level": 8,
"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
}
}
}
}
data.level/name/active/... 是兼容旧版的最终生效 VIP 扁平投影,新 Flutter 代码应读 data.state。
相关 IM
本接口自身不发 IM。收到购买/发放的 vip_notice 后,应调用本接口刷新最终状态。
场景处理
effective_source取值为paid/trial/none。paid_vip是付费会员事实;equipped_trial_card是当前佩戴卡;effective_vip是页面和房间展示的唯一真值。- 后台关闭
trial_card_enabled时,equipped_trial_card原始背包事实仍返回,但effective_source会立即回落为paid;Flutter 不得继续沿用旧体验卡权限缓存。 - 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为
effective_vip;卸下或过期后自然回落到仍有效的paid_vip。 - 体验卡状态下,
daily_coin_rebate不会进入effective_benefits。Flutter 不要只看卡等级猜测权益。 - 无 VIP 时,
effective_source="none"、effective_vip.active=false、effective_benefits=[]。 user_settings.updated_at_ms=0表示用户尚未修改,两个开关使用默认开启值;设置在无 VIP 时也保留,但不会单独授权。
8. 购买、续期和升级
接口地址
POST /api/v1/vip/purchase
参数
{
"command_id": "vip_purchase_10001_4_1780100000000",
"level": 4
}
| 字段 | 说明 |
|---|---|
command_id |
必填,同一次购买的网络重试必须复用同一值;新的购买动作必须生成新值。 |
level |
必填,目标 VIP 等级,必须来自 packages。 |
返回值
{
"code": "OK",
"message": "ok",
"request_id": "req_03",
"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,
"program_config": {
"app_code": "fami",
"program_type": "tiered_privilege_v1",
"config_version": 7
}
}
}
}
相关 IM
购买或后台直接激活成功后,notice-service 向该用户发送 C2C TIMCustomElem:
{
"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\",\"asset_type\":\"VIP\",\"available_delta\":0,\"frozen_delta\":0,\"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\",\"reward_resource_group_id\":0,\"resource_grant_id\":\"\",\"reward_items\":[],\"created_at_ms\":1780100000000,\"source_created_at_ms\":1780100000000}"
}
Flutter 收到后用 event_id 做会话内去重,再调 GET /vip/me。不要用 IM payload 自行拼出最终权益。
场景处理
- Fami 无有效会员时:新截止时间 = 购买时间 +
duration_ms。 - Fami 同级续期:新截止时间 = 旧
expires_at_ms+duration_ms,保留剩余时间。 - Fami 高级升级:新截止时间 = 购买时间 +
duration_ms,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。 - 购买更低等级由后端拒绝,错误
VIP_DOWNGRADE_NOT_ALLOWED。 - 余额不足返回
INSUFFICIENT_BALANCE;等级未启用返回VIP_LEVEL_DISABLED。 - 如果购买时仍佩戴体验卡,购买结果会写入
paid_vip,但当前展示仍可能是effective_source="trial"。购买成功页也必须以返回的state.effective_vip为准。
9. 体验卡背包列表
接口地址
GET /api/v1/users/me/resources?resource_type=vip_trial_card
参数
| 参数 | 位置 | 值 |
|---|---|---|
resource_type |
query | 固定 vip_trial_card |
返回值
{
"code": "OK",
"message": "ok",
"request_id": "req_04",
"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",
"grantable": true,
"grant_strategy": "new_entitlement",
"usage_scopes": [],
"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
}
}
metadata_json 是 JSON 字符串,如需读取 vip_level 必须再解析一次;但当前生效等级仍以 /vip/me 为准。
相关 IM
无。后台发卡目前不会自动佩戴,也没有已定义的体验卡 C2C 消息。
场景处理
- 体验卡从发放时的
effective_at_ms开始绝对计时,不是首次佩戴时才开始。 - 切换、卸下都不修改任何卡的
expires_at_ms,也不删除旧卡。 - 列表仅返回当前有效的背包实例。卡过期后会自然从 active 列表消失。
- 预置卡素材初始为空,Flutter 需提供无图兜底,不得拼接本地固定素材 URL。
10. 佩戴体验卡
接口地址
POST /api/v1/vip/trial-cards/{entitlement_id}/equip
参数
- path
entitlement_id:必须使用背包列表的实例 ID,不能使用resource_id。 - 无 body。
返回值
{
"code": "OK",
"message": "ok",
"request_id": "req_05",
"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,
"grant_source": "admin_grant",
"source_grant_id": "grant_b",
"created_at_ms": 1780200000000,
"updated_at_ms": 1780200000000
},
"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
}
}
相关 IM
无。
场景处理
- 任意等级可自由切换,不比较新旧卡高低。
- 切换 A 到 B 后,B 成为
equipped=true,A 仍在背包中,A/B 的绝对截止时间均不变。 - 必须直接用返回的
state更新全局 VIP,不要等待 IM。 - 倒计时使用
expires_at_ms - server_time_ms,不要在每次切换时重置为duration_ms。 - Flutter 的 VIP 流程固定调用本接口,不调用通用
/users/me/resources/{resource_id}/equip。后端虽兼容通用背包装备并会内部转交同一 VIP 状态机,但通用响应没有完整state;历史通用背包组件若触发成功,必须立即补拉GET /vip/me。
11. 卸下体验卡
接口地址
DELETE /api/v1/vip/trial-cards/equipped
参数
无 path/query/body 参数。
返回值
{
"code": "OK",
"message": "ok",
"request_id": "req_06",
"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
}
}
相关 IM
无。
场景处理
-
Flutter 的 VIP 流程固定调用本接口,不调用通用
/users/me/resources/vip_trial_card/unequip。后端对历史通用背包调用同样会转交专用状态机,但通用响应不包含回落后的完整state。 -
unequipped=true表示本次确实解除了佩戴;重复调用可返回false,仍按成功处理。 -
卸下只删除佩戴关系,不删卡、不暂停卡、不修改卡的截止时间。
-
卸下后优先回落到有效
paid_vip;没有付费 VIP 则effective_source="none"。
12. 经理中心发放 VIP
经理中心页面如果复用 Flutter,仍调用原地址,不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 program_config.grant_mode:Lalu 调直接会员发放,Fami 调体验卡专用发放。
接口地址
POST /api/v1/manager-center/vip-grants
参数
{
"command_id": "mgr_vip_10001_1780300000000",
"target_user_id": "10001",
"level": 5,
"reason": "manager_center"
}
command_id必填且最长 128 字节;重试必须复用同一个值和同一业务参数。- 经理中心现有权限仍只允许 VIP1–VIP5,目标用户国家与经理权限校验保持不变。
返回值
Lalu(direct_membership)直接激活会员:
{
"code": "OK",
"message": "ok",
"request_id": "req_08a",
"data": {
"grant_mode": "direct_membership",
"transaction_id": "tx_xxx",
"level": 5,
"active": true,
"expires_at_ms": 1782892000000,
"server_time_ms": 1780300000000
}
}
Fami(trial_card)发一张 30 天体验卡到背包,不自动佩戴:
{
"code": "OK",
"message": "ok",
"request_id": "req_08b",
"data": {
"grant_mode": "trial_card",
"transaction_id": "",
"trial_card_id": "vip_card_xxx",
"entitlement_id": "ent_xxx",
"source_grant_id": "grant_xxx",
"level": 5,
"active": false,
"card_status": "active",
"equipped": false,
"expires_at_ms": 1782892000000,
"server_time_ms": 1780300000000
}
}
相关 IM
无。Fami 发卡成功后,被发放用户下次进入背包或收到后续资源刷新信号时重新拉取资源列表;当前接口不会替用户佩戴。
场景处理
- 只根据响应
grant_mode展示“VIP 已生效”或“体验卡已发到背包”,不能仅按level判断。 trial_card返回的active=false表示没有直接激活会员;卡片本身状态看card_status。- 配置恰好在 gateway 查询后被后台切换时,wallet 会拒绝旧模式写入;Flutter 展示接口错误并允许使用同一
command_id重试。
13. 进房通知 IM
接口地址
进房仍使用现有 POST /api/v1/rooms/join。VIP 展示不新增 Flutter HTTP 请求。
参数
沿用现有进房参数:
{
"room_id": "room_1001",
"command_id": "join_10001_room_1001_1780300000000",
"role": "audience",
"password": "",
"response_mode": "full"
}
返回值
response_mode=full 和 response_mode=lite 均会在进房成功响应的 data 中返回当前用户的进房 VIP 快照:
{
"code": "OK",
"message": "ok",
"request_id": "req_join_xxx",
"data": {
"result": {
"applied": true,
"room_version": 18,
"server_time_ms": 1780300000000
},
"room": {},
"viewer": {},
"seats": [],
"profiles": [],
"im": {
"group_id": "room_1001",
"need_join_group": true
},
"rtc": {},
"effective_vip": {
"program_type": "tiered_privilege_v1",
"level": 8,
"name": "VIP8",
"room_entry_notice_enabled": true
},
"server_time_ms": 1780300000000
}
}
effective_vip 是当前进房用户在本次 Join 时的轻量快照,可用于本人首屏。完整会员状态和权益集仍以 /vip/me.state 为准。
相关 IM
进房成功后只向当前房间的腾讯 IM 群发送 TIMCustomElem,不发全服群:
{
"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\",\"attributes\":{\"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\"}}"
}
effective_vip_level 和 room_entry_notice_enabled 在 Data 中是字符串,Flutter 需做兼容解析。
场景处理
- 当前用户进房成功后,可直接用 HTTP
data.effective_vip初始化本人进房效果;其他成员的进房展示仍以房间 IM 为准。 - 仅当
event_type == "room_user_joined"且room_entry_notice_enabled == "true"时显示 VIP 进房高亮效果。 - 消息所在 IM GroupID 就是当前
room_id;不要转发到 App 全局播报群。 - 用
event_id去重。重连/重复 Join 仍可收到进房展示事件,不要以本地 presence 猜测是否展示。 - 进房 VIP 查询失败时后端会降级为普通进房,不阻断进房主链路。
14. VIP 通知开关
接口地址
GET /api/v1/vip/settingsPATCH /api/v1/vip/settings
GET /api/v1/vip/me 的 data.state.user_settings 与 GET 接口是同一份事实。VIP 页面已经拉取 /vip/me 时,不必再为展示开关多发一次请求。
参数
GET 无参数。PATCH 是局部更新,两个字段至少传一个;显式 false 不能被当成未传:
{
"room_entry_notice_enabled": false
}
{
"online_global_notice_enabled": true
}
Flutter 不传 app_code 和 user_id,后端只从登录态解析。空对象 {} 返回 INVALID_ARGUMENT。
返回值
GET:
{
"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
}
}
PATCH:
{
"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": false,
"updated_at_ms": 1780300010000
},
"server_time_ms": 1780300010000
}
}
用户从未修改过时,两项默认为 true,updated_at_ms=0。PATCH 只改本次提交的字段,不会覆盖另一项。
相关 IM
修改开关本身不发 IM。进房开关会在下一次 Join 时影响当前房间 room_user_joined;上线开关会在触发 /vip/online-notice 时由服务端再校验。
场景处理
- 开关是用户偏好,不是权益;无 VIP 时也可保存,但不会产生进房或上线播报。
- 进房展示必须同时满足
effective_benefits含room_entry_notice且room_entry_notice_enabled=true。 - 上线展示必须同时满足
online_global_notice权益和开关;最终以触发接口的服务端判定为准。 - 设置按 App 隔离,Fami 的修改不影响 Lalu。
15. 每日 VIP 金币返现
接口地址
GET /api/v1/vip/coin-rebates/current:查当前 UTC 日资格。POST /api/v1/vip/coin-rebates/statuses:批量恢复系统消息中的真实状态,或分页查历史。POST /api/v1/vip/coin-rebates/{rebate_id}/claim:手动领取。GET /api/v1/messages?section=system&page_size=20&page_token=:查“VIP金币返现”系统消息。
参数
current 无 query/body。statuses 有两种用法:
{
"rebate_ids": ["vip_rebate_xxx", "vip_rebate_yyy"],
"page": 1,
"page_size": 20
}
rebate_ids 最多 100 个,后端只返回当前登录用户拥有的记录。传空数组或不传时使用 page/page_size 查历史,page_size 最大 100。
claim:
{
"command_id": "vip_rebate_claim_10001_20260710"
}
rebate_id 只来自 current/statuses/系统消息。Flutter 不传金额、VIP 等级、app_code 或 user_id。
返回值
current 已生成资格:
{
"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-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimable",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 0,
"wallet_transaction_id": "",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641605000
},
"server_time_ms": 1783641610000
}
}
无资格或 cron 尚未生成时也是成功响应:
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_none",
"data": {
"found": false,
"rebate": null,
"server_time_ms": 1783641610000
}
}
statuses:
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_statuses",
"data": {
"rebates": [
{
"rebate_id": "vip_rebate_xxx",
"user_id": "10001",
"task_day": "2026-07-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimable",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 0,
"wallet_transaction_id": "",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641605000
}
],
"total": 1,
"server_time_ms": 1783641610000
}
}
claim:
{
"code": "OK",
"message": "ok",
"request_id": "req_rebate_claim",
"data": {
"rebate": {
"rebate_id": "vip_rebate_xxx",
"user_id": "10001",
"task_day": "2026-07-10",
"vip_level": 6,
"vip_name": "VIP6",
"coin_amount": 100,
"status": "claimed",
"available_at_ms": 1783641600000,
"expires_at_ms": 1783728000000,
"config_version": 8,
"claimed_at_ms": 1783641700000,
"wallet_transaction_id": "wallet_tx_xxx",
"created_at_ms": 1783641605000,
"updated_at_ms": 1783641700000
},
"transaction_id": "wallet_tx_xxx",
"coin_balance": {
"asset_type": "COIN",
"available_amount": 20100,
"frozen_amount": 0,
"version": 18
},
"server_time_ms": 1783641700000
}
}
status 只处理 claimable / claimed / expired。expired 是 wallet 根据当前 UTC 时间的投影,不要仅凭手机时钟自行改状态。
相关 IM
返现可领取通知进入后端 system inbox,不新增腾讯云 TIMCustomElem。GET /messages?section=system 的条目示例:
{
"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\":1783728000000,\"rebate_id\":\"vip_rebate_xxx\",\"task_day\":\"2026-07-10\"}",
"read": false,
"sent_at_ms": 1783641600000
}
Flutter 先把 action_param 再解析为 JSON。点击前可将页面上所有 rebate_id 合并调 statuses,用后端状态刷新“去领取/已领取/已过期”。
场景处理
- 资格按 UTC 0 点时的有效付费 VIP 还原;0 点后才购买或升级的状态从次日参与,体验卡不参与。
- 领取窗口是
[UTC 当日0点,UTC 次日0点)。到次日0点后服务端返回VIP_COIN_REBATE_EXPIRED,不再入账。 - 金额取当日首个成功 cron run 固化的后台配置;同一 run 不会因运营中途改价而分裂。
- 领取按钮要防连点,网络重试复用同一
command_id。同一返现换新command_id重复提交也只返回原成功回执,不会重复加币。 - 领取成功后用
data.coin_balance替换 COIN 余额快照,不在 Flutter 本地做balance += coin_amount。 - 记录不存在或不属于当前用户时返回
VIP_COIN_REBATE_NOT_FOUND;复用一个command_id改投另一笔返现时返回IDEMPOTENCY_CONFLICT。
16. 上线全服通知
接口地址
POST /api/v1/vip/online-notice
调用前先完成 GET /api/v1/im/usersig 的 IM 登录,并按 join_groups 加入 type=global_broadcast 的全局播报群。Flutter 只请求后端发送,不向全局群自行发消息。
参数
{
"command_id": "vip_online_550e8400-e29b-41d4-a716-446655440000_2026-07-10"
}
command_id 最长 128,用“进程启动唯一 ID + UTC 日期”生成。同一进程当日网络重试必须复用同一值。
返回值
{
"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": 1783641610000
}
}
status=pending 表示已持久化到 activity 播报 outbox,腾讯云 IM 由 worker 异步投递。相同 command_id 重试返回同一 event_id,created=false 是幂等命中,不是失败。
相关 IM
全局播报群下发 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": 1783641610500
}
display_user_id 优先美号,无美号时回落 raw_display_user_id。avatar_frame_url 是兼容字段,固定等于 avatar_frame_asset_url;无当前佩戴头像框时 ID 为 0、URL/code 为空字符串。用户资料、VIP 等级和头像框都由服务端取当前事实,Flutter 不能用请求伪造。用 event_id 做会话内去重。
场景处理
- 在进程内保存
process_boot_id、当前 UTC 日和该日command_id,不写 SharedPreferences/数据库/磁盘文件。 - 同进程同 UTC 日首次进入 App,且
/vip/me中具有online_global_notice资格、online_global_notice_enabled=true时触发。成功后当日不再产生新 command。 - 超时/5xx 可用同一
command_id重试;不能在同进程同日换 command 规避幂等。 - 杀掉进程后内存清空,新进程产生新
process_boot_id和command_id,同日可再播一次,这是已确认产品规则。 - 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回
PERMISSION_DENIED;Flutter 不做本地飘屏,可重拉/vip/me。 - 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
17. 权益场景和完成边界
| 场景 | Flutter 处理 | 当前后端状态 |
|---|---|---|
| App 维度 VIP 体系 | 始终渲染 program_config 和接口返回的动态等级,不按 Fami/Lalu 写死业务分支 |
已强制。Fami 为 tiered_privilege_v1,Lalu 为 legacy_timed |
| Fami 高级升级 | 直接展示购买响应的新截止时间 | 已强制,旧低级剩余时间丢弃 |
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 entitlement_id 佩戴,每次直接替换全局 state |
已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡权益 | 只渲染 effective_benefits 资格 |
已强制过滤 trial_enabled=false,金币返现不给体验卡 |
| VIP 通知开关 | 读 state.user_settings,用 PATCH 局部修改 |
已按 App 持久化,无记录默认两项开启,开关不单独授权 |
| 进房高亮通知 | 本人首屏读 Join HTTP effective_vip,其他成员消费当前房间 room_user_joined |
后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM;不发全服 |
| 防踢 | 房管操作返回 PERMISSION_DENIED 时显示不可踢,不做本地预判 |
已在 room-service 强制;只阻止普通房主/管理员的 KickUser |
| 防禁言 | 房管操作返回 PERMISSION_DENIED 时显示不可禁言,解禁始终可发起 |
已在 room-service 强制;只阻止普通房主/管理员新增禁言 |
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 SystemEvictUser 治理链路会绕过防踢,VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | tiered_privilege_v1 已由 room-service 强制校验 custom_room_background |
| 金币返现 | 消费 system inbox,批量恢复状态,手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 vip_online_notice |
已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM;服务端不做日级去重 |
| 其他 VIP 装扮/功能 | 可根据 effective_benefits 展示锁定/解锁态;有素材时使用后端 URL |
权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 resource_id=0、素材为空,未绑定 owner/资源前不能宣称已实现 |
18. Flutter 建议状态流
- 进 VIP 页并发请求
/vip/packages和/vip/me,套餐用前者,用户生效状态用后者。 - 购买成功、佩戴成功、卸下成功时,立即用响应中的
state替换全局 VIP store。 - App 冷启动、登录成功、回前台以及收到
vip_notice后调/vip/me校准;同时更新user_settings。 - 背包页用
vip_trial_card列表展示每张独立卡;当前佩戴态以/vip/me.state.equipped_trial_card.entitlement_id为准。 - 所有房间、个人页和消息展示都使用
effective_vip,不要用paid_vip覆盖体验卡展示。 - system inbox 遇到
action_type=vip_coin_rebate_claim时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。 - IM 登录并加入全局播报群后,按进程内 UTC 日规则触发
/vip/online-notice;收到vip_online_notice时用event_id去重。 - 修改通知开关成功后,用 PATCH 响应替换本地
user_settings,不修改effective_benefits。