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

303 lines
14 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"
"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")
}
// 申请创建后用户区域可能被后台调整;通过前重新锁 users 行校验,
// 确保最终成员关系的区域仍然和申请、Agency 三方一致。
regionID, err := r.userRegion(ctx, tx, application.ApplicantUserID, "FOR UPDATE")
if err != nil {
return hostdomain.ReviewAgencyApplicationResult{}, err
}
if regionID != application.RegionID || 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, application.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: application.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 只结束 Agency 成员关系,不删除 Host 身份。
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")
}
// host_profile 保留主播身份,只清理当前归属指针;因此这里先锁住身份行,
// 后续成员关系结束和 current_membership 清空能在同事务内完成。
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
}
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
}
}
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 err := tx.Commit(); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
return hostdomain.KickAgencyHostResult{Membership: membership, HostProfile: hostProfile}, nil
}