# 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 只负责把审核通过的工资单入账到工资余额,不直接处理线下打款。 本文不新增独立 `host-service` 微服务。host、Agency、BD、政策和工资后台能力属于 user-services,代码落在当前仓库的 `services/user-service` host domain。 ## Goals - 后台可以配置 host、agency、bd 三类工资政策,并按区域生效。 - 后台可以选择日结、周结、半月结、月结,不把结算周期写死在代码里。 - 后台可以生成工资周期,查看每个 host/Agency/BD/BD Leader 的明细来源。 - 后台审批后,`user-service` host domain 以幂等方式调用 `wallet-service` 给 `USD_BALANCE` 入账。 - 后台可以对迟到事件、人工修正、争议处理创建调整单,不能直接修改已入账工资项。 - 后台可以创建或停用 BD Leader、BD、Agency,也可以关闭 Agency;后台可以查看和新增币商 `coin_seller`;所有动作必须有审计记录。 ## Non-Goals - 不在 Admin 里承载 App 长连接、房间麦位、房间核心状态或 IM 投递。 - 不让 `wallet-service` 理解 host/Agency/BD 政策。钱包只接收已审批的入账请求。 - 不用 Redis 保存政策、工资周期、工资单或审计事实。Redis 只能做短 TTL 查询缓存和互斥锁兜底。 - 不允许后台在不留审计的情况下改关系、改政策、改工资金额。 - 不允许已 posted 的工资单原地改金额;必须创建反向或补差调整项。 ## Service Boundaries ```mermaid graph LR Admin["Admin Console"] --> AdminServer["hyapp-admin-server"] AdminServer --> User["user-service\nhost domain"] AdminServer --> Wallet["wallet-service"] AdminServer --> AdminDB[("hyapp_admin MySQL")] User --> UserDB[("user MySQL")] Wallet --> WalletDB[("wallet MySQL")] RoomOutbox[("room_outbox")] --> MQ["MQ / event bus"] WalletOutbox[("wallet_outbox")] --> MQ MQ --> User User --> HostOutbox[("host_outbox")] HostOutbox --> BI["BI / notification / risk"] ``` | Service | Admin Responsibility | | --- | --- | | `hyapp-admin-server` | Admin HTTP 入口、后台鉴权、request envelope、后台操作审计;使用独立 `hyapp_admin` 库 | | `user-service` | 用户、区域、账号状态查询;host/Agency/BD/coin_seller 政策、周期、工资单、关系管理、统计聚合、幂等结果、领域 outbox;App/客户端不能提交区域,后台关系命令的 `region_id` 只能来自 `hyapp-admin-server` 鉴权审计入口 | | `room-service` | 只产出上麦和礼物事件,不接收后台工资命令 | | `wallet-service` | `COIN`、`COIN_SELLER_COIN`、`USD_BALANCE` 入账、提现冻结、提现审核状态和钱包流水 | ## Admin Permission Model 后台权限至少拆成四类,避免一个运营账号同时拥有配置政策和发钱能力。 | Permission | Can Do | Cannot Do | | --- | --- | --- | | `host_relation_admin` | 创建/停用 BD Leader、BD、Agency,关闭 Agency,查看和新增 coin_seller | 审批工资入账 | | `salary_policy_admin` | 创建政策草稿、启停政策、配置周期 | 直接发工资 | | `recharge_policy_admin` | 配置区域充值汇率,例如 80000 金币记 1 美元充值 | 重算历史充值金额 | | `salary_reviewer` | 查看工资单、驳回、请求重算 | 调用钱包入账 | | `salary_approver` | 审批工资周期、触发 posting | 修改政策和关系 | 所有后台命令必须由 `hyapp-admin-server` 写入 `hyapp_admin.admin_operation_logs`。`user-service` 只保存 host/Agency/BD 业务事实、`command_id` 幂等结果和领域事件,不创建也不写后台审计表,避免后台库与用户业务库耦合。 币商后台入口同样遵守这个边界:`hyapp-admin-server` 负责权限和审计,`user-service` 保存 `coin_seller_profiles` 身份事实,`wallet-service` 保存 `COIN_SELLER_COIN` 余额和转账流水。后台列表可以聚合展示币商身份、用户基础资料和币商余额,但不能把余额字段落到 admin 库或 user 库。 币商给玩家转金币会形成用户充值记录,充值金额由 `wallet-service.wallet_recharge_policies` 的区域政策快照决定;后台修改充值政策只能影响新交易,不能重算历史 `wallet_recharge_records`。 | Permission | Kind | Usage | | --- | --- | --- | | `coin-seller:view` | `menu` | 币商列表、币商余额展示 | | `coin-seller:create` | `button` | 新增币商身份 | | `coin-seller:update` | `button` | 启用或禁用币商身份 | 后台管理某个 App 的 host、Agency、BD、政策或工资时,必须先在后台上下文中选择 `app_code`。`hyapp_admin` 库不按 App 拆库;审计行可以在 `detail` 中记录 `app_code`。业务事实仍写入 user/wallet 等业务库,并由对应表的 `app_code` 隔离。 ```sql admin_operation_logs( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL DEFAULT '', user_id BIGINT UNSIGNED NOT NULL, username VARCHAR(64) NOT NULL, action VARCHAR(80) NOT NULL, resource VARCHAR(120), resource_id VARCHAR(80) NOT NULL DEFAULT '', status VARCHAR(32) NOT NULL, detail TEXT, created_at DATETIME NOT NULL, INDEX idx_admin_operation_logs_request_id (request_id), INDEX idx_admin_operation_logs_resource_id (resource_id), INDEX idx_admin_operation_logs_action (action) ); ``` Phase 2 关系管理的 `detail` 至少包含 `command_id`、目标对象、最终状态和 `reason`;如后续要做结构化前后快照,应在 `hyapp_admin` 库新增专用审计表或扩展 `admin_operation_logs`,不能把审计行写入 user-service 库。 ## Policy Model 政策必须版本化。工资结算使用周期生成时锁定的 policy snapshot,不能用当前 active policy 重算历史。 ```sql salary_policies( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, region_id, policy_type, status) ); salary_policy_rules( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, 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( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, 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( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, region_id, cycle_type, period_start_ms, period_end_ms) ); salary_items( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, 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. `user-service` host domain 查询所有 `approved` salary items。 3. 对每个 item 调用 `wallet-service CreditSalaryBalance`,幂等键为 `salary:{salary_item_id}`。 4. `wallet-service` credit `USD_BALANCE` 并返回 `wallet_transaction_id`。 5. `user-service` host domain 保存 `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( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 UserHostAdminService { 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); } ``` `CreateBDLeaderRequest` 必须携带 `region_id`。user-service 校验该区域 active,锁定目标用户行,必要时更新 `users.region_id`,再创建 `bd_profiles`;同一个 `command_id` 重试只返回已创建的 BD Leader 事实,不重复迁移。 这属于允许改变用户区域的后台关系管理路径之一;完整来源见 [User Region Change Sources](./user-region-country-development.md#user-region-change-sources)。 当前项目仍在开发阶段,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 要锁目标用户的 `users`、`host_profiles`、`agencies`、`bd_profiles`,避免和 App 邀请接受并发造成身份冲突。后台创建 BD Leader 需要在同一事务内更新 `users.region_id` 和 `bd_profiles.region_id`。 ## Event And Outbox `user-service` host domain should write host outbox rows in the user database: | 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.