主播侧实现
This commit is contained in:
parent
ba772242ea
commit
af4b60bba4
@ -598,6 +598,7 @@ func (x *RoomSnapshot) GetVersion() int64 {
|
|||||||
|
|
||||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
// CreateRoomRequest 创建一个新的房间执行单元。
|
||||||
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。
|
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。
|
||||||
|
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||||
type CreateRoomRequest struct {
|
type CreateRoomRequest struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
|||||||
@ -79,6 +79,7 @@ message RoomSnapshot {
|
|||||||
|
|
||||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
// CreateRoomRequest 创建一个新的房间执行单元。
|
||||||
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。
|
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。
|
||||||
|
// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。
|
||||||
message CreateRoomRequest {
|
message CreateRoomRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
// seat_count 必须大于 0;没有麦位的语音房不进入 Room Cell。
|
// seat_count 必须大于 0;没有麦位的语音房不进入 Room Cell。
|
||||||
|
|||||||
479
docs/host-agency-bd-admin-architecture.md
Normal file
479
docs/host-agency-bd-admin-architecture.md
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
# Host, Agency And BD Admin Architecture
|
||||||
|
|
||||||
|
本文只定义后台/Admin 侧实现:政策配置、工资周期、工资单、审核、钱包入账、调整单、后台审计和运营干预。App 侧的主播申请、Agency 成员、BD 邀请和用户可见查询见 [Host Agency BD App Architecture](./host-agency-bd-architecture.md)。
|
||||||
|
|
||||||
|
这里的工资统一指发放到主播侧 `USD_BALANCE` 的业务奖励。提现审核和人工打款仍由 `wallet-service` 拥有,Admin 只负责把审核通过的工资单入账到工资余额,不直接处理线下打款。
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- 后台可以配置 host、agency、bd 三类工资政策,并按区域生效。
|
||||||
|
- 后台可以选择日结、周结、半月结、月结,不把结算周期写死在代码里。
|
||||||
|
- 后台可以生成工资周期,查看每个 host/Agency/BD/BD Leader 的明细来源。
|
||||||
|
- 后台审批后,`host-service` 以幂等方式调用 `wallet-service` 给 `USD_BALANCE` 入账。
|
||||||
|
- 后台可以对迟到事件、人工修正、争议处理创建调整单,不能直接修改已入账工资项。
|
||||||
|
- 后台可以创建或停用 BD Leader、BD、Agency,也可以关闭 Agency;所有动作必须有审计记录。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 不在 Admin 里承载 App 长连接、房间麦位、房间核心状态或 IM 投递。
|
||||||
|
- 不让 `wallet-service` 理解 host/Agency/BD 政策。钱包只接收已审批的入账请求。
|
||||||
|
- 不用 Redis 保存政策、工资周期、工资单或审计事实。Redis 只能做短 TTL 查询缓存和互斥锁兜底。
|
||||||
|
- 不允许后台在不留审计的情况下改关系、改政策、改工资金额。
|
||||||
|
- 不允许已 posted 的工资单原地改金额;必须创建反向或补差调整项。
|
||||||
|
|
||||||
|
## Service Boundaries
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Admin["Admin Console"] --> Gateway["gateway-service"]
|
||||||
|
Gateway --> Host["host-service"]
|
||||||
|
Gateway --> User["user-service"]
|
||||||
|
Gateway --> Wallet["wallet-service"]
|
||||||
|
|
||||||
|
Host --> HostDB[("host MySQL")]
|
||||||
|
Wallet --> WalletDB[("wallet MySQL")]
|
||||||
|
User --> UserDB[("user MySQL")]
|
||||||
|
|
||||||
|
RoomOutbox[("room_outbox")] --> MQ["MQ / event bus"]
|
||||||
|
WalletOutbox[("wallet_outbox")] --> MQ
|
||||||
|
MQ --> Host
|
||||||
|
|
||||||
|
Host --> HostOutbox[("host_outbox")]
|
||||||
|
HostOutbox --> BI["BI / notification / risk"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Admin Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `gateway-service` | Admin HTTP 入口、后台鉴权、request envelope、调用内部 gRPC |
|
||||||
|
| `user-service` | 用户、区域、账号状态查询;区域不由后台请求体伪造 |
|
||||||
|
| `room-service` | 只产出上麦和礼物事件,不接收后台工资命令 |
|
||||||
|
| `wallet-service` | `USD_BALANCE` 入账、提现冻结、提现审核状态和钱包流水 |
|
||||||
|
| `host-service` | 政策、周期、工资单、关系管理、统计聚合、审计、outbox |
|
||||||
|
|
||||||
|
## Admin Permission Model
|
||||||
|
|
||||||
|
后台权限至少拆成四类,避免一个运营账号同时拥有配置政策和发钱能力。
|
||||||
|
|
||||||
|
| Permission | Can Do | Cannot Do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host_relation_admin` | 创建/停用 BD Leader、BD、Agency,关闭 Agency | 审批工资入账 |
|
||||||
|
| `salary_policy_admin` | 创建政策草稿、启停政策、配置周期 | 直接发工资 |
|
||||||
|
| `salary_reviewer` | 查看工资单、驳回、请求重算 | 调用钱包入账 |
|
||||||
|
| `salary_approver` | 审批工资周期、触发 posting | 修改政策和关系 |
|
||||||
|
|
||||||
|
所有后台命令必须写 `admin_audit_logs`。审计记录要包含 `request_id`、`command_id`、管理员用户、目标对象、前后状态摘要和原因。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
admin_audit_logs(
|
||||||
|
audit_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
command_id VARCHAR(96) NOT NULL,
|
||||||
|
admin_user_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
target_type VARCHAR(64) NOT NULL,
|
||||||
|
target_id VARCHAR(96) NOT NULL,
|
||||||
|
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
before_json JSON NULL,
|
||||||
|
after_json JSON NULL,
|
||||||
|
request_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uk_admin_audit_command (command_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Policy Model
|
||||||
|
|
||||||
|
政策必须版本化。工资结算使用周期生成时锁定的 policy snapshot,不能用当前 active policy 重算历史。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
salary_policies(
|
||||||
|
policy_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
policy_type VARCHAR(32) NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
version VARCHAR(64) NOT NULL,
|
||||||
|
effective_from_ms BIGINT NOT NULL,
|
||||||
|
effective_to_ms BIGINT NULL,
|
||||||
|
settlement_cadence VARCHAR(32) NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_salary_policies_region_type (region_id, policy_type, status)
|
||||||
|
);
|
||||||
|
|
||||||
|
salary_policy_rules(
|
||||||
|
rule_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
policy_id BIGINT NOT NULL,
|
||||||
|
level_no INT NOT NULL,
|
||||||
|
min_mic_minutes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
min_gift_points BIGINT NOT NULL DEFAULT 0,
|
||||||
|
min_effective_days INT NOT NULL DEFAULT 0,
|
||||||
|
min_base_salary_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
fixed_salary_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
commission_bps INT NOT NULL DEFAULT 0,
|
||||||
|
metadata_json JSON NULL,
|
||||||
|
UNIQUE KEY uk_salary_policy_level (policy_id, level_no)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | Applies To | Base |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host` | active host work stats | mic minutes, gift points, effective days |
|
||||||
|
| `agency` | Agency owner extra salary | Agency hosts' host salary sum, count, or agency-specific metrics |
|
||||||
|
| `bd` | BD and BD Leader salary | downstream hosts' host salary sum |
|
||||||
|
|
||||||
|
BD 的基数只能是 host salary。Agency salary 和 BD salary 不能递归进入 BD 基数,否则工资会复利膨胀且无法审计。
|
||||||
|
|
||||||
|
## Settlement Cadence
|
||||||
|
|
||||||
|
后台可以按区域和政策类型选择 `daily`、`weekly`、`semi_monthly`、`monthly`。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
salary_cycle_configs(
|
||||||
|
config_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
policy_type VARCHAR(32) NOT NULL,
|
||||||
|
settlement_cadence VARCHAR(32) NOT NULL,
|
||||||
|
timezone VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_salary_cycle_config_scope (region_id, policy_type, status)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
同一区域、同政策类型只允许一个 active config。MySQL 没有 partial unique index 时,不要用 nullable 或 status 唯一键硬凑;在启用配置时用事务锁住同 scope 的 active rows,再做状态切换。
|
||||||
|
|
||||||
|
| Cadence | Rule |
|
||||||
|
| --- | --- |
|
||||||
|
| `daily` | local date 00:00:00 to next date 00:00:00 |
|
||||||
|
| `weekly` | configured week start, default Monday |
|
||||||
|
| `semi_monthly` | day 1-15 and day 16-end |
|
||||||
|
| `monthly` | calendar month |
|
||||||
|
|
||||||
|
计算必须使用配置时区。数据库保存 UTC 毫秒边界,同时保存后台导出需要的展示日期。
|
||||||
|
|
||||||
|
## Payroll Model
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["host_daily_stats"] --> B["salary cycle calculation"]
|
||||||
|
B --> C["host salary items"]
|
||||||
|
C --> D["agency salary items"]
|
||||||
|
C --> E["BD or BD Leader salary items"]
|
||||||
|
C --> F["review"]
|
||||||
|
D --> F
|
||||||
|
E --> F
|
||||||
|
F --> G["approve"]
|
||||||
|
G --> H["wallet CreditSalaryBalance"]
|
||||||
|
H --> I["USD_BALANCE"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
salary_cycles(
|
||||||
|
cycle_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
cycle_type VARCHAR(32) NOT NULL,
|
||||||
|
period_start_ms BIGINT NOT NULL,
|
||||||
|
period_end_ms BIGINT NOT NULL,
|
||||||
|
display_period VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
policy_snapshot_json JSON NOT NULL,
|
||||||
|
source_watermark_ms BIGINT NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
approved_by_user_id BIGINT NULL,
|
||||||
|
posted_at_ms BIGINT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uk_salary_cycle (region_id, cycle_type, period_start_ms, period_end_ms)
|
||||||
|
);
|
||||||
|
|
||||||
|
salary_items(
|
||||||
|
salary_item_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
cycle_id BIGINT NOT NULL,
|
||||||
|
receiver_user_id BIGINT NOT NULL,
|
||||||
|
receiver_role VARCHAR(32) NOT NULL,
|
||||||
|
salary_component VARCHAR(32) NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
relation_scope_key VARCHAR(160) NOT NULL,
|
||||||
|
agency_id BIGINT NULL,
|
||||||
|
bd_user_id BIGINT NULL,
|
||||||
|
bd_leader_user_id BIGINT NULL,
|
||||||
|
policy_id BIGINT NOT NULL,
|
||||||
|
policy_version VARCHAR(64) NOT NULL,
|
||||||
|
hit_level INT NOT NULL,
|
||||||
|
base_host_salary_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
|
mic_minutes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
gift_points BIGINT NOT NULL DEFAULT 0,
|
||||||
|
effective_days INT NOT NULL DEFAULT 0,
|
||||||
|
amount_usd_minor BIGINT NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
|
metadata_json JSON NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uk_salary_item_component (cycle_id, receiver_user_id, salary_component, relation_scope_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
salary_item_sources(
|
||||||
|
salary_item_id BIGINT NOT NULL,
|
||||||
|
source_salary_item_id BIGINT NOT NULL,
|
||||||
|
source_host_user_id BIGINT NOT NULL,
|
||||||
|
source_amount_usd_minor BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (salary_item_id, source_salary_item_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`relation_scope_key` 必须非空,例如 `agency:{agency_id}`、`bd:{bd_user_id}`、`leader:{leader_user_id}`、`none:{region_id}`。不要把 nullable `agency_id` 或 `bd_user_id` 直接放进唯一键后期待 MySQL 帮你保证幂等。
|
||||||
|
|
||||||
|
## Salary Cycle State
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> open
|
||||||
|
open --> calculating
|
||||||
|
calculating --> draft
|
||||||
|
draft --> approved
|
||||||
|
approved --> posting
|
||||||
|
posting --> posted
|
||||||
|
posting --> post_failed
|
||||||
|
post_failed --> posting
|
||||||
|
draft --> voided
|
||||||
|
posted --> adjusted
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `open` | 周期已创建但未计算 |
|
||||||
|
| `calculating` | 正在生成工资项 |
|
||||||
|
| `draft` | 已计算,等待后台审核 |
|
||||||
|
| `approved` | 已审核,可以入账 |
|
||||||
|
| `posting` | 正在调用钱包入账 |
|
||||||
|
| `posted` | 全部工资项已入账 |
|
||||||
|
| `post_failed` | 部分或全部入账失败,可重试 |
|
||||||
|
| `voided` | 入账前作废 |
|
||||||
|
| `adjusted` | 已通过调整项修正 |
|
||||||
|
|
||||||
|
`posted` cycle 不可变。迟到事件和人工修正只能创建 adjustment cycle 或 adjustment salary item。
|
||||||
|
|
||||||
|
## Salary Calculation Rules
|
||||||
|
|
||||||
|
### Host Salary
|
||||||
|
|
||||||
|
输入来自 App 文档定义的 `host_daily_stats`:
|
||||||
|
|
||||||
|
- Sum `mic_publishing_ms` within cycle。
|
||||||
|
- Sum `gift_point_received` within cycle。
|
||||||
|
- Sum `effective_day`。
|
||||||
|
- 使用 stats 中的 `agency_id/bd_user_id/bd_leader_user_id` 关系快照做归属。
|
||||||
|
|
||||||
|
规则匹配:
|
||||||
|
|
||||||
|
```text
|
||||||
|
find highest host policy rule where:
|
||||||
|
mic_minutes >= min_mic_minutes
|
||||||
|
gift_points >= min_gift_points
|
||||||
|
effective_days >= min_effective_days
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 命中的规则产生 `host_salary` item。
|
||||||
|
- `amount_usd_minor = fixed_salary_usd_minor`。
|
||||||
|
- 未命中规则时金额为 0;是否保留 0 元 draft item 由后台报表需求决定。
|
||||||
|
|
||||||
|
### Agency Salary
|
||||||
|
|
||||||
|
Agency owner 有两个收益组件:
|
||||||
|
|
||||||
|
- `host_salary`: owner 自己作为 host 工作产生。
|
||||||
|
- `agency_salary`: owner 作为 Agency 获得的额外收益。
|
||||||
|
|
||||||
|
默认 Agency 基数使用同周期、同 Agency 下所有 host salary item:
|
||||||
|
|
||||||
|
```text
|
||||||
|
agency_base = sum(host_salary.amount_usd_minor where agency_id = agency.agency_id)
|
||||||
|
agency_salary = rule.fixed_salary + agency_base * rule.commission_bps / 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
如果产品需要按礼物积分、有效主播数或开播天数计算 Agency 工资,必须把输入字段显式写进 rule 和 item snapshot,不允许结算时从当前 membership 反推。
|
||||||
|
|
||||||
|
### BD And BD Leader Salary
|
||||||
|
|
||||||
|
BD Leader 也是 BD,所以二者使用同一个 `bd_salary` 组件。`receiver_role` 区分 `bd` 和 `bd_leader`;同一 receiver 和同一来源 host salary 不能同时生成 BD item 和 Leader item。
|
||||||
|
|
||||||
|
普通 BD 基数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bd_source_items = distinct host_salary items where bd_user_id = bd.user_id
|
||||||
|
bd_base = sum(bd_source_items.amount_usd_minor)
|
||||||
|
bd_salary = bd_base * bd_policy.commission_bps / 10000 + fixed_salary
|
||||||
|
```
|
||||||
|
|
||||||
|
BD Leader 基数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
leader_source_items = distinct host_salary items where
|
||||||
|
bd_user_id = leader.user_id
|
||||||
|
or bd_leader_user_id = leader.user_id
|
||||||
|
leader_base = sum(leader_source_items.amount_usd_minor)
|
||||||
|
leader_salary = leader_base * bd_policy.commission_bps / 10000 + fixed_salary
|
||||||
|
```
|
||||||
|
|
||||||
|
Leader 直接拥有 Agency 下线时,推荐快照同时设置 `bd_user_id = leader.user_id` 和 `bd_leader_user_id = leader.user_id`;工资生成按 `source_host_salary.salary_item_id` 去重,所以这批 host 对该 leader 只算一次。下级 BD 拥有的 Agency 则设置 `bd_user_id = bd.user_id`、`bd_leader_user_id = leader.user_id`。
|
||||||
|
|
||||||
|
## Posting To Wallet
|
||||||
|
|
||||||
|
工资入账必须通过 `wallet-service`,不能直接改钱包表。
|
||||||
|
|
||||||
|
1. Admin 审批 cycle。
|
||||||
|
2. `host-service` 查询所有 `approved` salary items。
|
||||||
|
3. 对每个 item 调用 `wallet-service CreditSalaryBalance`,幂等键为 `salary:{salary_item_id}`。
|
||||||
|
4. `wallet-service` credit `USD_BALANCE` 并返回 `wallet_transaction_id`。
|
||||||
|
5. `host-service` 保存 `wallet_transaction_id`,把 item 标记为 `posted`。
|
||||||
|
6. 如果中途失败,只重试未 posted item,幂等键保持不变。
|
||||||
|
|
||||||
|
Wallet metadata 必须包含 `cycle_id`、`salary_item_id`、`receiver_role`、`salary_component`、`policy_id`、`policy_version`、`period_start_ms`、`period_end_ms`。
|
||||||
|
|
||||||
|
## Adjustments
|
||||||
|
|
||||||
|
调整只能补差或冲正,不直接修改 posted item。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
salary_adjustments(
|
||||||
|
adjustment_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
source_cycle_id BIGINT NOT NULL,
|
||||||
|
source_salary_item_id BIGINT NULL,
|
||||||
|
receiver_user_id BIGINT NOT NULL,
|
||||||
|
amount_usd_minor BIGINT NOT NULL,
|
||||||
|
reason VARCHAR(512) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
approved_by_user_id BIGINT NULL,
|
||||||
|
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
调整项也必须走审批和钱包幂等入账。负数调整如果钱包账户余额不足,不能直接扣成负数;应该进入人工处理状态,由提现审核或后续工资抵扣策略处理。
|
||||||
|
|
||||||
|
## Admin APIs
|
||||||
|
|
||||||
|
External HTTP stays in `gateway-service` and must use `/api/v1` envelope.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/admin/host/policies
|
||||||
|
PUT /api/v1/admin/host/policies/{policy_id}
|
||||||
|
POST /api/v1/admin/host/policies/{policy_id}/enable
|
||||||
|
POST /api/v1/admin/host/policies/{policy_id}/disable
|
||||||
|
|
||||||
|
POST /api/v1/admin/salary-cycles/calculate
|
||||||
|
GET /api/v1/admin/salary-cycles
|
||||||
|
GET /api/v1/admin/salary-cycles/{cycle_id}
|
||||||
|
GET /api/v1/admin/salary-cycles/{cycle_id}/items
|
||||||
|
POST /api/v1/admin/salary-cycles/{cycle_id}/approve
|
||||||
|
POST /api/v1/admin/salary-cycles/{cycle_id}/post
|
||||||
|
POST /api/v1/admin/salary-cycles/{cycle_id}/void
|
||||||
|
POST /api/v1/admin/salary-items/{salary_item_id}/adjust
|
||||||
|
|
||||||
|
POST /api/v1/admin/bd-leaders
|
||||||
|
POST /api/v1/admin/bds
|
||||||
|
POST /api/v1/admin/agencies
|
||||||
|
POST /api/v1/admin/agencies/{agency_id}/close
|
||||||
|
POST /api/v1/admin/agencies/{agency_id}/join-enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin APIs require separate admin auth and audit. App users must never call admin policy or settlement endpoints.
|
||||||
|
|
||||||
|
## Internal gRPC Sketch
|
||||||
|
|
||||||
|
```proto
|
||||||
|
service HostAdminService {
|
||||||
|
rpc CreateSalaryPolicy(CreateSalaryPolicyRequest) returns (CreateSalaryPolicyResponse);
|
||||||
|
rpc UpdateSalaryPolicy(UpdateSalaryPolicyRequest) returns (UpdateSalaryPolicyResponse);
|
||||||
|
rpc SetSalaryPolicyStatus(SetSalaryPolicyStatusRequest) returns (SetSalaryPolicyStatusResponse);
|
||||||
|
|
||||||
|
rpc CalculateSalaryCycle(CalculateSalaryCycleRequest) returns (CalculateSalaryCycleResponse);
|
||||||
|
rpc GetSalaryCycle(GetSalaryCycleRequest) returns (GetSalaryCycleResponse);
|
||||||
|
rpc ApproveSalaryCycle(ApproveSalaryCycleRequest) returns (ApproveSalaryCycleResponse);
|
||||||
|
rpc PostSalaryCycle(PostSalaryCycleRequest) returns (PostSalaryCycleResponse);
|
||||||
|
rpc AdjustSalaryItem(AdjustSalaryItemRequest) returns (AdjustSalaryItemResponse);
|
||||||
|
|
||||||
|
rpc CreateBDLeader(CreateBDLeaderRequest) returns (CreateBDLeaderResponse);
|
||||||
|
rpc CreateBD(CreateBDRequest) returns (CreateBDResponse);
|
||||||
|
rpc CreateAgency(CreateAgencyRequest) returns (CreateAgencyResponse);
|
||||||
|
rpc CloseAgency(CloseAgencyRequest) returns (CloseAgencyResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前项目仍在开发阶段,proto 可以按当前事实调整;修改 `api/proto` 后必须运行 `make proto` 并提交生成文件。
|
||||||
|
|
||||||
|
## Concurrency And Idempotency
|
||||||
|
|
||||||
|
- Admin 命令必须携带 `command_id`,同一 `command_id` 重试返回同一结果。
|
||||||
|
- Policy enable 必须锁定同区域、同 type 的 active policy,防止同时启用多个冲突政策。
|
||||||
|
- Cycle create 使用唯一键 `(region_id, cycle_type, period_start_ms, period_end_ms)`。
|
||||||
|
- Cycle calculation 使用 deterministic item key。重跑同一个 `draft` cycle 可以替换 draft items 或返回现有结果,不能重复插入。
|
||||||
|
- Cycle approve 必须锁 cycle 当前状态为 `draft`。
|
||||||
|
- Posting 使用 `salary_item_id` 作为钱包幂等键。`post_failed` 重试只处理未 posted item。
|
||||||
|
- Relationship admin action 要锁目标用户的 `host_profiles`、`agencies`、`bd_profiles`,避免和 App 邀请接受并发造成身份冲突。
|
||||||
|
|
||||||
|
## Event And Outbox
|
||||||
|
|
||||||
|
`host-service` should write its own outbox:
|
||||||
|
|
||||||
|
| Event | When |
|
||||||
|
| --- | --- |
|
||||||
|
| `SalaryPolicyChanged` | policy created, enabled, disabled, or updated |
|
||||||
|
| `SalaryCycleCalculated` | draft generated |
|
||||||
|
| `SalaryCycleApproved` | cycle approved |
|
||||||
|
| `SalaryCyclePosted` | all salary items posted to wallet |
|
||||||
|
| `SalaryItemPosted` | one salary item credited to wallet |
|
||||||
|
| `SalaryAdjustmentCreated` | adjustment draft created |
|
||||||
|
| `SalaryAdjustmentPosted` | adjustment credited or reversed in wallet |
|
||||||
|
| `AdminHostRelationChanged` | admin creates/stops BD/Agency/Leader |
|
||||||
|
|
||||||
|
Outbox consumers can update admin notifications, BI, risk systems, and export jobs. They must not be required for salary facts to commit.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Rule |
|
||||||
|
| --- | --- |
|
||||||
|
| Policy changes mid-cycle | Existing open/draft cycle keeps selected policy snapshot; future cycles use new active policy |
|
||||||
|
| Host changes Agency during cycle | Salary uses `host_daily_stats` relation snapshot and can split across agencies |
|
||||||
|
| Late room event after posted cycle | Create adjustment item; do not mutate posted salary item |
|
||||||
|
| Wallet posting partially fails | Retry unposted items with same idempotency key |
|
||||||
|
| Admin accidentally posts twice | Wallet idempotency must prevent duplicate `USD_BALANCE` credit |
|
||||||
|
| Agency closed mid-cycle | Work before close remains attributable; future App joins/search stop immediately |
|
||||||
|
| User region changed | Existing cycle uses event snapshots; new policy/cycle matching follows stats region |
|
||||||
|
| Negative adjustment exceeds balance | Move to manual handling; do not force wallet negative unless wallet explicitly supports debt |
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./host-agency-bd-implementation-sequence.md)。Admin 文档只保留后台政策、工资、入账、调整和权限审计设计,避免和 App 实施顺序混在一起。
|
||||||
|
|
||||||
|
## Verification Matrix
|
||||||
|
|
||||||
|
| Scenario | Expected |
|
||||||
|
| --- | --- |
|
||||||
|
| Enable two host policies for same region | Second enable rejected or first disabled in same transaction |
|
||||||
|
| Calculate daily cycle twice | Same draft result; no duplicate salary items |
|
||||||
|
| Host meets highest rule | Highest matching level selected |
|
||||||
|
| Agency salary generated | Base uses host salary items under that Agency only |
|
||||||
|
| BD Leader owns direct Agency | Source host salary counted once for the leader |
|
||||||
|
| BD under Leader owns Agency | Host salary contributes to BD and leader according to snapshot |
|
||||||
|
| Cycle posted twice | Wallet idempotency prevents duplicate `USD_BALANCE` credit |
|
||||||
|
| Posting fails after partial success | Retry posts only unposted items |
|
||||||
|
| Posted item needs correction | Adjustment item created; original item remains immutable |
|
||||||
|
| Admin closes Agency | Agency hidden from App search; existing facts remain auditable |
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- Admin owns policies and salary cycles; App owns user-initiated relationship flows.
|
||||||
|
- Wallet is the only owner of `USD_BALANCE` and withdrawal state.
|
||||||
|
- Posted salary items are immutable.
|
||||||
|
- BD/BD Leader base is downstream host salary only.
|
||||||
|
- Leader direct Agency salary source must be de-duplicated.
|
||||||
|
- Every admin mutation must be audited and idempotent.
|
||||||
|
- Redis cannot be the source of truth for policies, salary, relationships, or audit.
|
||||||
558
docs/host-agency-bd-architecture.md
Normal file
558
docs/host-agency-bd-architecture.md
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
# Host, Agency And BD App Architecture
|
||||||
|
|
||||||
|
本文只定义 App 侧的主播组织、关系和用户可操作流程。这里的 `host` 指业务主播,不等同于 `room-service` 里的房间 `host_user_id`。房间 `host_user_id` 是单个房间的主持/管理身份;本文的主播身份是用户长期经营身份,可以有 Agency 归属和收益展示。
|
||||||
|
|
||||||
|
后台政策、工资周期、工资单审批、钱包入账和调整单实现单独放在 [Host Agency BD Admin Architecture](./host-agency-bd-admin-architecture.md)。App 侧可以读取收益和结算状态,但不能配置政策、触发结算或直接入账。
|
||||||
|
|
||||||
|
旧 Java 项目只作为术语和历史口径参考。新架构按当前 Go 服务边界重新设计:`user-service` 仍然负责用户和区域,`room-service` 只产生房间事件,`wallet-service` 负责余额和提现,新增 `host-service` 负责主播、Agency、BD 层级、申请邀请和 App 查询读模型。
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- 用户申请加入 Agency 并通过后成为主播;Agency 踢出用户只解除 Agency 归属,不取消主播身份。
|
||||||
|
- 用户只能搜索并申请加入自己所属区域的 active Agency。
|
||||||
|
- Agency 归属于 BD;BD 归属于 BD Leader。BD Leader 也是 BD,只是具备邀请 BD 的能力。
|
||||||
|
- BD 可以邀请用户成为 Agency;BD Leader 可以邀请用户成为 BD,也可以直接邀请用户成为 Agency。
|
||||||
|
- 用户被邀请成为 Agency 时自动成为主播;但已是主播的用户不能再成为 Agency。
|
||||||
|
- BD 默认不是主播;BD 可以加入别的 Agency 成为主播,也可以在未成为主播时邀请自己成为自己的 Agency。
|
||||||
|
- App 侧可以展示 host 统计、Agency 成员、申请、邀请和后台已经生成的收益结果。
|
||||||
|
- App 侧不能创建政策、计算工资周期、审核工资单或直接给 `USD_BALANCE` 入账。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 不把主播组织关系写进 `room-service`。Room Cell 仍然只拥有房间状态、麦位、presence、榜单和房间 outbox。
|
||||||
|
- 不在本文设计后台政策、工资周期、工资单审批或人工调整;这些属于 Admin 文档。
|
||||||
|
- 不用 Redis 保存主播身份、Agency 归属或收益事实。Redis 只能做短 TTL 读缓存或限流。
|
||||||
|
- 不在客户端自报区域、身份或政策。区域来自 `user-service.users.region_id`,身份和政策来自 `host-service`。
|
||||||
|
- 不让关系变更追溯改历史统计。每个工作事件保存当时的关系快照,后续后台结算按快照读取。
|
||||||
|
|
||||||
|
## Vocabulary
|
||||||
|
|
||||||
|
| Term | Meaning | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host` | 业务主播身份,长期有效,可没有 Agency | `host-service` |
|
||||||
|
| `agency` | 代理组织,由一个用户拥有,拥有者自动是 host | `host-service` |
|
||||||
|
| `agency owner` | Agency 的拥有者用户,既是 Agency 身份也是 host | `host-service` |
|
||||||
|
| `BD` | 拓展 Agency 的业务角色,默认不是 host | `host-service` |
|
||||||
|
| `BD Leader` | BD 的上级,同时也是 BD,可以邀请 BD 和 Agency | `host-service` |
|
||||||
|
| `agency application` | 用户申请加入 Agency 的 App 流程 | `host-service` |
|
||||||
|
| `role invitation` | BD/BD Leader 邀请用户成为 Agency 或 BD 的 App 流程 | `host-service` |
|
||||||
|
| `earning summary` | 后台已结算或待结算收益的 App 展示读模型 | `host-service` reads, `wallet-service` owns balance |
|
||||||
|
|
||||||
|
## Service Boundaries
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Client["Client"] --> Gateway["gateway-service"]
|
||||||
|
|
||||||
|
Gateway --> User["user-service"]
|
||||||
|
Gateway --> Host["host-service"]
|
||||||
|
Gateway --> Room["room-service"]
|
||||||
|
Gateway --> Wallet["wallet-service"]
|
||||||
|
|
||||||
|
Room --> RoomOutbox[("room_outbox")]
|
||||||
|
Wallet --> WalletOutbox[("wallet_outbox")]
|
||||||
|
|
||||||
|
RoomOutbox --> MQ["MQ / event bus"]
|
||||||
|
WalletOutbox --> MQ
|
||||||
|
MQ --> Host
|
||||||
|
|
||||||
|
Host --> HostDB[("host MySQL")]
|
||||||
|
User --> UserDB[("user MySQL")]
|
||||||
|
Wallet --> WalletDB[("wallet MySQL")]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `gateway-service` | HTTP 入口、鉴权、request envelope、调用内部 gRPC |
|
||||||
|
| `user-service` | 用户主数据、国家、区域、账号状态 |
|
||||||
|
| `room-service` | 房间事件:上麦、确认发流、下麦、送礼、离房 |
|
||||||
|
| `wallet-service` | `USD_BALANCE`、收益余额、提现冻结/审核/出账 |
|
||||||
|
| `host-service` | host 身份、Agency、BD 层级、申请邀请、关系快照、App 查询读模型 |
|
||||||
|
|
||||||
|
`host-service` 读取 `room-service` 和 `wallet-service` 事件,但不反向修改房间状态。App 查询只读取当前身份、关系、申请、邀请、统计和后台已生成的收益结果;结算写入链路见 Admin 文档。
|
||||||
|
|
||||||
|
## Identity Rules
|
||||||
|
|
||||||
|
### Host
|
||||||
|
|
||||||
|
- 用户首次申请加入 Agency 并审核通过时,创建 `host_profiles`。
|
||||||
|
- `host_profiles.status=active` 表示用户拥有主播身份。
|
||||||
|
- Agency 踢出 host 后,`host_profiles.status` 保持 `active`,只结束当前 membership。
|
||||||
|
- 没有 Agency 的 host 可以重新申请加入新的 Agency。
|
||||||
|
- host 可提现金额以 `wallet-service` 的余额和提现状态为准,不放在 Agency membership 表里。
|
||||||
|
|
||||||
|
### Agency
|
||||||
|
|
||||||
|
- 用户被 BD 或 BD Leader 邀请成为 Agency 并接受后,创建 `agencies`。
|
||||||
|
- 被邀请人成为 Agency owner 时自动创建 host 身份。
|
||||||
|
- 如果被邀请人已经是 active host,则不能成为 Agency。该规则避免一个普通 host 直接升级成 Agency 后历史收益和归属口径混乱。
|
||||||
|
- Agency owner 的收益展示来自后台结算读模型;App 侧只展示结果和状态,不计算金额。
|
||||||
|
- Agency owner 不能被自己的 Agency 踢出;关闭 Agency 只能由有权限的 BD/BD Leader 或后台操作。
|
||||||
|
|
||||||
|
### BD
|
||||||
|
|
||||||
|
- BD 是业务拓展角色,默认不是 host。
|
||||||
|
- BD 可以申请加入别的 Agency 成为 host。此时 BD 身份和 host 身份并存。
|
||||||
|
- BD 也可以邀请自己成为自己的 Agency,但前提是该 BD 当前不是 active host,也没有 active Agency。
|
||||||
|
- BD 可以邀请用户成为自己的 Agency。
|
||||||
|
- BD 不能邀请 BD;邀请 BD 是 BD Leader 能力。
|
||||||
|
|
||||||
|
### BD Leader
|
||||||
|
|
||||||
|
- BD Leader 是 BD 的一种更高权限形态。
|
||||||
|
- BD Leader 可以邀请用户成为 BD。
|
||||||
|
- BD Leader 可以直接邀请用户成为 Agency;这种 Agency 的上级 BD 记为该 BD Leader 自己。
|
||||||
|
- BD Leader 的收益展示来自后台结算读模型;App 侧只展示结果和状态,不计算金额。
|
||||||
|
|
||||||
|
## Relationship Model
|
||||||
|
|
||||||
|
关系是有生效时间的事实,不应该只保存当前字段。当前字段用于 App 查询快照,历史表用于审计和后台结算。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
USERS ||--o| HOST_PROFILES : "may become"
|
||||||
|
USERS ||--o| BD_PROFILES : "may become"
|
||||||
|
USERS ||--o| AGENCIES : "owns"
|
||||||
|
AGENCIES ||--o{ AGENCY_MEMBERSHIPS : "has hosts"
|
||||||
|
BD_PROFILES ||--o{ AGENCIES : "manages"
|
||||||
|
BD_PROFILES ||--o{ BD_PROFILES : "leader manages bd"
|
||||||
|
|
||||||
|
HOST_PROFILES {
|
||||||
|
bigint user_id PK
|
||||||
|
string status
|
||||||
|
bigint region_id
|
||||||
|
bigint current_agency_id
|
||||||
|
bigint current_membership_id
|
||||||
|
bigint first_became_host_at_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENCIES {
|
||||||
|
bigint agency_id PK
|
||||||
|
bigint owner_user_id UK
|
||||||
|
bigint region_id
|
||||||
|
bigint parent_bd_user_id
|
||||||
|
string status
|
||||||
|
bool join_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
BD_PROFILES {
|
||||||
|
bigint user_id PK
|
||||||
|
string role
|
||||||
|
bigint region_id
|
||||||
|
bigint parent_leader_user_id
|
||||||
|
string status
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENCY_MEMBERSHIPS {
|
||||||
|
bigint membership_id PK
|
||||||
|
bigint agency_id
|
||||||
|
bigint host_user_id
|
||||||
|
string membership_type
|
||||||
|
string status
|
||||||
|
bigint joined_at_ms
|
||||||
|
bigint ended_at_ms
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
host_profiles(
|
||||||
|
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
current_agency_id BIGINT NULL,
|
||||||
|
current_membership_id BIGINT NULL,
|
||||||
|
source VARCHAR(64) NOT NULL,
|
||||||
|
first_became_host_at_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_host_profiles_region_status (region_id, status),
|
||||||
|
KEY idx_host_profiles_agency (current_agency_id, status)
|
||||||
|
);
|
||||||
|
|
||||||
|
agencies(
|
||||||
|
agency_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
owner_user_id BIGINT NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
parent_bd_user_id BIGINT NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
join_enabled BOOLEAN NOT NULL,
|
||||||
|
max_hosts INT NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uk_agencies_owner (owner_user_id),
|
||||||
|
KEY idx_agencies_region_status (region_id, status, join_enabled),
|
||||||
|
KEY idx_agencies_parent_bd (parent_bd_user_id, status)
|
||||||
|
);
|
||||||
|
|
||||||
|
bd_profiles(
|
||||||
|
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
parent_leader_user_id BIGINT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_bd_profiles_region_role (region_id, role, status),
|
||||||
|
KEY idx_bd_profiles_parent_leader (parent_leader_user_id, status)
|
||||||
|
);
|
||||||
|
|
||||||
|
agency_memberships(
|
||||||
|
membership_id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
agency_id BIGINT NOT NULL,
|
||||||
|
host_user_id BIGINT NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
membership_type VARCHAR(32) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
joined_at_ms BIGINT NOT NULL,
|
||||||
|
ended_at_ms BIGINT NULL,
|
||||||
|
ended_by_user_id BIGINT NULL,
|
||||||
|
ended_reason VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
KEY idx_agency_memberships_agency_status (agency_id, status),
|
||||||
|
KEY idx_agency_memberships_host_status (host_user_id, status),
|
||||||
|
KEY idx_agency_memberships_effective (host_user_id, joined_at_ms, ended_at_ms)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- `host_profiles.current_agency_id` 只能指向 active membership 的 Agency。
|
||||||
|
- 同一 host 同时最多一个 active membership。MySQL 可以用应用事务加 `FOR UPDATE` 锁 `host_profiles` 实现;如果使用支持 partial index 的数据库再加唯一索引。
|
||||||
|
- Agency owner 自动生成一条 `agency_memberships.membership_type=owner` 记录,用于把 Agency owner 的 host 工作收入归属到自己的 Agency。
|
||||||
|
- 普通成员使用 `membership_type=member`。
|
||||||
|
- 关系变更只结束旧记录并创建新记录,不原地改历史 `joined_at_ms`。
|
||||||
|
|
||||||
|
## Region Rules
|
||||||
|
|
||||||
|
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。
|
||||||
|
|
||||||
|
| Action | Region Rule |
|
||||||
|
| --- | --- |
|
||||||
|
| 搜索 Agency | 只返回 `agency.region_id == user.region_id` 且 active/join_enabled |
|
||||||
|
| 申请加入 Agency | 申请人 `region_id` 必须等于 Agency `region_id` |
|
||||||
|
| BD 邀请 Agency | BD 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||||
|
| Leader 邀请 BD | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||||
|
| Leader 邀请 Agency | Leader 和被邀请人必须在同一区域,除非后台显式跨区授权 |
|
||||||
|
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属;新申请按新区域限制,历史统计按事件快照归属 |
|
||||||
|
|
||||||
|
如果后续业务需要跨区 Agency 或跨区 BD,必须增加显式 `region_scope` 和后台审批,不能绕过区域规则直接查全局 Agency。
|
||||||
|
|
||||||
|
## Application And Invitation Flows
|
||||||
|
|
||||||
|
### User Applies To Agency
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant G as gateway-service
|
||||||
|
participant U as user-service
|
||||||
|
participant H as host-service
|
||||||
|
|
||||||
|
C->>G: GET /agencies/search
|
||||||
|
G->>U: GetUserRegion(user_id)
|
||||||
|
G->>H: SearchAgencies(region_id)
|
||||||
|
H-->>G: active agencies in region
|
||||||
|
G-->>C: agency list
|
||||||
|
|
||||||
|
C->>G: POST /host/applications
|
||||||
|
G->>U: GetUserRegion(user_id)
|
||||||
|
G->>H: ApplyToAgency(user_id, agency_id, region_id)
|
||||||
|
H->>H: lock user host profile and agency
|
||||||
|
H->>H: create pending application
|
||||||
|
H-->>G: application
|
||||||
|
```
|
||||||
|
|
||||||
|
Approval:
|
||||||
|
|
||||||
|
1. Agency owner or authorized Agency manager reviews pending application.
|
||||||
|
2. `host-service` locks application, host profile and agency.
|
||||||
|
3. If host profile does not exist, create `host_profiles(status=active)`.
|
||||||
|
4. If host has no active membership, create `agency_memberships(status=active)`.
|
||||||
|
5. Update `host_profiles.current_agency_id/current_membership_id`.
|
||||||
|
6. Emit `HostAgencyMembershipChanged`.
|
||||||
|
|
||||||
|
Rejection only changes application status; it does not create host identity.
|
||||||
|
|
||||||
|
### Agency Kicks Host
|
||||||
|
|
||||||
|
```text
|
||||||
|
AgencyKickHost
|
||||||
|
-> lock membership and host profile
|
||||||
|
-> membership.status = ended
|
||||||
|
-> ended_reason = kicked_by_agency
|
||||||
|
-> host_profiles.current_agency_id = null
|
||||||
|
-> host_profiles.status remains active
|
||||||
|
```
|
||||||
|
|
||||||
|
Kick must not delete `host_profiles` and must not delete relationship history. The host can apply to another Agency immediately unless product adds a cooldown.
|
||||||
|
|
||||||
|
### BD Invites Agency
|
||||||
|
|
||||||
|
```text
|
||||||
|
BDInviteAgency(target_user_id)
|
||||||
|
-> target must not be active host
|
||||||
|
-> target must not own active agency
|
||||||
|
-> target region must match BD region
|
||||||
|
-> create role_invitation(type=agency, inviter_bd_user_id)
|
||||||
|
-> target accepts
|
||||||
|
-> create agency owned by target
|
||||||
|
-> create host profile for target
|
||||||
|
-> create owner membership in that agency
|
||||||
|
```
|
||||||
|
|
||||||
|
BD can invite self only when:
|
||||||
|
|
||||||
|
- inviter is active BD;
|
||||||
|
- `target_user_id == inviter_user_id`;
|
||||||
|
- BD has no active host profile;
|
||||||
|
- BD does not already own an active Agency.
|
||||||
|
|
||||||
|
### BD Leader Invites BD
|
||||||
|
|
||||||
|
```text
|
||||||
|
LeaderInviteBD(target_user_id)
|
||||||
|
-> inviter must be active bd_leader
|
||||||
|
-> target must not be active BD
|
||||||
|
-> target region must match leader region
|
||||||
|
-> create role_invitation(type=bd)
|
||||||
|
-> target accepts
|
||||||
|
-> create bd_profiles(role=bd, parent_leader_user_id=leader)
|
||||||
|
```
|
||||||
|
|
||||||
|
Becoming BD does not create host identity.
|
||||||
|
|
||||||
|
### BD Leader Invites Agency
|
||||||
|
|
||||||
|
Leader inviting Agency uses the same Agency creation flow as BD, but `parent_bd_user_id` is the leader's own user ID because BD Leader is also BD for team attribution.
|
||||||
|
|
||||||
|
## Metrics And Event Attribution
|
||||||
|
|
||||||
|
App 统计和后台结算都不能直接从当前 membership 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。
|
||||||
|
|
||||||
|
### Input Events
|
||||||
|
|
||||||
|
| Event | Source | Use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦,计算有效上麦时长 |
|
||||||
|
| `RoomGiftSent` | `room-service` outbox | 房间展示和用户贡献统计 |
|
||||||
|
| `WalletGiftDebited` | `wallet-service` outbox | 权威 `gift_point_added` 和价格版本 |
|
||||||
|
| `HostAgencyMembershipChanged` | `host-service` outbox | 关系变化审计和缓存失效 |
|
||||||
|
|
||||||
|
有效上麦时长只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计有效工作时长,避免用户占麦但 RTC 没有成功发流时产生收益口径。
|
||||||
|
|
||||||
|
### Daily Stats
|
||||||
|
|
||||||
|
```sql
|
||||||
|
host_daily_stats(
|
||||||
|
stat_date DATE NOT NULL,
|
||||||
|
host_user_id BIGINT NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL,
|
||||||
|
relation_scope_key VARCHAR(160) NOT NULL,
|
||||||
|
agency_id BIGINT NULL,
|
||||||
|
bd_user_id BIGINT NULL,
|
||||||
|
bd_leader_user_id BIGINT NULL,
|
||||||
|
mic_publishing_ms BIGINT NOT NULL,
|
||||||
|
gift_point_received BIGINT NOT NULL,
|
||||||
|
coin_received_value BIGINT NOT NULL,
|
||||||
|
effective_day TINYINT NOT NULL,
|
||||||
|
event_watermark_ms BIGINT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (stat_date, host_user_id, relation_scope_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
host_event_dedup(
|
||||||
|
event_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
occurred_at_ms BIGINT NOT NULL,
|
||||||
|
consumed_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `agency_id/bd_user_id/bd_leader_user_id` 是事件发生时的关系快照。
|
||||||
|
- `relation_scope_key` 必须是非空稳定值,例如 `agency:{agency_id}` 或 `none:{region_id}`,避免 MySQL 在 nullable key 上无法保证无 Agency host 的幂等聚合。
|
||||||
|
- host 一天内换 Agency 时,同一天可以有多行 stats,按不同 Agency 拆分。
|
||||||
|
- `effective_day` 由政策解释,例如当天 `publishing >= 60 minutes` 记 1 天;不要硬编码在统计表结构里。
|
||||||
|
- 所有事件消费必须按 `event_id` 幂等。
|
||||||
|
- 迟到事件可以修正未锁定 stats;已生成收益结果的修正由 Admin 调整单处理,App 只读取最终状态。
|
||||||
|
|
||||||
|
## App Read Models
|
||||||
|
|
||||||
|
App 侧不要实时扫描关系历史表。`host-service` 应该维护面向 App 的读模型,写主事实表时同事务更新,事件消费失败时可以通过后台修复任务重建。
|
||||||
|
|
||||||
|
| Read Model | Purpose | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `host_me_view` | 当前用户是否 host、当前 Agency、申请状态、今日统计 | `host_profiles`、`agency_memberships`、`agency_applications`、`host_daily_stats` |
|
||||||
|
| `agency_member_view` | Agency owner 查看成员、成员状态、今日/本周期统计 | `agency_memberships`、`host_daily_stats` |
|
||||||
|
| `bd_team_view` | BD 查看下级 Agency 和邀请状态 | `bd_profiles`、`agencies`、`role_invitations` |
|
||||||
|
| `earning_summary_view` | App 展示后台已生成收益、可提现余额入口 | Admin 工资单只读结果、`wallet-service` balance |
|
||||||
|
|
||||||
|
收益展示只能读后台已经生成的工资项和钱包余额。App 不能把本地统计直接当成可提现金额,也不能根据客户端时间自行推导工资。
|
||||||
|
|
||||||
|
## State Machines
|
||||||
|
|
||||||
|
### Agency Application
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending
|
||||||
|
pending --> approved
|
||||||
|
pending --> rejected
|
||||||
|
pending --> cancelled
|
||||||
|
pending --> expired
|
||||||
|
approved --> [*]
|
||||||
|
rejected --> [*]
|
||||||
|
cancelled --> [*]
|
||||||
|
expired --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one pending application per user is allowed. A user with active Agency membership cannot create a new application.
|
||||||
|
|
||||||
|
### Role Invitation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending
|
||||||
|
pending --> accepted
|
||||||
|
pending --> rejected
|
||||||
|
pending --> cancelled
|
||||||
|
pending --> expired
|
||||||
|
accepted --> [*]
|
||||||
|
rejected --> [*]
|
||||||
|
cancelled --> [*]
|
||||||
|
expired --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepting an invitation executes the role creation in the same transaction as invitation status update.
|
||||||
|
|
||||||
|
## API Surface
|
||||||
|
|
||||||
|
External HTTP stays in `gateway-service`. Internal services use gRPC + protobuf.
|
||||||
|
|
||||||
|
### Public App APIs
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/host/agencies/search
|
||||||
|
GET /api/v1/host/me
|
||||||
|
POST /api/v1/host/agency-applications
|
||||||
|
POST /api/v1/host/agency-applications/{application_id}/cancel
|
||||||
|
GET /api/v1/host/agency-applications/me
|
||||||
|
|
||||||
|
GET /api/v1/agency/me
|
||||||
|
GET /api/v1/agency/members
|
||||||
|
GET /api/v1/agency/applications
|
||||||
|
POST /api/v1/agency/applications/{application_id}/approve
|
||||||
|
POST /api/v1/agency/applications/{application_id}/reject
|
||||||
|
POST /api/v1/agency/members/{host_user_id}/kick
|
||||||
|
|
||||||
|
GET /api/v1/bd/me
|
||||||
|
GET /api/v1/bd/agencies
|
||||||
|
POST /api/v1/bd/invitations/agency
|
||||||
|
POST /api/v1/bd/invitations/{invitation_id}/cancel
|
||||||
|
POST /api/v1/bd/invitations/{invitation_id}/accept
|
||||||
|
POST /api/v1/bd/invitations/{invitation_id}/reject
|
||||||
|
|
||||||
|
POST /api/v1/bd-leader/invitations/bd
|
||||||
|
POST /api/v1/bd-leader/invitations/agency
|
||||||
|
```
|
||||||
|
|
||||||
|
App API 必须使用 `/api/v1` envelope。用户身份来自 gateway 鉴权,`request_id` 只做链路追踪,不做幂等键;业务命令必须使用客户端生成或服务端下发的 `command_id`。
|
||||||
|
|
||||||
|
### Internal gRPC Sketch
|
||||||
|
|
||||||
|
```proto
|
||||||
|
service HostService {
|
||||||
|
rpc SearchAgencies(SearchAgenciesRequest) returns (SearchAgenciesResponse);
|
||||||
|
rpc ApplyToAgency(ApplyToAgencyRequest) returns (ApplyToAgencyResponse);
|
||||||
|
rpc ReviewAgencyApplication(ReviewAgencyApplicationRequest) returns (ReviewAgencyApplicationResponse);
|
||||||
|
rpc KickAgencyHost(KickAgencyHostRequest) returns (KickAgencyHostResponse);
|
||||||
|
|
||||||
|
rpc InviteAgency(InviteAgencyRequest) returns (InviteAgencyResponse);
|
||||||
|
rpc InviteBD(InviteBDRequest) returns (InviteBDResponse);
|
||||||
|
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
|
||||||
|
|
||||||
|
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
||||||
|
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
||||||
|
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
||||||
|
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
|
||||||
|
rpc GetHostEarningSummary(GetHostEarningSummaryRequest) returns (GetHostEarningSummaryResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前仍处于开发阶段,proto 可以按当前事实直接调整;生成代码后必须运行 `make proto`。
|
||||||
|
|
||||||
|
## Concurrency And Idempotency
|
||||||
|
|
||||||
|
- Application create uses `command_id` and `(applicant_user_id, status=pending)` guard.
|
||||||
|
- Invitation create uses `command_id` and pending invitation uniqueness per target and invitation type.
|
||||||
|
- Application approval locks application, target host profile and agency membership in one MySQL transaction.
|
||||||
|
- Agency kick locks active membership and host profile.
|
||||||
|
- Role invitation accept locks target user role rows: `host_profiles`, `agencies`, `bd_profiles`.
|
||||||
|
- App 查询接口不加写锁;读模型允许短暂延迟,但命令返回必须以事务提交后的事实为准。
|
||||||
|
|
||||||
|
## Event And Outbox
|
||||||
|
|
||||||
|
`host-service` should write its own outbox:
|
||||||
|
|
||||||
|
| Event | When |
|
||||||
|
| --- | --- |
|
||||||
|
| `HostCreated` | user first becomes host |
|
||||||
|
| `AgencyCreated` | invitation accepted and Agency created |
|
||||||
|
| `AgencyMembershipChanged` | host joins, leaves, or is kicked |
|
||||||
|
| `BDCreated` | user accepts BD invitation |
|
||||||
|
| `BDLeaderCreated` | admin or leader creation |
|
||||||
|
|
||||||
|
Outbox consumers can update search indexes, notifications, BI, admin views, and risk systems. They must not be required for core relationship facts to commit.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Rule |
|
||||||
|
| --- | --- |
|
||||||
|
| Host kicked from Agency mid-cycle | Work before kick belongs to old Agency; work after kick has no Agency until rejoined |
|
||||||
|
| Host joins new Agency mid-cycle | Stats split by relation snapshot; 后台收益可以按多个 Agency 归属拆分 |
|
||||||
|
| User becomes host then receives Agency invite | Reject invite because active host cannot become Agency |
|
||||||
|
| BD joins another Agency | BD profile remains active; host membership is separate |
|
||||||
|
| BD wants own Agency after joining another Agency | Must first leave or be removed from active host membership; then self-invite can create Agency |
|
||||||
|
| Agency owner wants to leave own Agency | Not allowed through normal leave; Agency close or transfer is backend ability |
|
||||||
|
| User region changes | Existing relationships remain; new search/application uses new region; historical stats use event snapshots |
|
||||||
|
| App earning summary and wallet balance differ | Wallet balance is authority for withdrawable amount; earning summary is business explanation |
|
||||||
|
| Client retries application or invitation action | Same `command_id` returns existing result; different `command_id` must hit uniqueness guard |
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./host-agency-bd-implementation-sequence.md)。App 文档只保留 App 侧目标、边界、流程和验收口径,避免和后台工资实施顺序重复。
|
||||||
|
|
||||||
|
## Verification Matrix
|
||||||
|
|
||||||
|
| Scenario | Expected |
|
||||||
|
| --- | --- |
|
||||||
|
| User searches Agency | Only same-region active joinable agencies returned |
|
||||||
|
| User applies and Agency approves | User becomes active host and active Agency member |
|
||||||
|
| Agency kicks host | Membership ends, host profile remains active |
|
||||||
|
| Kicked host reapplies to another Agency | New active membership created |
|
||||||
|
| Active host accepts Agency invitation | Rejected |
|
||||||
|
| BD invites self as Agency while not host | Agency created, BD also becomes host and Agency owner |
|
||||||
|
| BD joins another Agency | BD remains BD and becomes host under that Agency |
|
||||||
|
| BD Leader invites BD | Target gets BD profile, not host profile |
|
||||||
|
| Host works across two Agencies in one day | Stats split by relation snapshot |
|
||||||
|
| App reads earning summary before backend settlement | Shows pending/unsettled state, not withdrawable money |
|
||||||
|
| Same application command retried | Returns the original pending application |
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- `host` identity is durable; Agency membership is detachable.
|
||||||
|
- Agency owner is a host, but ordinary active host cannot become Agency.
|
||||||
|
- BD and host identities can coexist; BD role alone does not imply host.
|
||||||
|
- BD Leader is a BD with extra invitation rights.
|
||||||
|
- Region constraints are enforced by server-side `region_id`.
|
||||||
|
- App cannot configure policies, approve salary, post wallet balance, or mutate withdrawals.
|
||||||
|
- Wallet is the only owner of salary balance and withdrawal state.
|
||||||
|
- All relationship changes must be auditable and idempotent.
|
||||||
176
docs/host-agency-bd-implementation-sequence.md
Normal file
176
docs/host-agency-bd-implementation-sequence.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# Host, Agency And BD Implementation Sequence
|
||||||
|
|
||||||
|
本文定义主播、Agency、BD、BD Leader 功能的开发顺序。详细 App 架构见 [Host Agency BD App Architecture](./host-agency-bd-architecture.md),后台工资和政策架构见 [Host Agency BD Admin Architecture](./host-agency-bd-admin-architecture.md)。
|
||||||
|
|
||||||
|
核心原则:先做关系事实,再做 App 主链路,最后做统计和工资。工资依赖 host、Agency、BD 关系快照和有效上麦/礼物统计;如果先做工资后台,会得到一套没有可靠输入的数据系统。
|
||||||
|
|
||||||
|
## Phase 1: Host Service Core
|
||||||
|
|
||||||
|
先建立 `host-service` 的核心事实模型。这个阶段不追求完整后台和完整 App,只保证身份、关系、幂等、审计和 outbox 的地基正确。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 新增 `host-service` skeleton:配置、gRPC server、healthcheck、MySQL repository、docker-compose 本地运行入口。
|
||||||
|
- 新增 protobuf:host profile、Agency、BD profile、Agency membership、application、role invitation 的内部 gRPC 契约。
|
||||||
|
- 新增 MySQL 表:`host_profiles`、`agencies`、`bd_profiles`、`agency_memberships`、`agency_applications`、`role_invitations`、`host_outbox`。
|
||||||
|
- 实现 command id 幂等:申请、邀请、审核、踢出、接受邀请都不能因重试重复创建事实。
|
||||||
|
- 实现关系锁:涉及用户身份变化时锁 `host_profiles`、`agencies`、`bd_profiles` 和 active membership。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 同一用户不能同时存在两个 active Agency membership。
|
||||||
|
- active host 不能接受成为 Agency 的邀请。
|
||||||
|
- Agency owner 自动拥有 host profile 和 owner membership。
|
||||||
|
- `go test ./services/host-service/...` 通过。
|
||||||
|
- 修改 protobuf 后必须 `make proto && go test ./...`。
|
||||||
|
|
||||||
|
## Phase 2: Minimal Admin For Organization Seed
|
||||||
|
|
||||||
|
先做最小后台组织初始化能力,不做完整工资后台。App 需要有 Agency/BD 数据才能跑搜索、申请和邀请主链路。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- Admin 创建/停用 BD Leader。
|
||||||
|
- Admin 创建/停用 BD,并绑定 `parent_leader_user_id`。
|
||||||
|
- Admin 创建/关闭 Agency,并绑定 `parent_bd_user_id` 和 owner。
|
||||||
|
- Admin 设置 Agency `join_enabled`。
|
||||||
|
- Admin 关系操作写 `admin_audit_logs`,保留操作人、原因、前后状态和 `command_id`。
|
||||||
|
|
||||||
|
暂不做:
|
||||||
|
|
||||||
|
- 不做 salary policy。
|
||||||
|
- 不做 salary cycle。
|
||||||
|
- 不做工资单审批。
|
||||||
|
- 不做 wallet 入账。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- Admin 创建的 Agency 可以被同区域用户搜索到。
|
||||||
|
- 关闭或禁用加入的 Agency 不出现在 App 搜索结果。
|
||||||
|
- 所有 Admin 关系变更都有审计记录。
|
||||||
|
- 同一 `command_id` 重试不会重复创建 BD/Agency。
|
||||||
|
|
||||||
|
## Phase 3: App Main Flow
|
||||||
|
|
||||||
|
这个阶段跑通用户真实路径。完成后,主播身份和组织关系才成为可靠事实。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 用户搜索同区域 active、joinable Agency。
|
||||||
|
- 用户申请加入 Agency。
|
||||||
|
- Agency owner 或授权管理者审核、拒绝申请。
|
||||||
|
- Agency 踢出 host:只结束 membership,不删除 host identity。
|
||||||
|
- Kicked host 可以重新申请其他 Agency。
|
||||||
|
- BD 邀请用户成为 Agency。
|
||||||
|
- BD 自己邀请自己成为 Agency:仅限 BD 当前不是 active host 且没有 active Agency。
|
||||||
|
- BD Leader 邀请用户成为 BD。
|
||||||
|
- BD Leader 直接邀请用户成为 Agency。
|
||||||
|
- App 查询:`host/me`、`agency/me`、`agency/members`、`agency/applications`、`bd/me`、`bd/agencies`。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 用户只能看到自己区域内的 Agency。
|
||||||
|
- 申请通过后用户成为 host 且有 active membership。
|
||||||
|
- Agency 踢人后用户仍是 host,但 `current_agency_id` 为空。
|
||||||
|
- active host 接受 Agency 邀请被拒绝。
|
||||||
|
- BD 加入别的 Agency 成为 host 时,BD 身份仍然保留。
|
||||||
|
- BD Leader 邀请 BD 时,目标只得到 BD 身份,不自动成为 host。
|
||||||
|
|
||||||
|
## Phase 4: Stats And Attribution
|
||||||
|
|
||||||
|
关系主链路稳定后再消费房间和钱包事件。统计必须使用事件发生时的关系快照,不能从当前 membership 反推历史归属。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 消费 `RoomMicChanged`,只累计 `publish_state=publishing` 的有效上麦时间。
|
||||||
|
- 消费礼物/钱包事件,累计 host 收到的礼物积分或等价统计。
|
||||||
|
- 写入 `host_daily_stats`,包含 `agency_id`、`bd_user_id`、`bd_leader_user_id` 和非空 `relation_scope_key`。
|
||||||
|
- 写入 `host_event_dedup`,按 `event_id` 幂等消费。
|
||||||
|
- 构建 App 读模型:`host_me_view`、`agency_member_view`、`bd_team_view`、`earning_summary_view` 的基础查询。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `pending_publish` 不计有效工作时长。
|
||||||
|
- host 一天内换 Agency 时,stats 按 relation snapshot 拆行。
|
||||||
|
- 同一事件重复消费不会重复累计。
|
||||||
|
- 迟到事件可以修正未锁定 stats;已结算结果交给 Admin 调整单处理。
|
||||||
|
|
||||||
|
## Phase 5: Full Admin Salary
|
||||||
|
|
||||||
|
最后做完整工资后台。此时输入已经稳定:关系事实、事件统计和归属快照都可用。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 配置 host、agency、bd 三类 salary policy。
|
||||||
|
- 配置 `daily`、`weekly`、`semi_monthly`、`monthly` cycle cadence。
|
||||||
|
- 生成 salary cycle 和 salary items。
|
||||||
|
- Host salary:基于有效上麦时长、礼物积分、有效天数。
|
||||||
|
- Agency salary:基于下属 host salary item 或明确配置的 agency 指标。
|
||||||
|
- BD/BD Leader salary:基于 downstream host salary sum,且按 source host salary item 去重。
|
||||||
|
- 后台审核工资周期。
|
||||||
|
- 调用 `wallet-service` 给 `USD_BALANCE` 入账,幂等键为 `salary:{salary_item_id}`。
|
||||||
|
- 支持调整单:迟到事件、人工修正、争议处理都走补差或冲正。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 同区域同 policy type 不能同时启用多个 active policy。
|
||||||
|
- 同一 cycle 重算不会重复生成工资项。
|
||||||
|
- BD Leader 直接管理 Agency 时,同一 host salary source 只算一次。
|
||||||
|
- Salary cycle 重复 post 不会重复给钱包入账。
|
||||||
|
- 已 posted item 不可原地修改,必须通过 adjustment 修正。
|
||||||
|
|
||||||
|
## Phase 6: Hardening And Operations
|
||||||
|
|
||||||
|
主链路和工资链路都跑通后,再补运维和风险能力。不要在前几阶段提前把这些能力做成阻塞项。
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- Admin export:工资周期、工资项、Agency 成员、BD 团队。
|
||||||
|
- Rebuild job:按事实表重建 App read model。
|
||||||
|
- Compensation job:扫描 outbox 未投递、钱包 posting 失败、事件消费失败。
|
||||||
|
- Risk checks:异常高礼物积分、异常上麦时长、重复设备/账号关系。
|
||||||
|
- Dashboard:cycle 状态、posting 失败数、事件消费延迟、host stats watermark。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- outbox 消费失败可重放。
|
||||||
|
- read model 删除后可从事实表重建。
|
||||||
|
- wallet posting partial failure 可继续重试未 posted item。
|
||||||
|
- 关键后台动作都有 audit log。
|
||||||
|
|
||||||
|
## Recommended Milestones
|
||||||
|
|
||||||
|
| Milestone | Scope | Can Demo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 | Phase 1 | 用 gRPC 创建基础关系事实,证明幂等和锁正确 |
|
||||||
|
| M2 | Phase 2 | 后台创建 BD/Agency,App 可以搜到 Agency |
|
||||||
|
| M3 | Phase 3 | 用户申请、审核、踢出、重新申请、邀请链路完整跑通 |
|
||||||
|
| M4 | Phase 4 | 有效上麦和礼物统计按关系快照落库 |
|
||||||
|
| M5 | Phase 5 | 后台生成工资单,审批后入账 `USD_BALANCE` |
|
||||||
|
| M6 | Phase 6 | 失败补偿、导出、重建和监控可用 |
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
- Phase 1 没有完成前,不做工资政策和工资周期。
|
||||||
|
- Phase 2 没有完成前,不做 App Agency 搜索上线,因为没有可信组织种子数据。
|
||||||
|
- Phase 3 没有完成前,不做工资发放,因为 host/Agency/BD 归属还不稳定。
|
||||||
|
- Phase 4 没有完成前,不做真实工资入账,因为缺少可审计统计输入。
|
||||||
|
- Phase 5 没有完成审批和幂等 posting 前,不允许写 `USD_BALANCE`。
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
文档落地到代码后,按改动范围验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make proto
|
||||||
|
go test ./...
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
涉及本地启动链路时再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
@ -600,6 +600,7 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner 和默认 host。
|
gateway 从 access token 取当前 `user_id` 写入 room-service `RequestMeta.actor_user_id`,room-service 由该 actor 确定 owner 和默认 host。
|
||||||
请求体不接收 `owner_user_id` 或 `host_user_id`;房间命令重试幂等使用 `command_id`,不是 `X-Request-ID`。
|
请求体不接收 `owner_user_id` 或 `host_user_id`;房间命令重试幂等使用 `command_id`,不是 `X-Request-ID`。
|
||||||
|
同一个登录用户只能作为 owner 创建一个房间;已有房间时返回 409 Conflict。
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -1653,7 +1654,7 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
pattern: "^[A-Za-z0-9_-]{1,48}$"
|
pattern: "^[A-Za-z0-9_-]{1,48}$"
|
||||||
maxLength: 48
|
maxLength: 48
|
||||||
description: 同时满足腾讯 IM GroupID 和 RTC strRoomId 限制的业务房间 ID。
|
description: 同时满足腾讯 IM GroupID 和 RTC strRoomId 限制的业务房间 ID;同一登录用户只能创建一个房间,不能通过换 room_id 绕过。
|
||||||
command_id:
|
command_id:
|
||||||
type: string
|
type: string
|
||||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||||
|
|||||||
@ -42,7 +42,7 @@ paths:
|
|||||||
$ref: "#/definitions/CreateRoomRequest"
|
$ref: "#/definitions/CreateRoomRequest"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 创建成功;CreateRoom 会确保腾讯云 IM 群组存在后再提交房间状态。
|
description: 创建成功;CreateRoom 会确保腾讯云 IM 群组存在后再提交房间状态;同一个 actor 只能作为 owner 创建一个房间,重复创建返回 Conflict。
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/CreateRoomResponse"
|
$ref: "#/definitions/CreateRoomResponse"
|
||||||
default:
|
default:
|
||||||
@ -566,6 +566,7 @@ definitions:
|
|||||||
format: int64
|
format: int64
|
||||||
CreateRoomRequest:
|
CreateRoomRequest:
|
||||||
type: object
|
type: object
|
||||||
|
description: 同一个 `meta.actor_user_id` 只能作为 owner 创建一个房间;owner/host 不接受外部字段自报。
|
||||||
required:
|
required:
|
||||||
- meta
|
- meta
|
||||||
- seat_count
|
- seat_count
|
||||||
|
|||||||
@ -196,6 +196,7 @@ room-service.VerifyRoomPresence(room_id, user_id, session_id)
|
|||||||
| Requirement | Detail |
|
| Requirement | Detail |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| CreateRoom | 创建 meta、command log、snapshot、outbox,并安装 Room Cell |
|
| CreateRoom | 创建 meta、command log、snapshot、outbox,并安装 Room Cell |
|
||||||
|
| 单 owner 单房间 | 同一个 `RequestMeta.actor_user_id` 只能作为 owner 创建一个房间;已有 owner 房间时返回 Conflict |
|
||||||
| JoinRoom 幂等 | 已在房间时刷新 `last_seen_at_ms`,不重复广播 |
|
| JoinRoom 幂等 | 已在房间时刷新 `last_seen_at_ms`,不重复广播 |
|
||||||
| LeaveRoom 幂等 | 不在房间时返回当前快照 |
|
| LeaveRoom 幂等 | 不在房间时返回当前快照 |
|
||||||
| VerifyRoomPresence | 腾讯云 IM 回调或 gateway 代理进群前检查房间存在、用户在房间、未被 ban |
|
| VerifyRoomPresence | 腾讯云 IM 回调或 gateway 代理进群前检查房间存在、用户在房间、未被 ban |
|
||||||
|
|||||||
@ -167,6 +167,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room
|
|||||||
### 1. CreateRoom
|
### 1. CreateRoom
|
||||||
|
|
||||||
`CreateRoom` 必须在房间创建链路中确保腾讯云 IM 群组存在。
|
`CreateRoom` 必须在房间创建链路中确保腾讯云 IM 群组存在。
|
||||||
|
同一个 `actor_user_id` 只能作为 owner 创建一个房间;加入、主持或管理别人的房间不受该 owner 限制影响。
|
||||||
|
|
||||||
主链路顺序:
|
主链路顺序:
|
||||||
|
|
||||||
@ -174,6 +175,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room
|
|||||||
validate request
|
validate request
|
||||||
acquire Redis lease
|
acquire Redis lease
|
||||||
check room meta
|
check room meta
|
||||||
|
check owner has no room
|
||||||
EnsureRoomGroup(room_id)
|
EnsureRoomGroup(room_id)
|
||||||
initialize RoomState
|
initialize RoomState
|
||||||
write room meta + command log + snapshot + outbox
|
write room meta + command log + snapshot + outbox
|
||||||
|
|||||||
@ -128,6 +128,7 @@ sequenceDiagram
|
|||||||
约束:
|
约束:
|
||||||
|
|
||||||
- 客户端不能提交 `visible_region_id`。
|
- 客户端不能提交 `visible_region_id`。
|
||||||
|
- 同一个登录用户只能作为 owner 创建一个房间;room-service 按 `RequestMeta.actor_user_id` 判定 owner,不能通过更换 `room_id` 创建第二个房间。
|
||||||
- 客户端必须提交 `room_name`;`room_avatar` 为空时由 room-service 写默认系统头像,`room_description` 只进入 `RoomExt`。
|
- 客户端必须提交 `room_name`;`room_avatar` 为空时由 room-service 写默认系统头像,`room_description` 只进入 `RoomExt`。
|
||||||
- `visible_region_id` 来自创建者当前 `users.region_id`。
|
- `visible_region_id` 来自创建者当前 `users.region_id`。
|
||||||
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
||||||
|
|||||||
@ -1131,6 +1131,22 @@ func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
|
|||||||
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upstream")
|
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upstream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) {
|
||||||
|
roomClient := &fakeRoomClient{
|
||||||
|
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":8,"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")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-owner-conflict")
|
||||||
|
}
|
||||||
|
|
||||||
func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
||||||
router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||||
SDKAppID: 1400000000,
|
SDKAppID: 1400000000,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS rooms (
|
|||||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uk_rooms_owner_user (owner_user_id),
|
||||||
KEY idx_rooms_region_status (visible_region_id, status)
|
KEY idx_rooms_region_status (visible_region_id, status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,12 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
|||||||
// meta 已存在但 command 未命中,说明 room_id 已被其他创建命令占用。
|
// meta 已存在但 command 未命中,说明 room_id 已被其他创建命令占用。
|
||||||
return nil, xerr.New(xerr.Conflict, "room already exists")
|
return nil, xerr.New(xerr.Conflict, "room already exists")
|
||||||
}
|
}
|
||||||
|
if existing, exists, err := s.repository.GetRoomMetaByOwner(ctx, cmd.OwnerUserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if exists {
|
||||||
|
// 一个用户只能创建一个房间;加入、主持或管理别人的房间不受这个 owner 限制影响。
|
||||||
|
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID))
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
|
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
|
||||||
// 房间创建必须先保证外部群组可用。
|
// 房间创建必须先保证外部群组可用。
|
||||||
|
|||||||
@ -112,6 +112,8 @@ type Repository interface {
|
|||||||
SaveRoomMeta(ctx context.Context, meta RoomMeta) error
|
SaveRoomMeta(ctx context.Context, meta RoomMeta) error
|
||||||
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
|
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
|
||||||
GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error)
|
GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error)
|
||||||
|
// GetRoomMetaByOwner 查询 owner 已创建的房间;产品规则要求一个用户只能创建一个房间。
|
||||||
|
GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (RoomMeta, bool, error)
|
||||||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||||||
GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error)
|
GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error)
|
||||||
// SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。
|
// SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。
|
||||||
|
|||||||
@ -152,6 +152,58 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||||
|
|
||||||
|
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta("room-owner-single-a", 42),
|
||||||
|
SeatCount: 4,
|
||||||
|
Mode: "social",
|
||||||
|
RoomName: "Owner Room A",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first CreateRoom failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta("room-owner-single-b", 42),
|
||||||
|
SeatCount: 4,
|
||||||
|
Mode: "social",
|
||||||
|
RoomName: "Owner Room B",
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.Conflict) {
|
||||||
|
t.Fatalf("second CreateRoom by same owner should conflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta, exists, err := repository.GetRoomMetaByOwner(ctx, 42); err != nil || !exists || meta.RoomID != "room-owner-single-a" {
|
||||||
|
t.Fatalf("owner lookup should keep first room: exists=%v meta=%+v err=%v", exists, meta, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|
||||||
|
first := roomservice.RoomMeta{
|
||||||
|
RoomID: "room-owner-unique-a",
|
||||||
|
OwnerUserID: 77,
|
||||||
|
HostUserID: 77,
|
||||||
|
SeatCount: 4,
|
||||||
|
Mode: "social",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := repository.SaveRoomMeta(ctx, first); err != nil {
|
||||||
|
t.Fatalf("save first room meta failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := first
|
||||||
|
second.RoomID = "room-owner-unique-b"
|
||||||
|
if err := repository.SaveRoomMeta(ctx, second); !xerr.IsCode(err, xerr.Conflict) {
|
||||||
|
t.Fatalf("duplicate owner should map to conflict, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
|
func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
mysqlerr "github.com/go-sql-driver/mysql"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/room/outbox"
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
)
|
)
|
||||||
@ -76,6 +78,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uk_rooms_owner_user (owner_user_id),
|
||||||
KEY idx_rooms_region_status (visible_region_id, status)
|
KEY idx_rooms_region_status (visible_region_id, status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS room_list_entries (
|
`CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||||
@ -157,7 +160,7 @@ func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta
|
|||||||
meta.VisibleRegionID,
|
meta.VisibleRegionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return mapRoomMetaDuplicateError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRoomMeta 查询房间元数据。
|
// GetRoomMeta 查询房间元数据。
|
||||||
@ -183,6 +186,29 @@ func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservic
|
|||||||
return meta, true, nil
|
return meta, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRoomMetaByOwner 查询用户已经创建的房间。
|
||||||
|
func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) {
|
||||||
|
// rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT room_id, owner_user_id, host_user_id, seat_count, mode, status, visible_region_id
|
||||||
|
FROM rooms
|
||||||
|
WHERE owner_user_id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
ownerUserID,
|
||||||
|
)
|
||||||
|
|
||||||
|
var meta roomservice.RoomMeta
|
||||||
|
if err := row.Scan(&meta.RoomID, &meta.OwnerUserID, &meta.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.VisibleRegionID); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return roomservice.RoomMeta{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomservice.RoomMeta{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||||||
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
|
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
|
||||||
// 幂等检查只看 command log,只有成功提交过的命令才算 seen。
|
// 幂等检查只看 command log,只有成功提交过的命令才算 seen。
|
||||||
@ -561,3 +587,21 @@ func nullableString(value string) sql.NullString {
|
|||||||
Valid: value != "",
|
Valid: value != "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapRoomMetaDuplicateError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var mysqlErr *mysqlerr.MySQLError
|
||||||
|
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1062 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := mysqlErr.Message
|
||||||
|
if strings.Contains(message, "uk_rooms_owner_user") || strings.Contains(message, "owner_user_id") {
|
||||||
|
return xerr.New(xerr.Conflict, "owner already has room")
|
||||||
|
}
|
||||||
|
|
||||||
|
return xerr.New(xerr.Conflict, "room already exists")
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user