package host import ( "context" "database/sql" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" hostservice "hyapp/services/user-service/internal/service/host" ) // InviteAgency 创建 Agency 角色邀请,校验 inviter BD 身份和目标用户区域。 func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.RoleInvitation{}, err } defer tx.Rollback() // 邀请是可重试写命令;命中幂等结果时返回既有邀请, // 不重新校验当前 BD/用户状态,保持“已提交结果可回放”的语义。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeInviteAgency, hostdomain.ResultTypeInvitation); err != nil { return hostdomain.RoleInvitation{}, err } return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID) } // InviteAgency 没有显式角色字段;普通 BD 行优先承载 BD Center 邀请,普通 BD 不可用时再使用 Leader 行承载负责人直属邀请。 // 两种身份都用 FOR UPDATE 串行化停用/换区和邀请创建,避免旧权限在同一事务窗口内继续写入。 inviter, err := queryActiveAgencyInviterProfile(ctx, tx, command.InviterUserID) if err != nil { return hostdomain.RoleInvitation{}, err } // 目标用户还不是 Host/Agency 时没有领域表行,所以锁 users 行来串行化目标身份变更。 targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { return hostdomain.RoleInvitation{}, err } if targetRegionID != inviter.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region") } // Agency 拥有者必须没有当前有效 Agency 归属;已有 Host 身份但未归属 Agency 时可以复用身份。 if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership") } // 一个用户只能拥有一个有效 Agency;这里先拦截,最终仍依赖唯一索引兜底并发。 if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency") } // BD 发出的 Agency 邀请要记录直接上级 BD 和负责人链路; // 如果本次以负责人身份邀请,则直接 BD 和负责人都指向 Leader 自己。 parentLeaderUserID := inviter.ParentLeaderUserID if inviter.Role == hostdomain.BDRoleLeader { parentLeaderUserID = inviter.UserID } invitation := hostdomain.RoleInvitation{ InvitationID: command.InvitationID, CommandID: command.CommandID, InvitationType: hostdomain.InvitationTypeAgency, Status: hostdomain.InvitationStatusAccepted, InviterUserID: command.InviterUserID, InviterBDUserID: inviter.UserID, TargetUserID: command.TargetUserID, RegionID: inviter.RegionID, AgencyName: command.AgencyName, ParentBDUserID: inviter.UserID, ParentLeaderUserID: parentLeaderUserID, ProcessedByUserID: command.InviterUserID, ProcessReason: "direct_invite", ProcessedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } // 新邀请不再等待目标用户确认:accepted invitation、Agency、owner Host 和 owner membership 必须同事务提交。 if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} hostProfileCreated, err := r.acceptAgencyInvitation(ctx, tx, hostservice.ProcessRoleInvitationCommand{ CommandID: command.CommandID, ActorUserID: command.InviterUserID, InvitationID: command.InvitationID, Action: hostdomain.InvitationActionAccept, Reason: "direct_invite", AgencyID: command.AgencyID, MembershipID: command.MembershipID, EventID: command.EventID, NowMs: command.NowMs, }, &invitation, &result) if err != nil { return hostdomain.RoleInvitation{}, err } if hostProfileCreated { if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } } if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeInviteAgency, ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { return hostdomain.RoleInvitation{}, err } return invitation, nil } // InviteBD 创建 BD Leader 到 BD 的邀请。 func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDCommand) (hostdomain.RoleInvitation, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.RoleInvitation{}, err } defer tx.Rollback() // BD 邀请也以 command_id 去重;已成功的邀请不会因为负责人后续状态变化而回放失败。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeInviteBD, hostdomain.ResultTypeInvitation); err != nil { return hostdomain.RoleInvitation{}, err } return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID) } // 只有有效 BD 负责人能发展 BD;锁住独立 Leader 表行让停用和邀请创建串行。 inviter, err := queryBDLeaderProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.InviterUserID) if err != nil { return hostdomain.RoleInvitation{}, err } if inviter.Status != hostdomain.BDStatusActive { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter is not active bd leader") } // BD 从属于负责人的区域,邀请创建时先锁目标用户当前区域。 targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { return hostdomain.RoleInvitation{}, err } if targetRegionID != inviter.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region") } // 目标已有普通 BD 身份行时不再创建新邀请,即使已停用也不能再插入第二条普通 BD 事实。 // BD Leader 自邀也必须新增普通 BD 行,后续才能获得 BD_SALARY_USD 钱包鉴权。 if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has bd profile") } invitation := hostdomain.RoleInvitation{ InvitationID: command.InvitationID, CommandID: command.CommandID, InvitationType: hostdomain.InvitationTypeBD, Status: hostdomain.InvitationStatusAccepted, InviterUserID: command.InviterUserID, InviterBDUserID: inviter.UserID, TargetUserID: command.TargetUserID, RegionID: inviter.RegionID, ParentLeaderUserID: inviter.UserID, ProcessedByUserID: command.InviterUserID, ProcessReason: "direct_invite", ProcessedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } // 新 BD 邀请直接创建 BD 身份;accepted invitation 只作为审计事实和幂等回放结果。 if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } recordCommandResult := func() error { return insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeInviteBD, ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs, }) } result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} if err := r.acceptBDInvitation(ctx, tx, hostservice.ProcessRoleInvitationCommand{ CommandID: command.CommandID, ActorUserID: command.InviterUserID, InvitationID: command.InvitationID, Action: hostdomain.InvitationActionAccept, Reason: "direct_invite", EventID: command.EventID, NowMs: command.NowMs, }, &invitation, &result); err != nil { return hostdomain.RoleInvitation{}, err } if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } if err := recordCommandResult(); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { return hostdomain.RoleInvitation{}, err } return invitation, nil } func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int64) (hostdomain.BDProfile, error) { if profile, ok, err := queryBDProfileMaybe(ctx, tx, userID, true); err != nil || ok { return profile, err } if profile, ok, err := queryBDLeaderProfileMaybe(ctx, tx, userID, true); err != nil || ok { return profile, err } return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "inviter bd is not active") } // ProcessRoleInvitation 处理角色邀请,接受路径在同一事务内创建 Agency/BD 角色事实。 func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostservice.ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } defer tx.Rollback() // 接受、拒绝、取消共用同一个处理命令类型;重试时通过 invitation_id 重建结果, // 不重复创建 Agency、成员关系或 BD 身份行。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeProcessRoleInvitation, hostdomain.ResultTypeInvitation); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } return processResultByInvitationID(ctx, tx, existing.ResultID) } // 邀请行是邀请状态机的唯一锁;待处理状态只能被推进一次。 invitation, err := queryRoleInvitation(ctx, tx, "WHERE invitation_id = ? FOR UPDATE", command.InvitationID) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } if invitation.Status != hostdomain.InvitationStatusPending { return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.Conflict, "invitation is not pending") } // 取消属于发起人撤回;接受/拒绝属于目标用户处理。 // 这两个授权分支不要下沉到传输层,否则批处理或内部调用会绕过规则。 if command.Action == hostdomain.InvitationActionCancel && command.ActorUserID != invitation.InviterUserID { return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.PermissionDenied, "only inviter can cancel invitation") } if command.Action != hostdomain.InvitationActionCancel && command.ActorUserID != invitation.TargetUserID { return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.PermissionDenied, "only target can process invitation") } invitation.ProcessedByUserID = command.ActorUserID invitation.ProcessReason = command.Reason invitation.ProcessedAtMs = command.NowMs invitation.UpdatedAtMs = command.NowMs result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} switch command.Action { case hostdomain.InvitationActionReject: // 拒绝只改变邀请终态,不创建任何角色事实。 invitation.Status = hostdomain.InvitationStatusRejected case hostdomain.InvitationActionCancel: // 取消同样只关闭邀请,用于邀请人在目标处理前撤回。 invitation.Status = hostdomain.InvitationStatusCancelled case hostdomain.InvitationActionAccept: invitation.Status = hostdomain.InvitationStatusAccepted if invitation.InvitationType == hostdomain.InvitationTypeAgency { // Agency 邀请的接受会一次性创建 Agency 拥有者身份、Agency 和拥有者成员关系。 hostProfileCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } // 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录, // 让同一个接受事务中的派生事件保持可追踪顺序。 if hostProfileCreated { if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } } if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } } else if invitation.InvitationType == hostdomain.InvitationTypeBD { // BD 邀请的接受只创建 bd_profile;BD 不需要 host_profile。 if err := r.acceptBDInvitation(ctx, tx, command, &invitation, &result); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } } else { return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.InvalidArgument, "invitation type is invalid") } } // 邀请终态必须在角色事实之后更新;如果事实创建失败,待处理状态保留给后续重试/处理。 if err := updateRoleInvitationProcessed(ctx, tx, invitation); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } result.Invitation = invitation if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeProcessRoleInvitation, ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.ProcessRoleInvitationResult{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } return result, nil } func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) { // 邀请可能放置一段时间后才被接受,所以必须用当前用户区域再校验一次。 regionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return false, err } if regionID != invitation.RegionID { return false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation") } // 目标用户在邀请创建后可能已经加入其他 Agency;接受前必须再次阻断有效归属。 if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil { return false, err } else if ok { return false, xerr.New(xerr.Conflict, "target already has active agency membership") } // 同一用户不能拥有多个有效 Agency;这里和唯一索引共同处理并发接受。 if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil { return false, err } else if ok { return false, xerr.New(xerr.Conflict, "target already owns active agency") } // Agency、拥有者 host_profile、拥有者成员关系是同一业务事实的三张表投影, // 必须全部创建成功后邀请才能进入已接受状态。 agency := hostdomain.Agency{ AgencyID: command.AgencyID, OwnerUserID: invitation.TargetUserID, RegionID: invitation.RegionID, ParentBDUserID: invitation.ParentBDUserID, Name: invitation.AgencyName, Status: hostdomain.AgencyStatusActive, JoinEnabled: true, // max_hosts 是历史兼容字段,当前业务不再限制主播数量。 MaxHosts: 0, CreatedByUserID: invitation.InviterUserID, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } if err := insertAgency(ctx, tx, agency); err != nil { return false, mapHostDuplicateError(err) } hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, invitation.RegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs) if err != nil { return false, err } membership := hostdomain.AgencyMembership{ MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: invitation.TargetUserID, RegionID: invitation.RegionID, MembershipType: hostdomain.MembershipTypeOwner, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } if err := insertAgencyMembership(ctx, tx, membership); err != nil { return false, mapHostDuplicateError(err) } result.HostProfile = hostProfile result.Agency = agency result.Membership = membership return hostProfileCreated, nil } func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error { // BD 邀请同样以当前用户区域为准;跨区调整后不能接受旧区域邀请。 regionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return err } if regionID != invitation.RegionID { return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation") } // BD 身份是独立角色事实,不依赖 host_profile;任何已有普通 BD 行都不能重复插入。 if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil { return err } else if ok { return xerr.New(xerr.Conflict, "target already has bd profile") } bd := hostdomain.BDProfile{ UserID: invitation.TargetUserID, Role: hostdomain.BDRoleBD, RegionID: invitation.RegionID, ParentLeaderUserID: invitation.ParentLeaderUserID, Status: hostdomain.BDStatusActive, CreatedByUserID: invitation.InviterUserID, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } if err := insertBDProfile(ctx, tx, bd); err != nil { return mapHostDuplicateError(err) } result.BDProfile = bd return nil }