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