# Fami VIP Flutter 对接方案

本文只定义 Flutter 与已落地后端的对接契约，不要在客户端重复实现 VIP 状态机。界面和文案统一使用“VIP”，不再使用“SVIP”。

## 1. 通用约定

- 地址前缀：`/api/v1`，实际 host 按环境配置。
- 所有接口需携带 `Authorization: Bearer <access_token>`。`app_code` 和 `user_id` 从登录态解析，Flutter 不在 body 中传。
- 所有成功响应使用统一外壳：

```json
{
  "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. VIP 套餐

### 接口地址

`GET /api/v1/vip/packages`

### 参数

无 query/body 参数。

### 返回值

以下 JSON 仅保留对接所需的代表字段；后端会按本节字段表返回完整对象。

```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`。

## 3. 当前 VIP 状态

### 接口地址

`GET /api/v1/vip/me`

### 参数

无 query/body 参数。

### 返回值

以下为结构示例，`state` 和 `program_config` 实际返回完整字段。

```json
{
  "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 时也保留，但不会单独授权。

## 4. 购买、续期和升级

### 接口地址

`POST /api/v1/vip/purchase`

### 参数

```json
{
  "command_id": "vip_purchase_10001_4_1780100000000",
  "level": 4
}
```

| 字段 | 说明 |
|---|---|
| `command_id` | 必填，同一次购买的网络重试必须复用同一值；新的购买动作必须生成新值。 |
| `level` | 必填，目标 VIP 等级，必须来自 `packages`。 |

### 返回值

```json
{
  "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`：

```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\",\"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` 为准。

## 5. 体验卡背包列表

### 接口地址

`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_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。

## 6. 佩戴体验卡

### 接口地址

`POST /api/v1/vip/trial-cards/{entitlement_id}/equip`

### 参数

- path `entitlement_id`：必须使用背包列表的实例 ID，不能使用 `resource_id`。
- 无 body。

### 返回值

```json
{
  "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`。

## 7. 卸下体验卡

### 接口地址

`DELETE /api/v1/vip/trial-cards/equipped`

### 参数

无 path/query/body 参数。

### 返回值

```json
{
  "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"`。

## 8. 经理中心发放 VIP

经理中心页面如果复用 Flutter，仍调用原地址，不需要按 Fami/Lalu 写分支。gateway 会读取当前 App 的 `program_config.grant_mode`：Lalu 调直接会员发放，Fami 调体验卡专用发放。

### 接口地址

`POST /api/v1/manager-center/vip-grants`

### 参数

```json
{
  "command_id": "mgr_vip_10001_1780300000000",
  "target_user_id": "10001",
  "level": 5,
  "reason": "manager_center"
}
```

- `command_id` 必填且最长 128 字节；重试必须复用同一个值和同一业务参数。
- 经理中心现有权限仍只允许 VIP1–VIP5，目标用户国家与经理权限校验保持不变。

### 返回值

Lalu（`direct_membership`）直接激活会员：

```json
{
  "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 天体验卡到背包，不自动佩戴：

```json
{
  "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` 重试。

## 9. 进房通知 IM

### 接口地址

进房仍使用现有 `POST /api/v1/rooms/join`。VIP 展示不新增 Flutter HTTP 请求。

### 参数

沿用现有进房参数：

```json
{
  "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 快照：

```json
{
  "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`，不发全服群：

```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\",\"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 查询失败时后端会降级为普通进房，不阻断进房主链路。

## 10. VIP 通知开关

### 接口地址

- `GET /api/v1/vip/settings`
- `PATCH /api/v1/vip/settings`

`GET /api/v1/vip/me` 的 `data.state.user_settings` 与 GET 接口是同一份事实。VIP 页面已经拉取 `/vip/me` 时，不必再为展示开关多发一次请求。

### 参数

GET 无参数。PATCH 是局部更新，两个字段至少传一个；显式 `false` 不能被当成未传：

```json
{
  "room_entry_notice_enabled": false
}
```

```json
{
  "online_global_notice_enabled": true
}
```

Flutter 不传 `app_code` 和 `user_id`，后端只从登录态解析。空对象 `{}` 返回 `INVALID_ARGUMENT`。

### 返回值

GET：

```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
  }
}
```

PATCH：

```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": 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。

## 11. 每日 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 有两种用法：

```json
{
  "rebate_ids": ["vip_rebate_xxx", "vip_rebate_yyy"],
  "page": 1,
  "page_size": 20
}
```

`rebate_ids` 最多 100 个，后端只返回当前登录用户拥有的记录。传空数组或不传时使用 `page/page_size` 查历史，`page_size` 最大 100。

claim：

```json
{
  "command_id": "vip_rebate_claim_10001_20260710"
}
```

`rebate_id` 只来自 current/statuses/系统消息。Flutter 不传金额、VIP 等级、`app_code` 或 `user_id`。

### 返回值

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-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 尚未生成时也是成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_rebate_none",
  "data": {
    "found": false,
    "rebate": null,
    "server_time_ms": 1783641610000
  }
}
```

statuses：

```json
{
  "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：

```json
{
  "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` 的条目示例：

```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\":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`。

## 12. 上线全服通知

### 接口地址

`POST /api/v1/vip/online-notice`

调用前先完成 `GET /api/v1/im/usersig` 的 IM 登录，并按 `join_groups` 加入 `type=global_broadcast` 的全局播报群。Flutter 只请求后端发送，不向全局群自行发消息。

### 参数

```json
{
  "command_id": "vip_online_550e8400-e29b-41d4-a716-446655440000_2026-07-10"
}
```

`command_id` 最长 128，用“进程启动唯一 ID + UTC 日期”生成。同一进程当日网络重试必须复用同一值。

### 返回值

```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": 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 字符串 |

```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`。
- 不把“每日一次”放到服务端持久化去重，否则无法满足杀进程后同日可再展示。

## 13. 权益场景和完成边界

| 场景 | 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/资源前不能宣称已实现 |

## 14. Flutter 建议状态流

1. 进 VIP 页并发请求 `/vip/packages` 和 `/vip/me`，套餐用前者，用户生效状态用后者。
2. 购买成功、佩戴成功、卸下成功时，立即用响应中的 `state` 替换全局 VIP store。
3. App 冷启动、登录成功、回前台以及收到 `vip_notice` 后调 `/vip/me` 校准；同时更新 `user_settings`。
4. 背包页用 `vip_trial_card` 列表展示每张独立卡；当前佩戴态以 `/vip/me.state.equipped_trial_card.entitlement_id` 为准。
5. 所有房间、个人页和消息展示都使用 `effective_vip`，不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时，批量用 statuses 恢复真实状态；领取成功后替换余额与返现状态。
7. IM 登录并加入全局播报群后，按进程内 UTC 日规则触发 `/vip/online-notice`；收到 `vip_online_notice` 时用 `event_id` 去重。
8. 修改通知开关成功后，用 PATCH 响应替换本地 `user_settings`，不修改 `effective_benefits`。
