2026-05-02 13:02:38 +08:00

385 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
// 邀请人的 BD 身份行同时是授权凭证和邀请归属来源;
// 锁住它可以避免 BD 被停用/换区时仍继续发出旧区域邀请。
inviter, err := queryBDProfile(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 bd is not active")
}
// 目标用户还不是 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 拥有者在本阶段也会被建成 host_profile 和拥有者成员关系,
// 因此已是有效 Host 的用户不能再走“成为 Agency”邀请。
if _, ok, err := queryHostProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "active host cannot become agency")
}
// 一个用户只能拥有一个有效 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 和负责人链路;
// 如果邀请人自己就是负责人,则 parent_leader_user_id 指向自己。
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: inviter.RegionID,
AgencyName: command.AgencyName,
ParentBDUserID: inviter.UserID,
ParentLeaderUserID: parentLeaderUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
// 待处理邀请与 command_result 同事务写入,保证重复提交能按 invitation_id 回放。
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 := 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锁住负责人身份行让停用/降级和邀请创建串行。
inviter, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.InviterUserID)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if inviter.Status != hostdomain.BDStatusActive || inviter.Role != hostdomain.BDRoleLeader {
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 身份事实竞争。
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 active 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: inviter.RegionID,
ParentLeaderUserID: inviter.UserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
// 这里只创建待处理邀请;真正的 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 := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return invitation, nil
}
// 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 和拥有者成员关系。
created, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result)
if err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if created {
// 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录,
// 让同一个接受事务中的派生事件保持可追踪顺序。
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_profileBD 不需要 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")
}
// 目标用户在邀请创建后可能已经通过申请成为 Host接受时需要重新阻断。
if _, ok, err := queryHostProfileMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
return false, err
} else if ok {
return false, xerr.New(xerr.Conflict, "active host cannot become agency")
}
// 同一用户不能拥有多个有效 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,
MaxHosts: 0,
CreatedByUserID: invitation.InviterUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertAgency(ctx, tx, agency); err != nil {
return false, mapHostDuplicateError(err)
}
hostProfile := hostdomain.HostProfile{
UserID: invitation.TargetUserID,
Status: hostdomain.HostStatusActive,
RegionID: invitation.RegionID,
CurrentAgencyID: agency.AgencyID,
CurrentMembershipID: command.MembershipID,
Source: hostdomain.HostSourceAgencyInvitation,
FirstBecameHostAtMs: command.NowMs,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertHostProfile(ctx, tx, hostProfile); err != nil {
return false, mapHostDuplicateError(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 true, 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 := queryBDProfileMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
return err
} else if ok {
return xerr.New(xerr.Conflict, "target already has active 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
}