package host import ( "context" "database/sql" "encoding/json" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" hostservice "hyapp/services/user-service/internal/service/host" ) const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated" // 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.RegionMismatch, "target region does not match inviter region") } // Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner, // 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 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.InvitationStatusPending, InviterUserID: command.InviterUserID, InviterBDUserID: inviter.UserID, TargetUserID: command.TargetUserID, RegionID: targetRegionID, AgencyName: command.AgencyName, ParentBDUserID: inviter.UserID, ParentLeaderUserID: parentLeaderUserID, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } // 邀请创建只落 pending 消息;Agency、owner Host 和 owner membership 必须等目标用户接受时再同事务创建。 if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(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 := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, 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.RegionMismatch, "target region does not match leader region") } // 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请, // 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。 if _, ok, err := queryBDProfileMaybe(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.InvitationStatusPending, InviterUserID: command.InviterUserID, InviterBDUserID: inviter.UserID, TargetUserID: command.TargetUserID, RegionID: targetRegionID, ParentLeaderUserID: inviter.UserID, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } // 新 BD 邀请只产生待确认消息;BD 身份要等目标用户接受后才创建。 if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeInviteBD, ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } if err := tx.Commit(); err != nil { return hostdomain.RoleInvitation{}, err } return invitation, nil } // InviteHost 创建 Agency owner 到目标用户的 Host 入会邀请。 func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteHostCommand) (hostdomain.RoleInvitation, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.RoleInvitation{}, err } defer tx.Rollback() // 发送邀请是独立写命令;重试只回放同一条 pending/终态邀请,不重新读取 owner 当前权限。 if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { if err := requireCommandResult(existing, hostdomain.CommandTypeInviteHost, hostdomain.ResultTypeInvitation); err != nil { return hostdomain.RoleInvitation{}, err } return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID) } // Agency owner 是邀请 Host 的唯一授权身份;锁 active agency 行可以让关闭、删除、禁入和邀请创建串行。 agency, ok, err := queryActiveAgencyByOwner(ctx, tx, command.InviterUserID, true) if err != nil { return hostdomain.RoleInvitation{}, err } if !ok || !agency.JoinEnabled { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter agency is not active or joinable") } // 目标用户可能还没有 host_profile,所以用 users 行作为目标身份变化的串行化锁。 targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { return hostdomain.RoleInvitation{}, err } if targetRegionID != agency.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match agency region") } // 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") } 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") } invitation := hostdomain.RoleInvitation{ InvitationID: command.InvitationID, CommandID: command.CommandID, InvitationType: hostdomain.InvitationTypeHost, Status: hostdomain.InvitationStatusPending, InviterUserID: command.InviterUserID, InviterBDUserID: agency.ParentBDUserID, TargetUserID: command.TargetUserID, RegionID: targetRegionID, AgencyName: agency.Name, ParentBDUserID: agency.ParentBDUserID, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID, CommandType: hostdomain.CommandTypeInviteHost, ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs, }); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } if err := tx.Commit(); err != nil { return hostdomain.RoleInvitation{}, err } return invitation, nil } // GetRoleInvitation 读取单条邀请并校验 actor 只能看到自己发出或收到的邀请。 func (r *Repository) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) { invitation, err := queryRoleInvitation(ctx, r.db, "WHERE invitation_id = ?", invitationID) if err != nil { return hostdomain.RoleInvitation{}, err } if actorUserID != invitation.InviterUserID && actorUserID != invitation.TargetUserID { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "role invitation is not visible to actor") } return invitation, nil } type roleInvitationUserSnapshot struct { UserID int64 `json:"user_id"` DisplayUserID string `json:"display_user_id"` Username string `json:"username"` Avatar string `json:"avatar"` } type roleInvitationCreatedPayload struct { InvitationID int64 `json:"invitation_id"` InvitationType string `json:"invitation_type"` Status string `json:"status"` InviterUserID int64 `json:"inviter_user_id"` InviterBDUserID int64 `json:"inviter_bd_user_id"` TargetUserID int64 `json:"target_user_id"` RegionID int64 `json:"region_id"` AgencyName string `json:"agency_name"` ParentBDUserID int64 `json:"parent_bd_user_id"` ParentLeaderUserID int64 `json:"parent_leader_user_id"` Inviter roleInvitationUserSnapshot `json:"inviter"` Target roleInvitationUserSnapshot `json:"target"` CreatedAtMS int64 `json:"created_at_ms"` } func insertRoleInvitationCreatedOutbox(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, nowMs int64) error { inviter, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.InviterUserID) if err != nil { return err } target, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.TargetUserID) if err != nil { return err } payloadBytes, err := json.Marshal(roleInvitationCreatedPayload{ InvitationID: invitation.InvitationID, InvitationType: invitation.InvitationType, Status: invitation.Status, InviterUserID: invitation.InviterUserID, InviterBDUserID: invitation.InviterBDUserID, TargetUserID: invitation.TargetUserID, RegionID: invitation.RegionID, AgencyName: invitation.AgencyName, ParentBDUserID: invitation.ParentBDUserID, ParentLeaderUserID: invitation.ParentLeaderUserID, Inviter: inviter, Target: target, CreatedAtMS: invitation.CreatedAtMs, }) if err != nil { return err } // 邀请创建和 user_outbox 同事务提交;RocketMQ 失败时只影响异步确认 IM,业务邀请本身仍可由 outbox worker 补偿投递。 _, err = tx.ExecContext(ctx, ` INSERT INTO user_outbox ( app_code, event_id, event_type, aggregate_type, aggregate_id, status, worker_id, lock_until_ms, retry_count, next_retry_at_ms, last_error, payload_json, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'pending', '', 0, 0, 0, '', CAST(? AS JSON), ?, ?)`, appcode.FromContext(ctx), idgen.New("uout"), userOutboxEventRoleInvitationCreated, hostdomain.ResultTypeInvitation, invitation.InvitationID, string(payloadBytes), nowMs, nowMs, ) return err } func roleInvitationUserSnapshotByID(ctx context.Context, tx *sql.Tx, userID int64) (roleInvitationUserSnapshot, error) { var snapshot roleInvitationUserSnapshot err := tx.QueryRowContext(ctx, ` SELECT user_id, COALESCE(current_display_user_id, ''), COALESCE(username, ''), COALESCE(avatar, '') FROM users WHERE app_code = ? AND user_id = ?`, appcode.FromContext(ctx), userID, ).Scan(&snapshot.UserID, &snapshot.DisplayUserID, &snapshot.Username, &snapshot.Avatar) if err == sql.ErrNoRows { return roleInvitationUserSnapshot{}, xerr.New(xerr.NotFound, "user not found") } return snapshot, err } 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 处理角色邀请,接受路径在同一事务内创建 Host/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 和拥有者成员关系。 hostProfileChanged, membershipCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } // 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录, // 让同一个接受事务中的派生事件保持可追踪顺序。 if hostProfileChanged { 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 membershipCreated { 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 if invitation.InvitationType == hostdomain.InvitationTypeHost { // Host 邀请只创建或复用 Host 身份,并把当前成员关系挂到发起 Agency。 hostProfileCreated, err := r.acceptHostInvitation(ctx, tx, command, &invitation, &result) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } 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, "AgencyMembershipChanged", "agency_membership", command.MembershipID, 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, bool, error) { // 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。 // 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。 targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return false, false, err } inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "") if err != nil { return false, false, err } if targetRegionID != inviterRegionID { return false, false, xerr.New(xerr.RegionMismatch, "target region no longer matches invitation") } // 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。 // 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。 activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true) if err != nil { return false, false, err } // 同一用户不能拥有多个有效 Agency;这里和唯一索引共同处理并发接受。 if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil { return false, false, err } else if ok { return false, false, xerr.New(xerr.Conflict, "target already owns active agency") } // Agency、拥有者 host_profile、拥有者成员关系是同一业务事实的三张表投影, // 必须全部创建成功后邀请才能进入已接受状态。 agencyName, agencyAvatar, err := defaultAgencyProfileFromOwner(ctx, tx, invitation.TargetUserID, invitation.AgencyName) if err != nil { return false, false, err } agency := hostdomain.Agency{ AgencyID: command.AgencyID, OwnerUserID: invitation.TargetUserID, RegionID: targetRegionID, ParentBDUserID: invitation.ParentBDUserID, Name: agencyName, Avatar: agencyAvatar, 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, false, mapHostDuplicateError(err) } if hasActiveMembership { // 已有 active Host 绑定时只恢复/修正 host_profile 指向原 membership,不插入 owner membership, // 防止同一 Host 同时拥有两条 active agency_memberships。 hostProfile, hostProfileChanged, err := ensureHostProfileForExistingMembership(ctx, tx, invitation.TargetUserID, targetRegionID, activeMembership, hostdomain.HostSourceAgencyInvitation, command.NowMs) if err != nil { return false, false, err } result.HostProfile = hostProfile result.Agency = agency result.Membership = activeMembership return hostProfileChanged, false, nil } hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs) if err != nil { return false, false, err } membership := hostdomain.AgencyMembership{ MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: invitation.TargetUserID, RegionID: targetRegionID, 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, false, mapHostDuplicateError(err) } result.HostProfile = hostProfile result.Agency = agency result.Membership = membership return hostProfileCreated, true, nil } func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) { // Host 邀请绑定的是发起人当前 active Agency,而不是客户端传入的 agency_id; // 这样邀请消息无法被目标用户篡改成加入其他团队。 agency, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.InviterUserID, true) if err != nil { return false, err } if !ok || !agency.JoinEnabled { return false, xerr.New(xerr.Conflict, "inviter agency is no longer active or joinable") } targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return false, err } if targetRegionID != agency.RegionID { return false, xerr.New(xerr.RegionMismatch, "target region no longer matches agency") } // 接受时再次检查有效归属和 Agency owner 身份,覆盖邀请发出后用户已经加入或创建团队的情况。 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") } 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") } hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceHostInvitation, command.NowMs) if err != nil { return false, err } membership := hostdomain.AgencyMembership{ MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: invitation.TargetUserID, RegionID: targetRegionID, MembershipType: hostdomain.MembershipTypeMember, 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 邀请同样以目标用户和发起 Leader 的 users 当前区域为准;旧邀请快照不能继续授权跨区接受。 targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return err } leaderRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "") if err != nil { return err } if targetRegionID != leaderRegionID { return xerr.New(xerr.RegionMismatch, "target region no longer matches invitation") } // BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖, // disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。 if existing, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil { return err } else if ok { if existing.Status == hostdomain.BDStatusActive { return xerr.New(xerr.Conflict, "target already has bd profile") } existing.ParentLeaderUserID = invitation.ParentLeaderUserID existing.Status = hostdomain.BDStatusActive existing.UpdatedAtMs = command.NowMs if err := updateBDProfileForActivation(ctx, tx, existing); err != nil { return err } result.BDProfile = existing return nil } bd := hostdomain.BDProfile{ UserID: invitation.TargetUserID, Role: hostdomain.BDRoleBD, RegionID: targetRegionID, 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 }