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 { return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host profile is not active") } 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 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, region_id, 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, profile.RegionID, 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 region_id = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.RegionID, 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, region_id, parent_bd_user_id, name, status, join_enabled, max_hosts, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), agency.AgencyID, agency.OwnerUserID, agency.RegionID, agency.ParentBDUserID, agency.Name, 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 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, region_id, parent_leader_user_id, status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Role, profile.RegionID, 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, region_id, status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.RegionID, 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 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 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, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?) `, appcode.FromContext(ctx), profile.UserID, profile.Status, profile.MerchantAssetType, 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 = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) 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, region_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.RegionID, 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 和区域快照,审核时基于这份快照再校验当前事实。 _, err := tx.ExecContext(ctx, ` INSERT INTO agency_applications ( app_code, application_id, command_id, applicant_user_id, agency_id, region_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.RegionID, 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, region_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.RegionID, 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 }