# 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 归属，不取消主播身份。
- Huwaa 用户可搜索并申请加入本 App 内的跨区域 active Agency；其他 App 保留现有国家/区域限制。
- 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 侧不能创建政策、计算工资周期、审核工资单或直接给 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 入账。

## 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`、`HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`、收益余额、提现冻结/审核/出账 |

`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`，不能由客户端提交。`app_code` 来自 gateway 已验证的 token 并写入 `RequestMeta`；客户端不能通过请求体或请求头伪造 Huwaa 例外。
国家/区域后台管理边界在 `hyapp-admin-server`：创建国家、启用/禁用国家、创建区域、启用/禁用区域不属于 App 侧 user-service RPC。App 侧只消费 user-service 已计算好的 `region_id` 和区域投影。
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准；Host/Agency/BD 关系创建后保留目标用户当前区域，Huwaa 跨区组织关系不会顺带迁移 `users.region_id`。

| Action | Region Rule |
| --- | --- |
| 经理创建 BD Leader | Huwaa 经理可搜索并创建其他国家/区域的 BD Leader；其他 App 保持经理与目标用户同国家。创建后 BD Leader 使用目标用户已有 `users.region_id`，不迁移用户国家/区域 |
| 搜索 Agency | Huwaa 返回本 App 内所有 active/join_enabled Agency；其他 App 保留按当前用户国家过滤 |
| 申请加入 Agency | Huwaa 申请创建和 Agency 审核通过均允许跨区；其他 App 要求申请人与 Agency 同区域 |
| Agency 主动邀请 Host | 所有 App 仍要求目标用户与 Agency 同区域；Huwaa 例外只适用于 Host 主动申请 |
| BD/Leader 邀请 Agency | Huwaa 的邀请创建和接受均允许跨区；其他 App 要求邀请人与目标用户同区域 |
| Leader 邀请 BD | Huwaa 的邀请创建和接受均允许跨区；其他 App 要求 Leader 与目标用户同区域 |
| Coin Seller | 所有 App 继续按区域返回 active 币商；子币商申请创建、审批建立关系和转账前授权均要求父子币商同区域 |
| 用户改国家导致区域变化 | 不自动迁移 Agency/BD 归属；新申请或邀请按 `app_code` 对应规则处理，历史统计按事件快照归属 |

## 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 app_code policy, user country/region and active agencies
  H-->>U: Huwaa all active agencies; other App same-country agencies
  U-->>G: app-scoped agency list
  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: enforce app-scoped region policy and 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
  -> Huwaa allows cross-region; other App 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
  -> Huwaa allows cross-region; other App 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/invitation follows the current App policy; 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 |
| --- | --- |
| Huwaa user searches Agency | All active joinable agencies in Huwaa are returned, including other regions |
| Other App user searches Agency | Existing country/region restriction remains in force |
| 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 |
| Huwaa cross-region Agency/BD invitation is accepted | Target keeps its own region and the invited relationship is created |
| Huwaa manager creates cross-region BD Leader | Target keeps its own region and receives active BD Leader identity |
| Huwaa user lists or invites Coin Seller | Only same-region sellers or sub-seller targets are allowed |
| 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 policy is selected by authenticated `app_code`: Huwaa only exempts Agency invitation, Host application, BD invitation and manager-created BD Leader; other App paths and all Coin Seller relations retain server-side region restrictions.
- 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.
