主播侧实现

This commit is contained in:
170-carry 2026-05-01 00:32:00 +08:00
parent ba772242ea
commit af4b60bba4
16 changed files with 1346 additions and 4 deletions

View File

@ -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

View File

@ -79,6 +79,7 @@ message RoomSnapshot {
// CreateRoomRequest // CreateRoomRequest
// meta.room_idmeta.command_idmeta.actor_user_idseat_countmode room_name // meta.room_idmeta.command_idmeta.actor_user_idseat_countmode 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

View 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.

View 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 归属于 BDBD 归属于 BD Leader。BD Leader 也是 BD只是具备邀请 BD 的能力。
- BD 可以邀请用户成为 AgencyBD 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.

View 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 本地运行入口。
- 新增 protobufhost 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异常高礼物积分、异常上麦时长、重复设备/账号关系。
- Dashboardcycle 状态、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/AgencyApp 可以搜到 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
```

View File

@ -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 重试幂等。

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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` 列表桶。

View File

@ -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,

View File

@ -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;

View File

@ -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 {
// 房间创建必须先保证外部群组可用。 // 房间创建必须先保证外部群组可用。

View File

@ -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 追加成功命令日志,关键命令恢复必须依赖它。

View File

@ -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)

View File

@ -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")
}