package host import ( "context" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" hostservice "hyapp/services/user-service/internal/service/host" ) // ApplyToAgency 创建待处理申请;用户行和 Agency 行在同一事务内锁定。 func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.ApplyToAgencyCommand) (hostdomain.AgencyApplication, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.AgencyApplication{}, err } defer tx.Rollback() // command_id 是客户端写请求的幂等键;只要上一轮事务已经提交, // 重试就按持久化的结果 ID 回放,避免重复创建申请或消耗新 ID。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.AgencyApplication{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeApplyToAgency, hostdomain.ResultTypeApplication); err != nil { return hostdomain.AgencyApplication{}, err } return queryAgencyApplication(ctx, tx, "WHERE application_id = ?", existing.ResultID) } // 申请人此时可能还没有 host_profiles 行,因此以 users 行作为“按用户串行化”的主锁。 // 区域也从用户表读取,保证申请创建时的区域是 user-service 的当前事实。 userRegionID, err := r.userRegion(ctx, tx, command.UserID, "FOR UPDATE") if err != nil { return hostdomain.AgencyApplication{}, err } if userRegionID <= 0 { return hostdomain.AgencyApplication{}, xerr.New(xerr.InvalidArgument, "user region is required") } // Agency 行必须锁住:加入开关、状态、区域是申请可创建性的条件, // 如果后台同时禁用加入或迁移区域,这里必须和申请创建串行。 agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID) if err != nil { return hostdomain.AgencyApplication{}, err } if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled { return hostdomain.AgencyApplication{}, xerr.New(xerr.Conflict, "agency is not joinable") } if agency.RegionID != userRegionID { return hostdomain.AgencyApplication{}, xerr.New(xerr.PermissionDenied, "agency region does not match user region") } // 第一阶段规则是一个 Host 同时只能归属一个有效 Agency; // 这里在同事务内查询并加锁,防止并发申请绕过唯一归属约束。 if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.UserID, true); err != nil { return hostdomain.AgencyApplication{}, err } else if ok { return hostdomain.AgencyApplication{}, xerr.New(xerr.Conflict, "user already has active agency membership") } application := hostdomain.AgencyApplication{ ApplicationID: command.ApplicationID, CommandID: command.CommandID, ApplicantUserID: command.UserID, AgencyID: command.AgencyID, RegionID: userRegionID, Status: hostdomain.ApplicationStatusPending, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } // 申请记录和 command_result 同事务提交:如果申请记录成功但幂等记录失败, // 客户端重试会失去可回放结果,因此两者必须作为一个原子写入单元。 if err := insertAgencyApplication(ctx, tx, application); err != nil { return hostdomain.AgencyApplication{}, mapHostDuplicateError(err) } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeApplyToAgency, ResultType: hostdomain.ResultTypeApplication, ResultID: application.ApplicationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.AgencyApplication{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { return hostdomain.AgencyApplication{}, err } return application, nil } // ReviewAgencyApplication 审核申请;通过路径同时固化 Host 身份和成员关系。 func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostservice.ReviewAgencyApplicationCommand) (hostdomain.ReviewAgencyApplicationResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } defer tx.Rollback() // 审核动作可能由 App 重试触发;命中 command_result 时按申请 ID 重建完整返回值, // 不再重复执行通过/拒绝状态机。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeReviewAgencyApplication, hostdomain.ResultTypeApplication); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } return reviewResultByApplicationID(ctx, tx, existing.ResultID) } // 申请行是审核状态机的核心锁;同一个待处理申请只能被一个事务推进到终态。 application, err := queryAgencyApplication(ctx, tx, "WHERE application_id = ? FOR UPDATE", command.ApplicationID) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } if application.Status != hostdomain.ApplicationStatusPending { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "application is not pending") } // 当前阶段的授权边界是 Agency 拥有者;后续如果引入管理员代审, // 应该在这里扩展授权规则,而不是绕过持久化层的事务检查。 agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", application.AgencyID) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } if agency.OwnerUserID != command.ReviewerUserID { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "reviewer is not agency owner") } if agency.Status != hostdomain.AgencyStatusActive { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "agency is not active") } // 申请创建后用户区域可能被后台调整;通过前重新锁 users 行校验, // 确保最终成员关系的区域仍然和申请、Agency 三方一致。 regionID, err := r.userRegion(ctx, tx, application.ApplicantUserID, "FOR UPDATE") if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } if regionID != application.RegionID || regionID != agency.RegionID { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "application region no longer matches") } application.ReviewedByUserID = command.ReviewerUserID application.ReviewReason = command.Reason application.ReviewedAtMs = command.NowMs application.UpdatedAtMs = command.NowMs result := hostdomain.ReviewAgencyApplicationResult{Application: application} if command.Decision == hostdomain.ApplicationDecisionReject { // 拒绝只推进申请终态,不创建 host_profile,不改成员关系, // 这样被拒用户之后仍可以重新申请或被邀请。 application.Status = hostdomain.ApplicationStatusRejected if err := updateAgencyApplicationReview(ctx, tx, application); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } result.Application = application } else { // 通过前再次检查有效成员关系,覆盖“申请后已通过其他路径入会”的并发场景。 if _, ok, err := queryActiveMembershipByHost(ctx, tx, application.ApplicantUserID, true); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } else if ok { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "user already has active agency membership") } // host_profile 是用户成为主播的长期身份事实;成员关系是当前 Agency 归属事实。 // 两者和申请审核结果必须原子提交,否则列表页和身份页会看到不一致状态。 hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, application.ApplicantUserID, application.RegionID, command.MembershipID, application.AgencyID, hostdomain.HostSourceApplication, command.NowMs) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } membership := hostdomain.AgencyMembership{ MembershipID: command.MembershipID, AgencyID: application.AgencyID, HostUserID: application.ApplicantUserID, RegionID: application.RegionID, MembershipType: hostdomain.MembershipTypeMember, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } if err := insertAgencyMembership(ctx, tx, membership); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, mapHostDuplicateError(err) } application.Status = hostdomain.ApplicationStatusApproved if err := updateAgencyApplicationReview(ctx, tx, application); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } // 事件箱是派生读模型/异步集成的可靠通知,不参与业务事实判定; // 但必须和事实同事务写入,避免事实提交后事件丢失。 if created { if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", application.ApplicantUserID, command.NowMs); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } } if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } result = hostdomain.ReviewAgencyApplicationResult{ Application: application, HostProfile: hostProfile, Membership: membership, } } // command_result 放在状态机成功之后写入;如果前面的业务写失败, // 不会留下“已处理”的幂等记录误导下一次重试。 if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeReviewAgencyApplication, ResultType: hostdomain.ResultTypeApplication, ResultID: application.ApplicationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } return result, nil } // KickAgencyHost 只结束 Agency 成员关系,不删除 Host 身份。 func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.KickAgencyHostResult{}, err } defer tx.Rollback() // 踢人也是写命令:同一个 command_id 重试时返回上次结束的成员关系, // 不会重复写 ended_at 或再次投递成员关系变更事件。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.KickAgencyHostResult{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeKickAgencyHost, hostdomain.ResultTypeMembership); err != nil { return hostdomain.KickAgencyHostResult{}, err } return kickResultByMembershipID(ctx, tx, existing.ResultID) } // 第一阶段只允许 Agency 拥有者管理成员;Agency 行加锁后再校验拥有者, // 避免所有权变更和成员管理在同一时刻交叉。 agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID) if err != nil { return hostdomain.KickAgencyHostResult{}, err } if agency.OwnerUserID != command.OperatorUserID { return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.PermissionDenied, "operator is not agency owner") } if agency.OwnerUserID == command.HostUserID { return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "agency owner cannot be kicked") } // host_profile 保留主播身份,只清理当前归属指针;因此这里先锁住身份行, // 后续成员关系结束和 current_membership 清空能在同事务内完成。 hostProfile, err := queryHostProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.HostUserID) if err != nil { return hostdomain.KickAgencyHostResult{}, err } // 成员关系行是被结束的事实本体;只处理有效普通成员, // 拥有者成员关系不能通过踢人路径结束。 membership, err := queryAgencyMembership(ctx, tx, "WHERE agency_id = ? AND host_user_id = ? AND status = ? FOR UPDATE", command.AgencyID, command.HostUserID, hostdomain.MembershipStatusActive) if err != nil { return hostdomain.KickAgencyHostResult{}, err } if membership.MembershipType == hostdomain.MembershipTypeOwner { return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "owner membership cannot be kicked") } membership.Status = hostdomain.MembershipStatusEnded membership.EndedAtMs = command.NowMs membership.EndedByUserID = command.OperatorUserID membership.EndedReason = command.Reason membership.UpdatedAtMs = command.NowMs if err := endAgencyMembership(ctx, tx, membership); err != nil { return hostdomain.KickAgencyHostResult{}, err } if hostProfile.CurrentMembershipID == membership.MembershipID { // 只在身份行当前指向这条成员关系时清空,避免误伤未来可能出现的重新入会关系。 hostProfile.CurrentAgencyID = 0 hostProfile.CurrentMembershipID = 0 hostProfile.UpdatedAtMs = command.NowMs if err := updateHostCurrentMembership(ctx, tx, hostProfile); err != nil { return hostdomain.KickAgencyHostResult{}, err } } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeKickAgencyHost, ResultType: hostdomain.ResultTypeMembership, ResultID: membership.MembershipID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.KickAgencyHostResult{}, mapHostDuplicateError(err) } if err := insertHostOutbox(ctx, tx, command.EventID, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil { return hostdomain.KickAgencyHostResult{}, err } if err := tx.Commit(); err != nil { return hostdomain.KickAgencyHostResult{}, err } return hostdomain.KickAgencyHostResult{Membership: membership, HostProfile: hostProfile}, nil }