package host import ( "context" "database/sql" "encoding/json" "fmt" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" ) // commandResult 读取写命令流水。 // 命中表示前一次业务事务已经提交,调用方必须按持久化结果重建返回值。 func commandResult(ctx context.Context, q sqlQueryer, commandID string) (hostdomain.CommandResult, bool, error) { var result hostdomain.CommandResult err := q.QueryRowContext(ctx, ` SELECT command_id, command_type, result_type, result_id, created_at_ms FROM host_command_results WHERE app_code = ? AND command_id = ? `, appcode.FromContext(ctx), commandID).Scan(&result.CommandID, &result.CommandType, &result.ResultType, &result.ResultID, &result.CreatedAtMs) if err == sql.ErrNoRows { return hostdomain.CommandResult{}, false, nil } if err != nil { return hostdomain.CommandResult{}, false, err } return result, true, nil } // insertCommandResult 必须放在每个状态机成功之后写入。 // 它必须和领域事实共用同一事务,避免重试看到半提交命令。 func insertCommandResult(ctx context.Context, tx *sql.Tx, result hostdomain.CommandResult) error { _, err := tx.ExecContext(ctx, ` INSERT INTO host_command_results (app_code, command_id, command_type, result_type, result_id, created_at_ms) VALUES (?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), result.CommandID, result.CommandType, result.ResultType, result.ResultID, result.CreatedAtMs) return err } // insertHostOutbox 在产生事实的同一事务里记录可靠集成事件。 // 消费者可以延迟或重建;真正的事实来源仍是和本行一起提交的领域表。 func insertHostOutbox(ctx context.Context, tx *sql.Tx, eventID int64, eventType string, aggregateType string, aggregateID int64, nowMs int64) error { payload, err := json.Marshal(map[string]any{ "event_type": eventType, "app_code": appcode.FromContext(ctx), "aggregate_type": aggregateType, "aggregate_id": aggregateID, }) if err != nil { return err } _, err = tx.ExecContext(ctx, ` INSERT INTO host_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms) VALUES (?, ?, ?, ?, ?, 'pending', CAST(? AS JSON), ?) `, appcode.FromContext(ctx), eventID, eventType, aggregateType, fmt.Sprintf("%d", aggregateID), string(payload), nowMs) return err } func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membershipID int64, agencyID int64, source string, nowMs int64) (hostdomain.HostProfile, bool, error) { // host_profile 对每个用户只有一行,并且会跨 Agency 成员关系变化长期保留。 // FOR UPDATE 保证“创建身份或挂载当前成员关系”对同一用户是原子的。 profile, err := scanHostProfile(tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM host_profiles WHERE app_code = ? AND user_id = ? FOR UPDATE`, hostProfileColumns), appcode.FromContext(ctx), userID)) if err == sql.ErrNoRows { // 第一次通过申请时创建长期 Host 身份,并记录 first_became_host_at_ms。 profile = hostdomain.HostProfile{ UserID: userID, Status: hostdomain.HostStatusActive, RegionID: regionID, CurrentAgencyID: agencyID, CurrentMembershipID: membershipID, Source: source, FirstBecameHostAtMs: nowMs, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } return profile, true, insertHostProfile(ctx, tx, profile) } if err != nil { return hostdomain.HostProfile{}, false, err } if profile.Status != hostdomain.HostStatusActive { // disabled 不再算有效 Host 权限;当用户通过邀请或后台开通重新获得 Host 身份时, // 复用原主键行并挂到本次新 membership,避免因为历史停用记录导致身份补齐失败。 profile.Status = hostdomain.HostStatusActive profile.RegionID = regionID profile.CurrentAgencyID = agencyID profile.CurrentMembershipID = membershipID profile.UpdatedAtMs = nowMs return profile, true, updateHostProfileForMembership(ctx, tx, profile) } if profile.CurrentMembershipID > 0 { // current_membership 是为“我的 Agency”快速读取做的冗余;非 0 表示已经挂载。 return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host already has active agency membership") } // 复用已有身份行时不能重置最早成为主播的时间和来源。 profile.RegionID = regionID profile.CurrentAgencyID = agencyID profile.CurrentMembershipID = membershipID profile.UpdatedAtMs = nowMs return profile, false, updateHostCurrentMembership(ctx, tx, profile) } func ensureHostProfileForExistingMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membership hostdomain.AgencyMembership, source string, nowMs int64) (hostdomain.HostProfile, bool, error) { profile, ok, err := queryHostProfileByUserMaybe(ctx, tx, userID, true) if err != nil { return hostdomain.HostProfile{}, false, err } if !ok { // 极端历史数据可能只有 active membership 没有 host_profile;这里补回 Host 身份, // 但指针仍指向已有 membership,绝不把用户重新绑定到新创建的 Agency。 profile = hostdomain.HostProfile{ UserID: userID, Status: hostdomain.HostStatusActive, RegionID: regionID, CurrentAgencyID: membership.AgencyID, CurrentMembershipID: membership.MembershipID, Source: source, FirstBecameHostAtMs: nowMs, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } return profile, true, insertHostProfile(ctx, tx, profile) } changed := profile.Status != hostdomain.HostStatusActive || profile.CurrentAgencyID != membership.AgencyID || profile.CurrentMembershipID != membership.MembershipID profile.Status = hostdomain.HostStatusActive profile.RegionID = regionID profile.CurrentAgencyID = membership.AgencyID profile.CurrentMembershipID = membership.MembershipID profile.UpdatedAtMs = nowMs if !changed { return profile, false, nil } return profile, true, updateHostProfileForMembership(ctx, tx, profile) } func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { // 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。 _, err := tx.ExecContext(ctx, ` INSERT INTO host_profiles ( app_code, user_id, status, current_agency_id, current_membership_id, source, first_became_host_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.Source, profile.FirstBecameHostAtMs, profile.CreatedAtMs, profile.UpdatedAtMs) return err } func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { // 这里只更新可变的“当前成员关系指针”字段。 // 状态、来源、首次成为主播时间属于身份历史,不在这里重写。 _, err := tx.ExecContext(ctx, ` UPDATE host_profiles SET current_agency_id = ?, current_membership_id = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func disableHostProfileAfterAgencyKick(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { // Agency 移除普通 Host 的产品语义是收回主播身份;历史行保留给审计和后续重新开通复用。 // 当前归属必须同步清空,否则 App 入口和送礼结算会继续把 ended membership 当作有效关系。 _, err := tx.ExecContext(ctx, ` UPDATE host_profiles SET status = ?, current_agency_id = NULL, current_membership_id = NULL, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { // 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属; // source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。 _, err := tx.ExecContext(ctx, ` UPDATE host_profiles SET status = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func updateUserRegion(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, nowMs int64) error { _, err := tx.ExecContext(ctx, ` UPDATE users SET region_id = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, nullableRegionID(regionID), nowMs, appcode.FromContext(ctx), userID) return err } func insertAgency(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error { // 第一阶段只有接受 Agency 邀请时会创建 Agency。 _, err := tx.ExecContext(ctx, ` INSERT INTO agencies ( app_code, agency_id, owner_user_id, parent_bd_user_id, name, avatar, status, join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.ParentBDUserID, agency.Name, agency.Avatar, agency.Status, agency.JoinEnabled, agency.MaxHosts, agency.CreatedByUserID, agency.CreatedAtMs, agency.UpdatedAtMs) return err } func updateAgencyAdminState(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error { // 后台 Phase 2 只允许调整 Agency 状态和入会开关;owner/parent_bd/region 不在这里迁移。 _, err := tx.ExecContext(ctx, ` UPDATE agencies SET status = ?, join_enabled = ?, updated_at_ms = ? WHERE app_code = ? AND agency_id = ? `, agency.Status, agency.JoinEnabled, agency.UpdatedAtMs, appcode.FromContext(ctx), agency.AgencyID) return err } func updateAgencyProfile(ctx context.Context, tx *sql.Tx, agency hostdomain.Agency) error { // Agency 资料是独立组织资料;这里绝不更新 users 表,也不从 owner 用户资料重新派生。 _, err := tx.ExecContext(ctx, ` UPDATE agencies SET name = ?, avatar = ?, updated_at_ms = ? WHERE app_code = ? AND agency_id = ? `, agency.Name, agency.Avatar, agency.UpdatedAtMs, appcode.FromContext(ctx), agency.AgencyID) return err } func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // 普通 BD 身份行和 host_profile 相互独立;BD 用户不会自动成为 Host,也不会隐式拥有 BD Leader 权限。 _, err := tx.ExecContext(ctx, ` INSERT INTO bd_profiles ( app_code, user_id, role, parent_leader_user_id, status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Role, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) return err } func insertBDLeaderProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // BD Leader 使用独立表承载负责人身份,避免和普通 BD 工资、状态、团队归属混成同一条事实。 _, err := tx.ExecContext(ctx, ` INSERT INTO bd_leader_profiles ( app_code, user_id, status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) return err } func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // 状态变更只更新 status/updated_at_ms,不改角色、区域和上级 Leader 这些身份历史字段。 _, err := tx.ExecContext(ctx, ` UPDATE bd_profiles SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND role = 'bd' `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func updateBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // disabled 普通 BD 被重新授予权限时才允许重写父级 Leader; // active BD 的 parent 代表既有业务关系,任何补齐逻辑都不能覆盖它。 _, err := tx.ExecContext(ctx, ` UPDATE bd_profiles SET parent_leader_user_id = ?, status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND role = 'bd' `, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。 _, err := tx.ExecContext(ctx, ` UPDATE bd_leader_profiles SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func ensureActiveBDLeaderProfile(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) { profile, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, userID, true) if err != nil { return hostdomain.BDProfile{}, "", err } if !ok { // 直开 BD Leader 时,负责人身份先成为 active 事实;普通 BD 钱包/工资身份由后续 helper 补齐。 profile = hostdomain.BDProfile{ UserID: userID, Role: hostdomain.BDRoleLeader, RegionID: regionID, Status: hostdomain.BDStatusActive, CreatedByUserID: createdByUserID, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } if err := insertBDLeaderProfile(ctx, tx, profile); err != nil { return hostdomain.BDProfile{}, "", mapHostDuplicateError(err) } return profile, "BDCreated", nil } if profile.Status == hostdomain.BDStatusActive { // 已经是 active Leader 时只回放当前事实,不重写 created_by,也不重复发状态事件。 return profile, "", nil } profile.Status = hostdomain.BDStatusActive profile.UpdatedAtMs = nowMs if err := updateBDLeaderProfileStatus(ctx, tx, profile); err != nil { return hostdomain.BDProfile{}, "", err } return profile, "BDStatusChanged", nil } func ensureSelfBDProfileForLeader(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) { profile, ok, err := queryBDProfileByUserMaybe(ctx, tx, userID, true) if err != nil { return hostdomain.BDProfile{}, "", err } if !ok { // BD Leader 需要普通 BD 身份承载下级 Agency/工资等 BD 体系能力; // 首次补齐时把 parent_leader_user_id 指向自己,形成自有 BD Leader 体系。 profile = hostdomain.BDProfile{ UserID: userID, Role: hostdomain.BDRoleBD, RegionID: regionID, ParentLeaderUserID: userID, Status: hostdomain.BDStatusActive, CreatedByUserID: createdByUserID, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } if err := insertBDProfile(ctx, tx, profile); err != nil { return hostdomain.BDProfile{}, "", mapHostDuplicateError(err) } return profile, "BDCreated", nil } if profile.Status == hostdomain.BDStatusActive { // 已有 active 普通 BD 说明用户已经在某个 BD 关系里;开通 Leader 不能覆盖原 parent。 return profile, "", nil } profile.ParentLeaderUserID = userID profile.Status = hostdomain.BDStatusActive profile.UpdatedAtMs = nowMs if err := updateBDProfileForActivation(ctx, tx, profile); err != nil { return hostdomain.BDProfile{}, "", err } return profile, "BDStatusChanged", nil } func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error { // 币商身份是独立角色事实;账户余额属于 wallet-service,不在此表冗余。 _, err := tx.ExecContext(ctx, ` INSERT INTO coin_seller_profiles ( app_code, user_id, status, merchant_asset_type, can_manage_sub_coin_sellers, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.MerchantAssetType, profile.CanManageSubCoinSellers, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) return err } func updateCoinSellerProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error { // 启停币商身份只改状态;merchant_asset_type 是账户边界,创建后不能静默迁移。 _, err := tx.ExecContext(ctx, ` UPDATE coin_seller_profiles SET status = ?, can_manage_sub_coin_sellers = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, profile.CanManageSubCoinSellers, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err } func insertCoinSellerRelation(ctx context.Context, tx *sql.Tx, relation hostdomain.CoinSellerSubRelation) error { // 直属关系是 H5 子币商转账的授权边界;唯一键保证一个子币商同一时间只有一个有效父币商。 _, err := tx.ExecContext(ctx, ` INSERT INTO coin_seller_relations ( app_code, relation_id, parent_user_id, child_user_id, status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), relation.RelationID, relation.ParentUserID, relation.ChildUserID, relation.Status, relation.CreatedByUserID, relation.CreatedAtMs, relation.UpdatedAtMs) return err } func insertAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdomain.AgencyMembership) error { // 成员关系是 Agency 关系历史行;有效且当前的关系也会冗余到身份表。 _, err := tx.ExecContext(ctx, ` INSERT INTO agency_memberships ( app_code, membership_id, agency_id, host_user_id, membership_type, status, joined_at_ms, ended_at_ms, ended_by_user_id, ended_reason, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), membership.MembershipID, membership.AgencyID, membership.HostUserID, membership.MembershipType, membership.Status, membership.JoinedAtMs, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.CreatedAtMs, membership.UpdatedAtMs) return err } func endAgencyMembership(ctx context.Context, tx *sql.Tx, membership hostdomain.AgencyMembership) error { // 结束成员关系只改状态,不删除历史行,便于审计和列表回看。 _, err := tx.ExecContext(ctx, ` UPDATE agency_memberships SET status = ?, ended_at_ms = ?, ended_by_user_id = ?, ended_reason = ?, updated_at_ms = ? WHERE app_code = ? AND membership_id = ? `, membership.Status, nullableRegionID(membership.EndedAtMs), nullableRegionID(membership.EndedByUserID), membership.EndedReason, membership.UpdatedAtMs, appcode.FromContext(ctx), membership.MembershipID) return err } func insertAgencyApplication(ctx context.Context, tx *sql.Tx, application hostdomain.AgencyApplication) error { // 申请记录只保留申请人和 Agency;区域每次读取时都从申请人 users 行投影。 _, err := tx.ExecContext(ctx, ` INSERT INTO agency_applications ( app_code, application_id, command_id, applicant_user_id, agency_id, status, reviewed_by_user_id, review_reason, reviewed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), application.ApplicationID, application.CommandID, application.ApplicantUserID, application.AgencyID, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.CreatedAtMs, application.UpdatedAtMs) return err } func updateAgencyApplicationReview(ctx context.Context, tx *sql.Tx, application hostdomain.AgencyApplication) error { // 审核更新是申请终态的唯一写入口。 _, err := tx.ExecContext(ctx, ` UPDATE agency_applications SET status = ?, reviewed_by_user_id = ?, review_reason = ?, reviewed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ? `, application.Status, nullableRegionID(application.ReviewedByUserID), application.ReviewReason, nullableRegionID(application.ReviewedAtMs), application.UpdatedAtMs, appcode.FromContext(ctx), application.ApplicationID) return err } func insertRoleInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error { // 邀请记录保留足够的上级 BD 信息,接受时不需要重新推导组织链路。 _, err := tx.ExecContext(ctx, ` INSERT INTO role_invitations ( app_code, invitation_id, command_id, invitation_type, status, inviter_user_id, inviter_bd_user_id, target_user_id, agency_name, parent_bd_user_id, parent_leader_user_id, processed_by_user_id, process_reason, processed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), invitation.InvitationID, invitation.CommandID, invitation.InvitationType, invitation.Status, invitation.InviterUserID, invitation.InviterBDUserID, invitation.TargetUserID, invitation.AgencyName, invitation.ParentBDUserID, nullableRegionID(invitation.ParentLeaderUserID), nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.CreatedAtMs, invitation.UpdatedAtMs) return err } func updateRoleInvitationProcessed(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error { // 处理邀请时统一记录处理人、原因和时间,覆盖接受、拒绝和取消。 _, err := tx.ExecContext(ctx, ` UPDATE role_invitations SET status = ?, processed_by_user_id = ?, process_reason = ?, processed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND invitation_id = ? `, invitation.Status, nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.UpdatedAtMs, appcode.FromContext(ctx), invitation.InvitationID) return err }