diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 525d6fcb..455b84f4 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -598,6 +598,7 @@ func (x *RoomSnapshot) GetVersion() int64 { // CreateRoomRequest 创建一个新的房间执行单元。 // 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。 +// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 type CreateRoomRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 68781972..0e2e2dda 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -79,6 +79,7 @@ message RoomSnapshot { // CreateRoomRequest 创建一个新的房间执行单元。 // 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count、mode 和 room_name。 +// 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 message CreateRoomRequest { RequestMeta meta = 1; // seat_count 必须大于 0;没有麦位的语音房不进入 Room Cell。 diff --git a/docs/host-agency-bd-admin-architecture.md b/docs/host-agency-bd-admin-architecture.md new file mode 100644 index 00000000..17007665 --- /dev/null +++ b/docs/host-agency-bd-admin-architecture.md @@ -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. diff --git a/docs/host-agency-bd-architecture.md b/docs/host-agency-bd-architecture.md new file mode 100644 index 00000000..3ed46518 --- /dev/null +++ b/docs/host-agency-bd-architecture.md @@ -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. diff --git a/docs/host-agency-bd-implementation-sequence.md b/docs/host-agency-bd-implementation-sequence.md new file mode 100644 index 00000000..15c4a793 --- /dev/null +++ b/docs/host-agency-bd-implementation-sequence.md @@ -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 +``` diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 7eb7e582..47ef2b8e 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -600,6 +600,7 @@ paths: description: | 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 创建一个房间;已有房间时返回 409 Conflict。 security: - BearerAuth: [] parameters: @@ -1653,7 +1654,7 @@ definitions: type: string pattern: "^[A-Za-z0-9_-]{1,48}$" maxLength: 48 - description: 同时满足腾讯 IM GroupID 和 RTC strRoomId 限制的业务房间 ID。 + description: 同时满足腾讯 IM GroupID 和 RTC strRoomId 限制的业务房间 ID;同一登录用户只能创建一个房间,不能通过换 room_id 绕过。 command_id: type: string description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。 diff --git a/docs/openapi/room.swagger.yaml b/docs/openapi/room.swagger.yaml index 0ec6ccdd..f736231b 100644 --- a/docs/openapi/room.swagger.yaml +++ b/docs/openapi/room.swagger.yaml @@ -42,7 +42,7 @@ paths: $ref: "#/definitions/CreateRoomRequest" responses: "200": - description: 创建成功;CreateRoom 会确保腾讯云 IM 群组存在后再提交房间状态。 + description: 创建成功;CreateRoom 会确保腾讯云 IM 群组存在后再提交房间状态;同一个 actor 只能作为 owner 创建一个房间,重复创建返回 Conflict。 schema: $ref: "#/definitions/CreateRoomResponse" default: @@ -566,6 +566,7 @@ definitions: format: int64 CreateRoomRequest: type: object + description: 同一个 `meta.actor_user_id` 只能作为 owner 创建一个房间;owner/host 不接受外部字段自报。 required: - meta - seat_count diff --git a/docs/room-entry-reconnect-development-requirements.md b/docs/room-entry-reconnect-development-requirements.md index 9b4ea77e..b1b960f8 100644 --- a/docs/room-entry-reconnect-development-requirements.md +++ b/docs/room-entry-reconnect-development-requirements.md @@ -196,6 +196,7 @@ room-service.VerifyRoomPresence(room_id, user_id, session_id) | Requirement | Detail | | --- | --- | | CreateRoom | 创建 meta、command log、snapshot、outbox,并安装 Room Cell | +| 单 owner 单房间 | 同一个 `RequestMeta.actor_user_id` 只能作为 owner 创建一个房间;已有 owner 房间时返回 Conflict | | JoinRoom 幂等 | 已在房间时刷新 `last_seen_at_ms`,不重复广播 | | LeaveRoom 幂等 | 不在房间时返回当前快照 | | VerifyRoomPresence | 腾讯云 IM 回调或 gateway 代理进群前检查房间存在、用户在房间、未被 ban | diff --git a/docs/voice-room-basic-loop-implementation.md b/docs/voice-room-basic-loop-implementation.md index f5da61fb..360ca232 100644 --- a/docs/voice-room-basic-loop-implementation.md +++ b/docs/voice-room-basic-loop-implementation.md @@ -167,6 +167,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room ### 1. CreateRoom `CreateRoom` 必须在房间创建链路中确保腾讯云 IM 群组存在。 +同一个 `actor_user_id` 只能作为 owner 创建一个房间;加入、主持或管理别人的房间不受该 owner 限制影响。 主链路顺序: @@ -174,6 +175,7 @@ gateway 不保存房间状态,只做回调鉴权、字段解析、调用 room validate request acquire Redis lease check room meta +check owner has no room EnsureRoomGroup(room_id) initialize RoomState write room meta + command log + snapshot + outbox diff --git a/docs/voice-room-region-room-list-architecture.md b/docs/voice-room-region-room-list-architecture.md index ba6e28cb..e5f3c219 100644 --- a/docs/voice-room-region-room-list-architecture.md +++ b/docs/voice-room-region-room-list-architecture.md @@ -128,6 +128,7 @@ sequenceDiagram 约束: - 客户端不能提交 `visible_region_id`。 +- 同一个登录用户只能作为 owner 创建一个房间;room-service 按 `RequestMeta.actor_user_id` 判定 owner,不能通过更换 `room_id` 创建第二个房间。 - 客户端必须提交 `room_name`;`room_avatar` 为空时由 room-service 写默认系统头像,`room_description` 只进入 `RoomExt`。 - `visible_region_id` 来自创建者当前 `users.region_id`。 - 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 49ae348e..38d36af2 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -1131,6 +1131,22 @@ func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) { 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) { router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index affc5299..cff0fce7 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS rooms ( visible_region_id BIGINT NOT NULL DEFAULT 0, 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), + UNIQUE KEY uk_rooms_owner_user (owner_user_id), KEY idx_rooms_region_status (visible_region_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/services/room-service/internal/room/service/create_room.go b/services/room-service/internal/room/service/create_room.go index edd8825c..598b5b8a 100644 --- a/services/room-service/internal/room/service/create_room.go +++ b/services/room-service/internal/room/service/create_room.go @@ -120,6 +120,12 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) // meta 已存在但 command 未命中,说明 room_id 已被其他创建命令占用。 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 { // 房间创建必须先保证外部群组可用。 diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 56078e2d..ea584035 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -112,6 +112,8 @@ type Repository interface { SaveRoomMeta(ctx context.Context, meta RoomMeta) error // GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。 GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error) + // GetRoomMetaByOwner 查询 owner 已创建的房间;产品规则要求一个用户只能创建一个房间。 + GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (RoomMeta, bool, error) // GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。 GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error) // SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。 diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go index f83cbda6..6a2442fe 100644 --- a/services/room-service/internal/room/service/service_test.go +++ b/services/room-service/internal/room/service/service_test.go @@ -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) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index ae489de5..dba5e6dd 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -5,11 +5,13 @@ import ( "context" "database/sql" "errors" + "strings" "time" - _ "github.com/go-sql-driver/mysql" + mysqlerr "github.com/go-sql-driver/mysql" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp/api/proto/events/room/v1" + "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/outbox" 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, 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), + UNIQUE KEY uk_rooms_owner_user (owner_user_id), KEY idx_rooms_region_status (visible_region_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS room_list_entries ( @@ -157,7 +160,7 @@ func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta meta.VisibleRegionID, ) - return err + return mapRoomMetaDuplicateError(err) } // GetRoomMeta 查询房间元数据。 @@ -183,6 +186,29 @@ func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservic 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 冲突判定。 func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) { // 幂等检查只看 command log,只有成功提交过的命令才算 seen。 @@ -561,3 +587,21 @@ func nullableString(value string) sql.NullString { 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") +}