647 lines
29 KiB
Go
647 lines
29 KiB
Go
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.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.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.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.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.PermissionDenied, "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 和拥有者成员关系。
|
||
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 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, error) {
|
||
// 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。
|
||
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
|
||
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if targetRegionID != inviterRegionID {
|
||
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: targetRegionID,
|
||
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, targetRegionID, 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: 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, mapHostDuplicateError(err)
|
||
}
|
||
result.HostProfile = hostProfile
|
||
result.Agency = agency
|
||
result.Membership = membership
|
||
return hostProfileCreated, 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.PermissionDenied, "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.HostSourceAgencyInvitation, 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.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: 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
|
||
}
|