623 lines
28 KiB
Markdown
623 lines
28 KiB
Markdown
# 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 服务边界重新设计:不新增独立 `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`。
|
||
|
||
不同 App 下可以出现相同的 `user_id`、`agency_id`、`command_id` 或 `event_id`,但不能跨 `app_code` 查询或复用关系事实。后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。
|
||
|
||
## 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](./user-region-country-development.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 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。
|
||
|
||
### Input Events
|
||
|
||
| Event | Source | Use |
|
||
| --- | --- | --- |
|
||
| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦,计算有效上麦时长 |
|
||
| `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 没有成功发流时产生收益口径。
|
||
|
||
### 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_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 (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 拆分。
|
||
- `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.
|
||
|
||
## 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.
|