2026-06-10 11:14:49 +08:00

433 lines
20 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)
}
// 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_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")
}
// 目标用户在邀请创建后可能已经加入其他 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
}