23 KiB
Host, Agency And BD App Architecture
本文只定义 App 侧的主播组织、关系和用户可操作流程。这里的 host 指业务主播,不等同于 room-service 里的房间 host_user_id。房间 host_user_id 是单个房间的主持/管理身份;本文的主播身份是用户长期经营身份,可以有 Agency 归属和收益展示。
后台政策、工资周期、工资单审批、钱包入账和调整单实现单独放在 Host Agency BD Admin Architecture。App 侧可以读取收益和结算状态,但不能配置政策、触发结算或直接入账。
旧 Java 项目只作为术语和历史口径参考。新架构按当前 Go 服务边界重新设计:user-service 仍然负责用户和区域,room-service 只产生房间事件,wallet-service 负责余额和提现,新增 host-service 负责主播、Agency、BD 层级、申请邀请和 App 查询读模型。
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。
- App 侧可以展示 host 统计、Agency 成员、申请、邀请和后台已经生成的收益结果。
- App 侧不能创建政策、计算工资周期、审核工资单或直接给
USD_BALANCE入账。
Non-Goals
- 不把主播组织关系写进
room-service。Room Cell 仍然只拥有房间状态、麦位、presence、榜单和房间 outbox。 - 不在本文设计后台政策、工资周期、工资单审批或人工调整;这些属于 Admin 文档。
- 不用 Redis 保存主播身份、Agency 归属或收益事实。Redis 只能做短 TTL 读缓存或限流。
- 不在客户端自报区域、身份或政策。区域来自
user-service.users.region_id,身份和政策来自host-service。 - 不让关系变更追溯改历史统计。每个工作事件保存当时的关系快照,后续后台结算按快照读取。
Vocabulary
| Term | Meaning | Owner |
|---|---|---|
host |
业务主播身份,长期有效,可没有 Agency | host-service |
agency |
代理组织,由一个用户拥有,拥有者自动是 host | host-service |
agency owner |
Agency 的拥有者用户,既是 Agency 身份也是 host | host-service |
BD |
拓展 Agency 的业务角色,默认不是 host | host-service |
BD Leader |
BD 的上级,同时也是 BD,可以邀请 BD 和 Agency | host-service |
agency application |
用户申请加入 Agency 的 App 流程 | host-service |
role invitation |
BD/BD Leader 邀请用户成为 Agency 或 BD 的 App 流程 | host-service |
earning summary |
后台已结算或待结算收益的 App 展示读模型 | host-service reads, wallet-service owns balance |
Service Boundaries
graph LR
Client["Client"] --> Gateway["gateway-service"]
Gateway --> User["user-service"]
Gateway --> Host["host-service"]
Gateway --> Room["room-service"]
Gateway --> Wallet["wallet-service"]
Room --> RoomOutbox[("room_outbox")]
Wallet --> WalletOutbox[("wallet_outbox")]
RoomOutbox --> MQ["MQ / event bus"]
WalletOutbox --> MQ
MQ --> Host
Host --> HostDB[("host MySQL")]
User --> UserDB[("user MySQL")]
Wallet --> WalletDB[("wallet MySQL")]
| Service | Responsibility |
|---|---|
gateway-service |
HTTP 入口、鉴权、request envelope、调用内部 gRPC |
user-service |
用户主数据、国家、区域、账号状态 |
room-service |
房间事件:上麦、确认发流、下麦、送礼、离房 |
wallet-service |
USD_BALANCE、收益余额、提现冻结/审核/出账 |
host-service |
host 身份、Agency、BD 层级、申请邀请、关系快照、App 查询读模型 |
host-service 读取 room-service 和 wallet-service 事件,但不反向修改房间状态。App 查询只读取当前身份、关系、申请、邀请、统计和后台已生成的收益结果;结算写入链路见 Admin 文档。
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 侧只展示结果和状态,不计算金额。
Relationship Model
关系是有生效时间的事实,不应该只保存当前字段。当前字段用于 App 查询快照,历史表用于审计和后台结算。
erDiagram
USERS ||--o| HOST_PROFILES : "may become"
USERS ||--o| BD_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 {
bigint user_id PK
string status
bigint region_id
bigint current_agency_id
bigint current_membership_id
bigint first_became_host_at_ms
}
AGENCIES {
bigint agency_id PK
bigint owner_user_id UK
bigint region_id
bigint parent_bd_user_id
string status
bool join_enabled
}
BD_PROFILES {
bigint user_id PK
string role
bigint region_id
bigint parent_leader_user_id
string status
}
AGENCY_MEMBERSHIPS {
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
host_profiles(
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 (region_id, status),
KEY idx_host_profiles_agency (current_agency_id, status)
);
agencies(
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 (owner_user_id),
KEY idx_agencies_region_status (region_id, status, join_enabled),
KEY idx_agencies_parent_bd (parent_bd_user_id, status)
);
bd_profiles(
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 (region_id, role, status),
KEY idx_bd_profiles_parent_leader (parent_leader_user_id, status)
);
agency_memberships(
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 (agency_id, status),
KEY idx_agency_memberships_host_status (host_user_id, status),
KEY idx_agency_memberships_effective (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。
Region Rules
用户所属区域来自 user-service.users.region_id,不能由客户端提交。
| Action | Region Rule |
|---|---|
| 搜索 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
sequenceDiagram
participant C as Client
participant G as gateway-service
participant U as user-service
participant H as host-service
C->>G: GET /agencies/search
G->>U: GetUserRegion(user_id)
G->>H: SearchAgencies(region_id)
H-->>G: active agencies in region
G-->>C: agency list
C->>G: POST /host/applications
G->>U: GetUserRegion(user_id)
G->>H: ApplyToAgency(user_id, agency_id, region_id)
H->>H: lock user host profile and agency
H->>H: create pending application
H-->>G: application
Approval:
- Agency owner or authorized Agency manager reviews pending application.
host-servicelocks application, host profile and agency.- If host profile does not exist, create
host_profiles(status=active). - If host has no active membership, create
agency_memberships(status=active). - Update
host_profiles.current_agency_id/current_membership_id. - Emit
HostAgencyMembershipChanged.
Rejection only changes application status; it does not create host identity.
Agency Kicks Host
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
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
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 和价格版本 |
HostAgencyMembershipChanged |
host-service outbox |
关系变化审计和缓存失效 |
有效上麦时长只计算 publish_state=publishing 的时间段。pending_publish 不计有效工作时长,避免用户占麦但 RTC 没有成功发流时产生收益口径。
Daily Stats
host_daily_stats(
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 (stat_date, host_user_id, relation_scope_key)
);
host_event_dedup(
event_id VARCHAR(128) NOT NULL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
occurred_at_ms BIGINT NOT NULL,
consumed_at_ms BIGINT NOT NULL
);
规则:
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 天;不要硬编码在统计表结构里。- 所有事件消费必须按
event_id幂等。 - 迟到事件可以修正未锁定 stats;已生成收益结果的修正由 Admin 调整单处理,App 只读取最终状态。
App Read Models
App 侧不要实时扫描关系历史表。host-service 应该维护面向 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
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
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. Internal services use gRPC + protobuf.
Public App APIs
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
App API 必须使用 /api/v1 envelope。用户身份来自 gateway 鉴权,request_id 只做链路追踪,不做幂等键;业务命令必须使用客户端生成或服务端下发的 command_id。
Internal gRPC Sketch
service HostService {
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 GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
rpc GetHostEarningSummary(GetHostEarningSummaryRequest) returns (GetHostEarningSummaryResponse);
}
当前仍处于开发阶段,proto 可以按当前事实直接调整;生成代码后必须运行 make proto。
Concurrency And Idempotency
- Application create uses
command_idand(applicant_user_id, status=pending)guard. - Invitation create uses
command_idand 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
host-service should write its own outbox:
| 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。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
hostidentity 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.