329 lines
15 KiB
Go
329 lines
15 KiB
Go
package host
|
||
|
||
import (
|
||
"context"
|
||
|
||
"hyapp/pkg/xerr"
|
||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||
hostservice "hyapp/services/user-service/internal/service/host"
|
||
)
|
||
|
||
// ApplyToAgency 创建待处理申请;用户行和 Agency 行在同一事务内锁定。
|
||
func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.ApplyToAgencyCommand) (hostdomain.AgencyApplication, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// command_id 是客户端写请求的幂等键;只要上一轮事务已经提交,
|
||
// 重试就按持久化的结果 ID 回放,避免重复创建申请或消耗新 ID。
|
||
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
} else if ok {
|
||
if err := requireCommandResult(existing, hostdomain.CommandTypeApplyToAgency, hostdomain.ResultTypeApplication); err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
}
|
||
return queryAgencyApplication(ctx, tx, "WHERE application_id = ?", existing.ResultID)
|
||
}
|
||
|
||
// 申请人此时可能还没有 host_profiles 行,因此以 users 行作为“按用户串行化”的主锁。
|
||
// 区域也从用户表读取,保证申请创建时的区域是 user-service 的当前事实。
|
||
userRegionID, err := r.userRegion(ctx, tx, command.UserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
}
|
||
if userRegionID <= 0 {
|
||
return hostdomain.AgencyApplication{}, xerr.New(xerr.InvalidArgument, "user region is required")
|
||
}
|
||
|
||
// Agency 行必须锁住:加入开关、状态、拥有者当前区域是申请可创建性的条件,
|
||
// 如果后台同时禁用加入或用户迁移区域,这里必须和申请创建串行。
|
||
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID)
|
||
if err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
}
|
||
if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled {
|
||
return hostdomain.AgencyApplication{}, xerr.New(xerr.Conflict, "agency is not joinable")
|
||
}
|
||
if agency.RegionID != userRegionID {
|
||
return hostdomain.AgencyApplication{}, xerr.New(xerr.PermissionDenied, "agency region does not match user region")
|
||
}
|
||
// 第一阶段规则是一个 Host 同时只能归属一个有效 Agency;
|
||
// 这里在同事务内查询并加锁,防止并发申请绕过唯一归属约束。
|
||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.UserID, true); err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
} else if ok {
|
||
return hostdomain.AgencyApplication{}, xerr.New(xerr.Conflict, "user already has active agency membership")
|
||
}
|
||
|
||
application := hostdomain.AgencyApplication{
|
||
ApplicationID: command.ApplicationID,
|
||
CommandID: command.CommandID,
|
||
ApplicantUserID: command.UserID,
|
||
AgencyID: command.AgencyID,
|
||
RegionID: userRegionID,
|
||
Status: hostdomain.ApplicationStatusPending,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
// 申请记录和 command_result 同事务提交:如果申请记录成功但幂等记录失败,
|
||
// 客户端重试会失去可回放结果,因此两者必须作为一个原子写入单元。
|
||
if err := insertAgencyApplication(ctx, tx, application); err != nil {
|
||
return hostdomain.AgencyApplication{}, mapHostDuplicateError(err)
|
||
}
|
||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
|
||
CommandID: command.CommandID,
|
||
CommandType: hostdomain.CommandTypeApplyToAgency,
|
||
ResultType: hostdomain.ResultTypeApplication,
|
||
ResultID: application.ApplicationID,
|
||
CreatedAtMs: command.NowMs,
|
||
}); err != nil {
|
||
return hostdomain.AgencyApplication{}, mapHostDuplicateError(err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.AgencyApplication{}, err
|
||
}
|
||
|
||
return application, nil
|
||
}
|
||
|
||
// ReviewAgencyApplication 审核申请;通过路径同时固化 Host 身份和成员关系。
|
||
func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostservice.ReviewAgencyApplicationCommand) (hostdomain.ReviewAgencyApplicationResult, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 审核动作可能由 App 重试触发;命中 command_result 时按申请 ID 重建完整返回值,
|
||
// 不再重复执行通过/拒绝状态机。
|
||
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
} else if ok {
|
||
if err := requireCommandResult(existing, hostdomain.CommandTypeReviewAgencyApplication, hostdomain.ResultTypeApplication); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
return reviewResultByApplicationID(ctx, tx, existing.ResultID)
|
||
}
|
||
|
||
// 申请行是审核状态机的核心锁;同一个待处理申请只能被一个事务推进到终态。
|
||
application, err := queryAgencyApplication(ctx, tx, "WHERE application_id = ? FOR UPDATE", command.ApplicationID)
|
||
if err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
if application.Status != hostdomain.ApplicationStatusPending {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "application is not pending")
|
||
}
|
||
|
||
// 当前阶段的授权边界是 Agency 拥有者;后续如果引入管理员代审,
|
||
// 应该在这里扩展授权规则,而不是绕过持久化层的事务检查。
|
||
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", application.AgencyID)
|
||
if err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
if agency.OwnerUserID != command.ReviewerUserID {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "reviewer is not agency owner")
|
||
}
|
||
if agency.Status != hostdomain.AgencyStatusActive {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "agency is not active")
|
||
}
|
||
|
||
// 申请创建后申请人或 Agency 拥有者区域都可能被后台调整;通过前重新锁申请人 users 行,
|
||
// 并用 Agency 当前拥有者区域校验,申请表不再保存区域快照。
|
||
regionID, err := r.userRegion(ctx, tx, application.ApplicantUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
if regionID != agency.RegionID {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "application region no longer matches")
|
||
}
|
||
|
||
application.ReviewedByUserID = command.ReviewerUserID
|
||
application.ReviewReason = command.Reason
|
||
application.ReviewedAtMs = command.NowMs
|
||
application.UpdatedAtMs = command.NowMs
|
||
|
||
result := hostdomain.ReviewAgencyApplicationResult{Application: application}
|
||
if command.Decision == hostdomain.ApplicationDecisionReject {
|
||
// 拒绝只推进申请终态,不创建 host_profile,不改成员关系,
|
||
// 这样被拒用户之后仍可以重新申请或被邀请。
|
||
application.Status = hostdomain.ApplicationStatusRejected
|
||
if err := updateAgencyApplicationReview(ctx, tx, application); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
result.Application = application
|
||
} else {
|
||
// 通过前再次检查有效成员关系,覆盖“申请后已通过其他路径入会”的并发场景。
|
||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, application.ApplicantUserID, true); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
} else if ok {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.Conflict, "user already has active agency membership")
|
||
}
|
||
// host_profile 是用户成为主播的长期身份事实;成员关系是当前 Agency 归属事实。
|
||
// 两者和申请审核结果必须原子提交,否则列表页和身份页会看到不一致状态。
|
||
hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, application.ApplicantUserID, regionID, command.MembershipID, application.AgencyID, hostdomain.HostSourceApplication, command.NowMs)
|
||
if err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
membership := hostdomain.AgencyMembership{
|
||
MembershipID: command.MembershipID,
|
||
AgencyID: application.AgencyID,
|
||
HostUserID: application.ApplicantUserID,
|
||
RegionID: regionID,
|
||
MembershipType: hostdomain.MembershipTypeMember,
|
||
Status: hostdomain.MembershipStatusActive,
|
||
JoinedAtMs: command.NowMs,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, mapHostDuplicateError(err)
|
||
}
|
||
application.Status = hostdomain.ApplicationStatusApproved
|
||
if err := updateAgencyApplicationReview(ctx, tx, application); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
// 事件箱是派生读模型/异步集成的可靠通知,不参与业务事实判定;
|
||
// 但必须和事实同事务写入,避免事实提交后事件丢失。
|
||
if created {
|
||
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", application.ApplicantUserID, command.NowMs); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
}
|
||
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
result = hostdomain.ReviewAgencyApplicationResult{
|
||
Application: application,
|
||
HostProfile: hostProfile,
|
||
Membership: membership,
|
||
}
|
||
}
|
||
// command_result 放在状态机成功之后写入;如果前面的业务写失败,
|
||
// 不会留下“已处理”的幂等记录误导下一次重试。
|
||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
|
||
CommandID: command.CommandID,
|
||
CommandType: hostdomain.CommandTypeReviewAgencyApplication,
|
||
ResultType: hostdomain.ResultTypeApplication,
|
||
ResultID: application.ApplicationID,
|
||
CreatedAtMs: command.NowMs,
|
||
}); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, mapHostDuplicateError(err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.ReviewAgencyApplicationResult{}, err
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// KickAgencyHost 结束普通成员关系;普通成员会同步停用 Host 身份,Agency owner 例外。
|
||
func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 踢人也是写命令:同一个 command_id 重试时返回上次结束的成员关系,
|
||
// 不会重复写 ended_at 或再次投递成员关系变更事件。
|
||
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
} else if ok {
|
||
if err := requireCommandResult(existing, hostdomain.CommandTypeKickAgencyHost, hostdomain.ResultTypeMembership); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
return kickResultByMembershipID(ctx, tx, existing.ResultID)
|
||
}
|
||
|
||
// 第一阶段只允许 Agency 拥有者管理成员;Agency 行加锁后再校验拥有者,
|
||
// 避免所有权变更和成员管理在同一时刻交叉。
|
||
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID)
|
||
if err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
if agency.OwnerUserID != command.OperatorUserID {
|
||
return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.PermissionDenied, "operator is not agency owner")
|
||
}
|
||
if agency.OwnerUserID == command.HostUserID {
|
||
return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "agency owner cannot be kicked")
|
||
}
|
||
// 如果目标用户同时是其他 active Agency owner,协议要求 owner 自动拥有 Host 身份;
|
||
// 这种身份不能因为另一家 Agency 移除普通成员关系而被误停用。
|
||
_, ownsActiveAgency, err := queryActiveAgencyByOwner(ctx, tx, command.HostUserID, true)
|
||
if err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
// host_profile 是 Host 身份事实;踢出时必须和 membership 同锁同事务更新,
|
||
// 否则我的页、Host Center 和送礼结算会在短时间内看到互相矛盾的身份。
|
||
hostProfile, err := queryHostProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.HostUserID)
|
||
if err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
// 成员关系行是被结束的事实本体;只处理有效普通成员,
|
||
// 拥有者成员关系不能通过踢人路径结束。
|
||
membership, err := queryAgencyMembership(ctx, tx, "WHERE agency_id = ? AND host_user_id = ? AND status = ? FOR UPDATE", command.AgencyID, command.HostUserID, hostdomain.MembershipStatusActive)
|
||
if err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
if membership.MembershipType == hostdomain.MembershipTypeOwner {
|
||
return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "owner membership cannot be kicked")
|
||
}
|
||
|
||
membership.Status = hostdomain.MembershipStatusEnded
|
||
membership.EndedAtMs = command.NowMs
|
||
membership.EndedByUserID = command.OperatorUserID
|
||
membership.EndedReason = command.Reason
|
||
membership.UpdatedAtMs = command.NowMs
|
||
if err := endAgencyMembership(ctx, tx, membership); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
hostProfileStatusChanged := false
|
||
if ownsActiveAgency {
|
||
// Agency owner 本身仍是 Host;这里只摘掉被结束的普通成员归属,
|
||
// 不把 owner 的 Host 入口关掉,避免破坏“owner 自动拥有 Host 身份”的协议边界。
|
||
if hostProfile.CurrentMembershipID == membership.MembershipID {
|
||
hostProfile.CurrentAgencyID = 0
|
||
hostProfile.CurrentMembershipID = 0
|
||
hostProfile.UpdatedAtMs = command.NowMs
|
||
if err := updateHostCurrentMembership(ctx, tx, hostProfile); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
}
|
||
} else {
|
||
// 普通成员被 Agency 移除后不再拥有 Host 身份;保留历史行但状态置 disabled,
|
||
// 后续重新申请或被邀请时会复用该行并重新激活。
|
||
hostProfileStatusChanged = hostProfile.Status != hostdomain.HostStatusDisabled
|
||
hostProfile.Status = hostdomain.HostStatusDisabled
|
||
hostProfile.CurrentAgencyID = 0
|
||
hostProfile.CurrentMembershipID = 0
|
||
hostProfile.UpdatedAtMs = command.NowMs
|
||
if err := disableHostProfileAfterAgencyKick(ctx, tx, hostProfile); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
}
|
||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
|
||
CommandID: command.CommandID,
|
||
CommandType: hostdomain.CommandTypeKickAgencyHost,
|
||
ResultType: hostdomain.ResultTypeMembership,
|
||
ResultID: membership.MembershipID,
|
||
CreatedAtMs: command.NowMs,
|
||
}); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, mapHostDuplicateError(err)
|
||
}
|
||
if err := insertHostOutbox(ctx, tx, command.EventID, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
if hostProfileStatusChanged {
|
||
if err := insertHostOutbox(ctx, tx, command.EventID+1, "HostStatusChanged", "host_profile", hostProfile.UserID, command.NowMs); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.KickAgencyHostResult{}, err
|
||
}
|
||
|
||
return hostdomain.KickAgencyHostResult{Membership: membership, HostProfile: hostProfile}, nil
|
||
}
|