hyapp-server/docs/主播公会BD架构.md

31 KiB
Raw Blame History

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 归属于 BDBD 归属于 BD Leader。BD Leader 也是 BD只是具备邀请 BD 的能力。
  • BD 可以邀请用户成为 AgencyBD 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

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

user-service host domain 读取 room-servicewallet-service 事件但不反向修改房间状态。App 查询只读取当前身份、关系、申请、邀请、统计和后台已生成的收益结果;结算写入链路见 Admin 文档。

Multi-App Scope

host、Agency、BD、申请、邀请、统计和收益读模型都按 app_code 隔离。gateway 必须把客户端解析出的 app_code 写入 RequestMetauser-service host domain 的所有查询、锁、唯一约束、幂等结果和 outbox 都必须带 app_code

当前用户体系的 user_id 是内部稳定主键,业务查询仍必须带 app_code 做租户作用域。display_user_idcommand_idevent_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 UPDATEhost_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:

  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

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_profilesagency_membershipsagency_applicationshost_daily_stats
agency_member_view Agency owner 查看成员、成员状态、今日/本周期统计 agency_membershipshost_daily_stats
bd_team_view BD 查看下级 Agency 和邀请状态 bd_profilesagenciesrole_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_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 Architectureuser-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

  • 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.