31 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 服务边界重新设计:不新增独立 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 侧不能创建政策、计算工资周期、审核工资单或直接给
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-servicehost 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
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 查询快照,历史表用于审计和后台结算。
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
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 为准;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
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:
- Agency owner or authorized Agency manager reviews pending application.
user-servicehost domain locks 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 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。
麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 User Mic Online Time Architecture。本节的 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
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
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. Host/Agency/BD internal methods live in user-service gRPC + protobuf; they are not a separate deployable service.
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
POST /api/v1/wallet/coin-seller/transfer
App API 必须使用 /api/v1 envelope。用户身份来自 gateway 鉴权,request_id 由后端生成且只做链路追踪,不做幂等键;业务命令必须使用客户端按用户动作生成的 command_id。
Internal gRPC Sketch
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_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
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。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。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.