# Host, Agency And BD App Architecture 本文只定义 App 侧的主播组织、关系和用户可操作流程。这里的 `host` 指业务主播,不等同于 `room-service` 里的房间 `host_user_id`。房间 `host_user_id` 是单个房间的主持/管理身份;本文的主播身份是用户长期经营身份,可以有 Agency 归属和收益展示。 后台政策、工资周期、工资单审批、钱包入账和调整单实现单独放在 [Host Agency BD Admin Architecture](./主播公会BD后台架构.md)。App 侧可以读取收益和结算状态,但不能配置政策、触发结算或直接入账。 旧 Java 项目只作为术语和历史口径参考。新架构按当前 Go 服务边界重新设计:不新增独立 `host-service` 微服务;主播、Agency、BD 层级、申请邀请和 App 查询读模型属于 user-services,代码落在当前仓库的 `services/user-service` host domain。`room-service` 只产生房间事件,`wallet-service` 负责余额和提现。 ## 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。 - `coin_seller` 是独立币商身份,可以和 Agency、BD、BD Leader 身份并存;币商拥有专用金币账户,可以给玩家转普通金币。 - App 侧可以展示 host 统计、Agency 成员、申请、邀请和后台已经生成的收益结果。 - App 侧不能创建政策、计算工资周期、审核工资单或直接给 `USD_BALANCE` 入账。 ## Non-Goals - 不把主播组织关系写进 `room-service`。Room Cell 仍然只拥有房间状态、麦位、presence、榜单和房间 outbox。 - 不在本文设计后台政策、工资周期、工资单审批或人工调整;这些属于 Admin 文档。 - 不用 Redis 保存主播身份、Agency 归属或收益事实。Redis 只能做短 TTL 读缓存或限流。 - 不在客户端自报区域、身份或政策。区域来自 `user-service.users.region_id`,身份和政策来自 `user-service` host domain。 - 不让关系变更追溯改历史统计。每个工作事件保存当时的关系快照,后续后台结算按快照读取。 ## Vocabulary | Term | Meaning | Owner | | --- | --- | --- | | `host` | 业务主播身份,长期有效,可没有 Agency | `user-service` host domain | | `agency` | 代理组织,由一个用户拥有,拥有者自动是 host | `user-service` host domain | | `agency owner` | Agency 的拥有者用户,既是 Agency 身份也是 host | `user-service` host domain | | `BD` | 拓展 Agency 的业务角色,默认不是 host | `user-service` host domain | | `BD Leader` | BD 的上级,同时也是 BD,可以邀请 BD 和 Agency | `user-service` host domain | | `coin_seller` | 币商身份,可叠加在 Agency、BD、BD Leader 用户上 | `user-service` host domain | | `COIN_SELLER_COIN` | 币商专用金币账户资产,只能由币商转出给玩家 | `wallet-service` | | `agency application` | 用户申请加入 Agency 的 App 流程 | `user-service` host domain | | `role invitation` | BD/BD Leader 邀请用户成为 Agency 或 BD 的 App 流程 | `user-service` host domain | | `earning summary` | 后台已结算或待结算收益的 App 展示读模型 | `user-service` host domain reads, `wallet-service` owns balance | ## Service Boundaries ```mermaid graph LR Client["Client"] --> Gateway["gateway-service"] Gateway --> User["user-service\nhost domain"] Gateway --> Room["room-service"] Gateway --> Wallet["wallet-service"] Room --> RoomOutbox[("room_outbox")] Wallet --> WalletOutbox[("wallet_outbox")] RoomOutbox --> MQ["MQ / event bus"] WalletOutbox --> MQ MQ --> User User --> UserDB[("user MySQL")] Wallet --> WalletDB[("wallet MySQL")] ``` | Service | Responsibility | | --- | --- | | `gateway-service` | HTTP 入口、鉴权、request envelope、调用内部 gRPC | | `user-service` | 用户主数据、国家、区域、账号状态;host 身份、Agency、BD、coin_seller 层级、申请邀请、关系快照、App 查询读模型 | | `room-service` | 房间事件:上麦、确认发流、下麦、送礼、离房 | | `wallet-service` | `COIN`、`COIN_SELLER_COIN`、`USD_BALANCE`、收益余额、提现冻结/审核/出账 | `user-service` host domain 读取 `room-service` 和 `wallet-service` 事件,但不反向修改房间状态。App 查询只读取当前身份、关系、申请、邀请、统计和后台已生成的收益结果;结算写入链路见 Admin 文档。 ## Multi-App Scope host、Agency、BD、申请、邀请、统计和收益读模型都按 `app_code` 隔离。gateway 必须把客户端解析出的 `app_code` 写入 `RequestMeta`;`user-service` host domain 的所有查询、锁、唯一约束、幂等结果和 outbox 都必须带 `app_code`。 当前用户体系的 `user_id` 是内部稳定主键,业务查询仍必须带 `app_code` 做租户作用域。`display_user_id`、`command_id`、`event_id`、申请、邀请和 outbox 幂等键都必须在 `app_code` 下判定唯一,不能因为某个本地表暂时使用全局主键就让业务逻辑绕过 `app_code`。 后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。如果后续要允许同一数值业务 ID 在不同 App 下复用,必须先把对应表的主键或唯一键迁移为 `(app_code, id)` 或 `(app_code, command_id)`,再开放多 App 数据写入。 ## 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 侧只展示结果和状态,不计算金额。 ### Coin Seller - `coin_seller` 是币商身份,拼写按现有业务口径固定为 `coin_seller`。 - `coin_seller_profiles.status=active` 表示该用户可以使用币商转账能力。 - 币商身份不改变 host、Agency、BD、BD Leader 的规则;同一个用户可以同时是 Agency owner、BD、BD Leader 和 coin_seller。 - 币商自己的金币账户不是普通玩家 `COIN`,而是钱包资产 `COIN_SELLER_COIN`。 - 币商给玩家转金币时,`wallet-service` 原子扣减币商 `COIN_SELLER_COIN`,给目标玩家增加普通 `COIN`,并按双方同一区域的充值政策记录用户充值金额。 - App 调用币商转账前必须由 gateway/user-service 校验当前用户拥有 active `coin_seller` 身份;客户端不能自报币商身份。 ## Relationship Model 关系是有生效时间的事实,不应该只保存当前字段。当前字段用于 App 查询快照,历史表用于审计和后台结算。 ```mermaid erDiagram USERS ||--o| HOST_PROFILES : "may become" USERS ||--o| BD_PROFILES : "may become" USERS ||--o| COIN_SELLER_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 { string app_code bigint user_id PK string status bigint region_id bigint current_agency_id bigint current_membership_id bigint first_became_host_at_ms } AGENCIES { string app_code bigint agency_id PK bigint owner_user_id UK bigint region_id bigint parent_bd_user_id string status bool join_enabled } BD_PROFILES { string app_code bigint user_id PK string role bigint region_id bigint parent_leader_user_id string status } COIN_SELLER_PROFILES { string app_code bigint user_id PK string status string merchant_asset_type } AGENCY_MEMBERSHIPS { string app_code 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( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, region_id, status), KEY idx_host_profiles_agency (app_code, current_agency_id, status) ); agencies( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, owner_user_id), KEY idx_agencies_region_status (app_code, region_id, status, join_enabled), KEY idx_agencies_parent_bd (app_code, parent_bd_user_id, status) ); bd_profiles( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, region_id, role, status), KEY idx_bd_profiles_parent_leader (app_code, parent_leader_user_id, status) ); coin_seller_profiles( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', user_id BIGINT NOT NULL PRIMARY KEY, status VARCHAR(32) NOT NULL, merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN', created_by_user_id BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, KEY idx_coin_seller_profiles_status (app_code, status, updated_at_ms), KEY idx_coin_seller_profiles_created_by (app_code, created_by_user_id, created_at_ms) ); agency_memberships( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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 (app_code, agency_id, status), KEY idx_agency_memberships_host_status (app_code, host_user_id, status), KEY idx_agency_memberships_effective (app_code, 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`。 - `coin_seller_profiles` 只表达身份事实,不保存余额;币商余额只读 `wallet_accounts(asset_type='COIN_SELLER_COIN')`。 - 新增币商不要求目标用户没有 Agency/BD/BD Leader 身份,只要求目标用户存在且未重复拥有 coin_seller 身份。 - 币商转账的充值金额来自 `wallet-service.wallet_recharge_policies` 快照;gateway 只从 user-service 读取双方 `region_id`,不允许客户端提交区域。 - 当前版本只允许币商和目标用户同区域转账,避免跨区时静默套用任意一方政策。 ## Region Rules 用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。 国家/区域后台管理边界在 `hyapp-admin-server`:创建国家、启用/禁用国家、创建区域、启用/禁用区域不属于 App 侧 user-service RPC。App 侧只消费 user-service 已计算好的 `region_id` 和区域投影。 后台创建 BD Leader 是关系管理命令,不是客户端区域提交:`hyapp-admin-server` 负责权限和审计,`user-service` 在同一事务里把目标用户的 `users.region_id` 更新为后台选择的区域,并用同一个区域创建 `bd_profiles`。 用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准;Host/Agency/BD 关系只消费当前 `users.region_id` 和各关系表里的区域快照。 | Action | Region Rule | | --- | --- | | 后台创建 BD Leader | 后台必须选择 active 区域;目标用户当前区域可以不同,创建成功后目标用户 `users.region_id` 和 BD Leader `bd_profiles.region_id` 都变为所选区域 | | 搜索 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 user-service host domain C->>G: GET /agencies/search G->>U: SearchAgenciesForUser(user_id) U->>H: load user region and active agencies H-->>U: active agencies in region U-->>G: active agencies in region G-->>C: agency list C->>G: POST /host/applications G->>U: ApplyToAgency(user_id, agency_id) U->>H: load user region and agency H->>H: lock user host profile and agency H->>H: create pending application H-->>U: application U-->>G: application ``` Approval: 1. Agency owner or authorized Agency manager reviews pending application. 2. `user-service` host domain 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 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。 麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 [User Mic Online Time Architecture](./用户麦上在线时长架构.md)。本节的 `host_daily_stats` 是主播、Agency、BD 工资和团队统计的派生读模型,不是唯一时长来源。 ### Input Events | Event | Source | Use | | --- | --- | --- | | `UserMicSessionClosed` / user mic stats | user mic time consumer | 用户级麦上在线时长基础事实,host stats 从这里派生 | | `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦;由用户麦时长 consumer 先统一处理 | | `RoomGiftSent` | `room-service` outbox | 房间展示和用户贡献统计 | | `WalletGiftDebited` | `wallet-service` outbox | 权威 `gift_point_added` 和价格版本 | | `WalletRechargeRecorded` | `wallet-service` outbox | 用户充值口径,币商转账按区域政策折算后的 `usd_minor_amount` | | `HostAgencyMembershipChanged` | `user-service` host outbox | 关系变化审计和缓存失效 | 有效麦上在线默认只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计默认在线时长,避免用户占麦但 RTC 没有成功发流时产生任务或收益口径。若某个运营玩法明确要求“占麦时长”,应读取通用用户麦时长里的 `seat_occupied_ms`,不要改 host stats 口径。 ### Daily Stats ```sql host_daily_stats( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', 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_online_ms BIGINT NOT NULL, seat_occupied_ms BIGINT NOT NULL DEFAULT 0, 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 (app_code, stat_date, host_user_id, relation_scope_key) ); host_event_dedup( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', event_id VARCHAR(128) NOT NULL, event_type VARCHAR(64) NOT NULL, occurred_at_ms BIGINT NOT NULL, consumed_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, event_id) ); ``` 规则: - `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 拆分。 - 用户成为 host 之前的麦上在线历史仍保留在用户级 stats;是否计入某个 host/工资政策,由派生规则决定。 - `effective_day` 由政策解释,例如当天 `publishing >= 60 minutes` 记 1 天;不要硬编码在统计表结构里。 - 所有事件消费必须按 `app_code + event_id` 幂等。 - 迟到事件可以修正未锁定 stats;已生成收益结果的修正由 Admin 调整单处理,App 只读取最终状态。 ## App Read Models App 侧不要实时扫描关系历史表。`user-service` host domain 应该维护面向 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`. Host/Agency/BD internal methods live in `user-service` gRPC + protobuf; they are not a separate deployable service. ### 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 POST /api/v1/wallet/coin-seller/transfer ``` App API 必须使用 `/api/v1` envelope。用户身份来自 gateway 鉴权,`request_id` 由后端生成且只做链路追踪,不做幂等键;业务命令必须使用客户端按用户动作生成的 `command_id`。 ### Internal gRPC Sketch ```proto service UserHostService { 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 GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse); rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse); rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse); rpc GetHostEarningSummary(GetHostEarningSummaryRequest) returns (GetHostEarningSummaryResponse); } service WalletService { rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse); } ``` 当前仍处于开发阶段,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 `user-service` host domain should write host outbox rows in the user database: | 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. ## App Message Tab Integration Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./App消息Tab架构.md)。`user-service` host domain 只产出 host outbox 事件或调用内部 `CreateInboxMessage`;不能直接写 message inbox 表,也不能复用后台 `admin_notifications`。 | Source Event | Target User | Message Type | Action | | --- | --- | --- | --- | | `AgencyApplicationSubmitted` | Agency owner or authorized reviewer | `system` | `agency_application_detail` | | `AgencyApplicationApproved` | applicant host | `system` | `host_application_detail` | | `AgencyApplicationRejected` | applicant user | `system` | `host_application_detail` | | `AgencyMembershipChanged(kicked)` | kicked host | `system` | `agency_membership_detail` | | `RoleInvitationCreated(type=agency)` | invited user | `system` | `role_invitation_detail` | | `RoleInvitationCreated(type=bd)` | invited user | `system` | `role_invitation_detail` | | `RoleInvitationAccepted` | inviter | `system` | `bd_team_detail` | | `BDCreated` | new BD | `system` | `bd_profile_detail` | 消息只做通知和跳转,不是关系事实。App 收到消息后仍必须调用 host/agency/bd 查询接口刷新当前状态。 ## 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](./主播公会BD实施顺序.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.