2026-07-09 18:15:57 +08:00

973 lines
45 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.

// 本包实现 user-service 内 Host/Agency/BD 业务用例。
package host
import (
"context"
"strings"
"time"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
)
// Repository 是 Host 领域的持久化边界。
// 写方法内部必须开启 MySQL 事务并完成行锁、幂等检查、事实写入和事件箱写入。
type Repository interface {
SearchAgencies(ctx context.Context, command SearchAgenciesCommand) ([]hostdomain.Agency, error)
ApplyToAgency(ctx context.Context, command ApplyToAgencyCommand) (hostdomain.AgencyApplication, error)
ReviewAgencyApplication(ctx context.Context, command ReviewAgencyApplicationCommand) (hostdomain.ReviewAgencyApplicationResult, error)
KickAgencyHost(ctx context.Context, command KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error)
InviteAgency(ctx context.Context, command InviteAgencyCommand) (hostdomain.RoleInvitation, error)
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error)
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error)
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
ListManagerTeamAgencies(ctx context.Context, command ListManagerTeamAgenciesCommand) (hostdomain.ManagerTeamAgencies, error)
ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error)
CreateBDLeader(ctx context.Context, command CreateBDLeaderCommand) (hostdomain.BDProfile, error)
CreateBD(ctx context.Context, command CreateBDCommand) (hostdomain.BDProfile, error)
SetBDStatus(ctx context.Context, command SetBDStatusCommand) (hostdomain.BDProfile, error)
CreateCoinSeller(ctx context.Context, command CreateCoinSellerCommand) (hostdomain.CoinSellerProfile, error)
SetCoinSellerStatus(ctx context.Context, command SetCoinSellerStatusCommand) (hostdomain.CoinSellerProfile, error)
CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerCommand) (hostdomain.CreateSubCoinSellerResult, error)
SubmitSubCoinSellerApplication(ctx context.Context, command SubmitSubCoinSellerApplicationCommand) (hostdomain.SubmitSubCoinSellerApplicationResult, error)
ReviewSubCoinSellerApplication(ctx context.Context, command ReviewSubCoinSellerApplicationCommand) (hostdomain.ReviewSubCoinSellerApplicationResult, error)
ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error)
CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, error)
CreateAgency(ctx context.Context, command CreateAgencyCommand) (hostdomain.CreateAgencyResult, error)
CloseAgency(ctx context.Context, command CloseAgencyCommand) (hostdomain.Agency, error)
DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error)
SetAgencyStatus(ctx context.Context, command SetAgencyStatusCommand) (hostdomain.Agency, error)
SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error)
AdminAddAgencyHost(ctx context.Context, command AdminAddAgencyHostCommand) (hostdomain.CreateAgencyResult, error)
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error)
GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error)
GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error)
GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error)
UpdateAgencyProfile(ctx context.Context, command UpdateAgencyProfileCommand) (hostdomain.Agency, error)
HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error)
HasActiveManagerProfile(ctx context.Context, userID int64) (bool, error)
HasActiveManagerCapability(ctx context.Context, userID int64, capability string) (bool, error)
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error)
}
// IDGenerator 生成 Host 领域内部事实 ID。
type IDGenerator interface {
NewInt64() int64
}
// Service 承载 Host/Agency/BD 用例;它不直接访问数据库,事务语义保留在持久化层。
type Service struct {
repository Repository
idGenerator IDGenerator
now func() time.Time
}
// Option 调整 Host 业务层的运行时依赖,测试可以注入固定时钟和确定性发号器。
type Option func(*Service)
// New 创建 Host 领域业务层对象。
func New(repository Repository, options ...Option) *Service {
svc := &Service{
repository: repository,
idGenerator: idgen.NewInt64Generator(0),
now: time.Now,
}
for _, option := range options {
option(svc)
}
return svc
}
// WithIDGenerator 注入事实 ID 发号器。
func WithIDGenerator(generator IDGenerator) Option {
return func(s *Service) {
if generator != nil {
s.idGenerator = generator
}
}
}
// WithClock 注入业务时钟,避免测试依赖真实时间。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// SearchAgencies 返回当前用户同国家可加入的有效 Agency。
func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword string, pageSize int32) ([]hostdomain.Agency, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
limit := int(pageSize)
if limit <= 0 || limit > 100 {
// 搜索接口默认收敛到小结果集,避免第一阶段误用成全表导出。
limit = 50
}
// 业务层只负责输入归一化;用户国家过滤由持久化层读取 users 当前事实完成。
return s.repository.SearchAgencies(ctx, SearchAgenciesCommand{
UserID: userID,
Keyword: strings.TrimSpace(keyword),
PageSize: limit,
})
}
// ApplyToAgency 创建用户加入 Agency 的待处理申请。
func (s *Service) ApplyToAgency(ctx context.Context, command ApplyToAgencyInput) (hostdomain.AgencyApplication, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.AgencyApplication{}, err
}
// command_id 来自调用方,是幂等边界;申请 ID 由服务端生成,避免客户端猜测事实主键。
if strings.TrimSpace(command.CommandID) == "" {
return hostdomain.AgencyApplication{}, xerr.New(xerr.InvalidArgument, "command_id is required")
}
if command.UserID <= 0 || command.AgencyID <= 0 {
return hostdomain.AgencyApplication{}, xerr.New(xerr.InvalidArgument, "user_id and agency_id are required")
}
nowMs := s.now().UnixMilli()
// 业务层不做 Agency、区域、成员关系判定避免在无事务上下文里读到过期事实。
return s.repository.ApplyToAgency(ctx, ApplyToAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
ApplicationID: s.idGenerator.NewInt64(),
UserID: command.UserID,
AgencyID: command.AgencyID,
NowMs: nowMs,
})
}
// ReviewAgencyApplication 审核申请;通过时会在同一事务内创建 Host 身份和有效成员关系。
func (s *Service) ReviewAgencyApplication(ctx context.Context, command ReviewAgencyApplicationInput) (hostdomain.ReviewAgencyApplicationResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.ReviewAgencyApplicationResult{}, err
}
// 对外动作统一做空白裁剪和小写化,领域层只接收规范化后的状态机输入。
decision := normalizeAction(command.Decision)
if strings.TrimSpace(command.CommandID) == "" || command.ReviewerUserID <= 0 || command.ApplicationID <= 0 {
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.InvalidArgument, "command_id, reviewer_user_id and application_id are required")
}
if decision != hostdomain.ApplicationDecisionApprove && decision != hostdomain.ApplicationDecisionReject {
return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.InvalidArgument, "decision is invalid")
}
nowMs := s.now().UnixMilli()
// 成员关系 ID 和事件 ID 对拒绝动作可能不会被使用;提前生成可以保持命令对象完整,
// 真正是否落库由持久化层事务状态机决定。
eventID := s.nextEventIDRange(2)
return s.repository.ReviewAgencyApplication(ctx, ReviewAgencyApplicationCommand{
CommandID: strings.TrimSpace(command.CommandID),
ReviewerUserID: command.ReviewerUserID,
ApplicationID: command.ApplicationID,
Decision: decision,
Reason: strings.TrimSpace(command.Reason),
MembershipID: s.idGenerator.NewInt64(),
EventID: eventID,
NowMs: nowMs,
})
}
// KickAgencyHost 结束普通成员关系,并在目标没有 active Agency owner 身份时停用 Host 身份。
func (s *Service) KickAgencyHost(ctx context.Context, command KickAgencyHostInput) (hostdomain.KickAgencyHostResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
// 业务层只校验必填字段操作者是否为拥有者、Host 是否为普通成员必须在锁内判断。
if strings.TrimSpace(command.CommandID) == "" || command.OperatorUserID <= 0 || command.AgencyID <= 0 || command.HostUserID <= 0 {
return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.InvalidArgument, "command_id, operator_user_id, agency_id and host_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.KickAgencyHost(ctx, KickAgencyHostCommand{
CommandID: strings.TrimSpace(command.CommandID),
OperatorUserID: command.OperatorUserID,
AgencyID: command.AgencyID,
HostUserID: command.HostUserID,
Reason: strings.TrimSpace(command.Reason),
EventID: s.nextEventIDRange(2),
NowMs: nowMs,
})
}
// GetAgency 读取 Agency 详情;调用方负责根据业务入口校验 owner 或管理员身份。
func (s *Service) GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error) {
if s.repository == nil {
return hostdomain.Agency{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if agencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "agency_id is required")
}
return s.repository.GetAgency(ctx, agencyID)
}
// UpdateAgencyProfile 只修改 Agency 独立组织资料,不回写 owner 用户昵称或头像。
func (s *Service) UpdateAgencyProfile(ctx context.Context, command UpdateAgencyProfileInput) (hostdomain.Agency, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.Agency{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.OperatorUserID <= 0 || command.AgencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "command_id, operator_user_id and agency_id are required")
}
if command.Name == nil && command.Avatar == nil {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "name or avatar is required")
}
var name *string
if command.Name != nil {
trimmed := strings.TrimSpace(*command.Name)
if trimmed == "" {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "agency name is required")
}
name = &trimmed
}
var avatar *string
if command.Avatar != nil {
trimmed := strings.TrimSpace(*command.Avatar)
if trimmed == "" {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "agency avatar is required")
}
avatar = &trimmed
}
return s.repository.UpdateAgencyProfile(ctx, UpdateAgencyProfileCommand{
CommandID: strings.TrimSpace(command.CommandID),
OperatorUserID: command.OperatorUserID,
AgencyID: command.AgencyID,
Name: name,
Avatar: avatar,
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.nextEventIDRange(1),
NowMs: s.now().UnixMilli(),
})
}
// InviteAgency 由 BD 或 BD Leader 创建 Agency owner 邀请;目标用户接受前不创建 Agency 身份。
func (s *Service) InviteAgency(ctx context.Context, command InviteAgencyInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// Agency 名称允许邀请方显式覆盖;为空时在目标接受并真正创建 Agency 时复制 owner 当前昵称/短 ID。
agencyName := strings.TrimSpace(command.AgencyName)
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.InviteAgency(ctx, InviteAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
AgencyName: agencyName,
NowMs: nowMs,
})
}
// InviteBD 由 BD Leader 创建 BD 邀请;目标用户接受前不创建普通 BD 身份。
func (s *Service) InviteBD(ctx context.Context, command InviteBDInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// 负责人身份、区域、目标是否已有 BD 都交给持久化层在同一事务中校验,避免发出不可处理的待确认消息。
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.InviteBD(ctx, InviteBDCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
NowMs: nowMs,
})
}
// InviteHost 由 active Agency owner 邀请用户加入自己的 Agency接受前不创建 Host 或成员关系。
func (s *Service) InviteHost(ctx context.Context, command InviteHostInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// command_id 仍是发送动作的幂等边界;目标处理时会使用新的 command_id 推进邀请终态。
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.InviteHost(ctx, InviteHostCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
NowMs: nowMs,
})
}
// ListRoleInvitations 返回当前用户收到的角色/团队邀请收件箱。
func (s *Service) ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.TargetUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
return s.repository.ListRoleInvitations(ctx, ListRoleInvitationsCommand{
TargetUserID: command.TargetUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// GetRoleInvitation 返回单条邀请事实actor 必须是邀请发起人或目标用户,避免内部接口被误用成任意枚举入口。
func (s *Service) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) {
if s.repository == nil {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if actorUserID <= 0 || invitationID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "actor_user_id and invitation_id are required")
}
return s.repository.GetRoleInvitation(ctx, actorUserID, invitationID)
}
// ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.LeaderUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "leader_user_id is required")
}
return s.repository.ListBDLeaderBDs(ctx, ListBDLeaderBDsCommand{
LeaderUserID: command.LeaderUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// ListBDLeaderAgencies 返回当前 BD Leader 团队下的 Agency 列表。
func (s *Service) ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.LeaderUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "leader_user_id is required")
}
return s.repository.ListBDLeaderAgencies(ctx, ListBDLeaderAgenciesCommand{
LeaderUserID: command.LeaderUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// ListBDAgencies 返回当前 BD 直接拓展的 Agency 列表。
func (s *Service) ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.BDUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "bd_user_id is required")
}
return s.repository.ListBDAgencies(ctx, ListBDAgenciesCommand{
BDUserID: command.BDUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// ListManagerTeamAgencies 返回经理团队树内全部有效 Agency经理中心团队工资统计据此圈定主播归属。
func (s *Service) ListManagerTeamAgencies(ctx context.Context, command ListManagerTeamAgenciesCommand) (hostdomain.ManagerTeamAgencies, error) {
if s.repository == nil {
return hostdomain.ManagerTeamAgencies{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ManagerUserID <= 0 {
return hostdomain.ManagerTeamAgencies{}, xerr.New(xerr.InvalidArgument, "manager_user_id is required")
}
// 团队统计要求一次拿全 Agency 集合,防御上限比常规分页列表更高;超限用 Truncated 显式标记而不是静默丢数据。
pageSize := command.PageSize
if pageSize <= 0 {
pageSize = 2000
}
if pageSize > 5000 {
pageSize = 5000
}
return s.repository.ListManagerTeamAgencies(ctx, ListManagerTeamAgenciesCommand{
ManagerUserID: command.ManagerUserID,
PageSize: pageSize,
})
}
// ProcessRoleInvitation 接受、拒绝或取消角色邀请。
func (s *Service) ProcessRoleInvitation(ctx context.Context, command ProcessRoleInvitationInput) (hostdomain.ProcessRoleInvitationResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
// 接受、拒绝、取消共用同一入口;先归一化再进入持久化层状态机。
action := normalizeAction(command.Action)
if strings.TrimSpace(command.CommandID) == "" || command.ActorUserID <= 0 || command.InvitationID <= 0 {
return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.InvalidArgument, "command_id, actor_user_id and invitation_id are required")
}
if action != hostdomain.InvitationActionAccept && action != hostdomain.InvitationActionReject && action != hostdomain.InvitationActionCancel {
return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.InvalidArgument, "action is invalid")
}
nowMs := s.now().UnixMilli()
// Agency ID、成员关系 ID、事件 ID 只在接受 Agency 邀请时使用;
// 拒绝、取消、接受 BD 邀请可能消耗不到这些预分配 ID这是开发阶段可接受的设计。
eventID := s.nextEventIDRange(3)
return s.repository.ProcessRoleInvitation(ctx, ProcessRoleInvitationCommand{
CommandID: strings.TrimSpace(command.CommandID),
ActorUserID: command.ActorUserID,
InvitationID: command.InvitationID,
Action: action,
Reason: strings.TrimSpace(command.Reason),
AgencyID: s.idGenerator.NewInt64(),
MembershipID: s.idGenerator.NewInt64(),
EventID: eventID,
NowMs: nowMs,
})
}
// CreateBDLeader 创建 BD Leader/Admin 身份。
// 后台 admin 的权限在 admin-server 校验App 经理中心的权限由 CheckBusinessCapability 基于 manager_profiles 校验。
// 本方法只负责统一写入 BD Leader 事实,保证幂等、区域和身份冲突规则不会因入口不同而分叉。
func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInput) (hostdomain.BDProfile, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.BDProfile{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.CreateBDLeader(ctx, CreateBDLeaderCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// CreateBD 由后台创建普通 BD传入父级 Leader 时绑定到该 Leader未传时创建独立 BD。
func (s *Service) CreateBD(ctx context.Context, command CreateBDInput) (hostdomain.BDProfile, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.BDProfile{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required")
}
if command.ParentLeaderUserID < 0 {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "parent_leader_user_id must not be negative")
}
nowMs := s.now().UnixMilli()
return s.repository.CreateBD(ctx, CreateBDCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
ParentLeaderUserID: command.ParentLeaderUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// SetBDStatus 调整普通 BD 或 BD Leader 状态;调用方必须传 role避免同一用户两种身份时误改。
func (s *Service) SetBDStatus(ctx context.Context, command SetBDStatusInput) (hostdomain.BDProfile, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.BDProfile{}, err
}
status := normalizeStatus(command.Status)
role := normalizeBDRole(command.Role)
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 || role == "" {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, target_user_id and role are required")
}
if status != hostdomain.BDStatusActive && status != hostdomain.BDStatusDisabled {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "bd status is invalid")
}
nowMs := s.now().UnixMilli()
return s.repository.SetBDStatus(ctx, SetBDStatusCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
Role: role,
Status: status,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// CreateCoinSeller 由后台创建币商身份;该身份可以和 Agency/BD/BD Leader 并存。
func (s *Service) CreateCoinSeller(ctx context.Context, command CreateCoinSellerInput) (hostdomain.CoinSellerProfile, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.CoinSellerProfile{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.CoinSellerProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.CreateCoinSeller(ctx, CreateCoinSellerCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
CanManageSubCoinSellers: command.CanManageSubCoinSellers,
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// SetCoinSellerStatus 调整币商身份状态;停用后 gateway 不能再放行币商转账。
func (s *Service) SetCoinSellerStatus(ctx context.Context, command SetCoinSellerStatusInput) (hostdomain.CoinSellerProfile, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.CoinSellerProfile{}, err
}
status := normalizeStatus(command.Status)
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.CoinSellerProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required")
}
if status != hostdomain.CoinSellerStatusActive && status != hostdomain.CoinSellerStatusDisabled {
return hostdomain.CoinSellerProfile{}, xerr.New(xerr.InvalidArgument, "coin_seller status is invalid")
}
nowMs := s.now().UnixMilli()
return s.repository.SetCoinSellerStatus(ctx, SetCoinSellerStatusCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
TargetUserID: command.TargetUserID,
Status: status,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
CanManageSubCoinSellers: command.CanManageSubCoinSellers,
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// CreateSubCoinSeller 是历史直开 RPC。审批制上线后保留 proto 兼容,但服务端拒绝新直开请求,避免旧调用方绕过后台审核。
func (s *Service) CreateSubCoinSeller(ctx context.Context, command CreateSubCoinSellerInput) (hostdomain.CreateSubCoinSellerResult, error) {
if strings.TrimSpace(command.CommandID) == "" || command.ParentUserID <= 0 || command.ChildUserID <= 0 {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and child_user_id are required")
}
if command.ParentUserID == command.ChildUserID {
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
}
return hostdomain.CreateSubCoinSellerResult{}, xerr.New(xerr.PermissionDenied, "sub coin seller invitation requires admin review")
}
// SubmitSubCoinSellerApplication 只创建待审申请active 直属关系必须等待后台审核通过后再落库。
func (s *Service) SubmitSubCoinSellerApplication(ctx context.Context, command SubmitSubCoinSellerApplicationInput) (hostdomain.SubmitSubCoinSellerApplicationResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.SubmitSubCoinSellerApplicationResult{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.ParentUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "command_id, parent_user_id and target_user_id are required")
}
if command.ParentUserID == command.TargetUserID {
return hostdomain.SubmitSubCoinSellerApplicationResult{}, xerr.New(xerr.Conflict, "coin seller cannot add self as sub seller")
}
nowMs := s.now().UnixMilli()
return s.repository.SubmitSubCoinSellerApplication(ctx, SubmitSubCoinSellerApplicationCommand{
CommandID: strings.TrimSpace(command.CommandID),
ApplicationID: s.idGenerator.NewInt64(),
ParentUserID: command.ParentUserID,
TargetUserID: command.TargetUserID,
RequestID: strings.TrimSpace(command.RequestID),
NowMs: nowMs,
})
}
// ReviewSubCoinSellerApplication 是后台审批边界;通过时才创建子币商身份与直属关系。
func (s *Service) ReviewSubCoinSellerApplication(ctx context.Context, command ReviewSubCoinSellerApplicationInput) (hostdomain.ReviewSubCoinSellerApplicationResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.ReviewSubCoinSellerApplicationResult{}, err
}
decision := strings.TrimSpace(command.Decision)
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.ApplicationID <= 0 || decision == "" {
return hostdomain.ReviewSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, application_id and decision are required")
}
if decision != hostdomain.CoinSellerSubApplicationStatusApproved && decision != hostdomain.CoinSellerSubApplicationStatusRejected {
return hostdomain.ReviewSubCoinSellerApplicationResult{}, xerr.New(xerr.InvalidArgument, "decision must be approved or rejected")
}
nowMs := s.now().UnixMilli()
return s.repository.ReviewSubCoinSellerApplication(ctx, ReviewSubCoinSellerApplicationCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
ApplicationID: command.ApplicationID,
Decision: decision,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
RelationID: s.idGenerator.NewInt64(),
EventID: s.nextEventIDRange(2),
NowMs: nowMs,
})
}
// ListSubCoinSellers 返回父币商直属子币商列表;权限和 active 身份在存储层按当前事实校验。
func (s *Service) ListSubCoinSellers(ctx context.Context, command ListSubCoinSellersCommand) (hostdomain.ListSubCoinSellersResult, error) {
if s.repository == nil {
return hostdomain.ListSubCoinSellersResult{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ParentUserID <= 0 {
return hostdomain.ListSubCoinSellersResult{}, xerr.New(xerr.InvalidArgument, "parent_user_id is required")
}
page := command.Page
if page <= 0 {
page = 1
}
pageSize := command.PageSize
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
return s.repository.ListSubCoinSellers(ctx, ListSubCoinSellersCommand{
ParentUserID: command.ParentUserID,
Page: page,
PageSize: pageSize,
})
}
// CheckCoinSellerSubRelation 是转账前的服务端授权边界,避免 H5 隐藏按钮后仍能绕过前端直调。
func (s *Service) CheckCoinSellerSubRelation(ctx context.Context, command CheckCoinSellerSubRelationCommand) (hostdomain.CoinSellerListItem, error) {
if s.repository == nil {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.ParentUserID <= 0 || command.ChildUserID <= 0 {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.InvalidArgument, "parent_user_id and child_user_id are required")
}
if command.ParentUserID == command.ChildUserID {
return hostdomain.CoinSellerListItem{}, xerr.New(xerr.Conflict, "coin seller sub relation is invalid")
}
return s.repository.CheckCoinSellerSubRelation(ctx, command)
}
// CreateAgency 由后台直接创建 Agency父级 BD 可选,并原子创建或复用 owner 的 host_profile 和 owner membership。
func (s *Service) CreateAgency(ctx context.Context, command CreateAgencyInput) (hostdomain.CreateAgencyResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
name := strings.TrimSpace(command.Name)
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.OwnerUserID <= 0 {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and owner_user_id are required")
}
if command.ParentBDUserID < 0 {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "parent_bd_user_id must not be negative")
}
nowMs := s.now().UnixMilli()
eventID := s.nextEventIDRange(3)
return s.repository.CreateAgency(ctx, CreateAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
OwnerUserID: command.OwnerUserID,
ParentBDUserID: command.ParentBDUserID,
Name: name,
JoinEnabled: command.JoinEnabled,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
AgencyID: s.idGenerator.NewInt64(),
MembershipID: s.idGenerator.NewInt64(),
EventID: eventID,
NowMs: nowMs,
})
}
// CloseAgency 关闭 Agency当前阶段只关闭组织和入会开关不追溯改历史成员关系。
func (s *Service) CloseAgency(ctx context.Context, command CloseAgencyInput) (hostdomain.Agency, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.Agency{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.AgencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and agency_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.CloseAgency(ctx, CloseAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
AgencyID: command.AgencyID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// DeleteAgency 删除 Agency 身份;它只解除当前 Agency 关系,不删除主播身份和历史记录。
func (s *Service) DeleteAgency(ctx context.Context, command DeleteAgencyInput) (hostdomain.Agency, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.Agency{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.AgencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and agency_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.DeleteAgency(ctx, DeleteAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
AgencyID: command.AgencyID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// SetAgencyStatus 显式切换 Agency 可用状态closed 可恢复为 active但 deleted 是删除事实,不能复活。
func (s *Service) SetAgencyStatus(ctx context.Context, command SetAgencyStatusInput) (hostdomain.Agency, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.Agency{}, err
}
status := normalizeStatus(command.Status)
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.AgencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and agency_id are required")
}
if status != hostdomain.AgencyStatusActive && status != hostdomain.AgencyStatusClosed {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "agency status is invalid")
}
nowMs := s.now().UnixMilli()
return s.repository.SetAgencyStatus(ctx, SetAgencyStatusCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
AgencyID: command.AgencyID,
Status: status,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// SetAgencyJoinEnabled 设置 Agency 是否允许 App 用户申请加入。
func (s *Service) SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledInput) (hostdomain.Agency, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.Agency{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.AgencyID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and agency_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.SetAgencyJoinEnabled(ctx, SetAgencyJoinEnabledCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
AgencyID: command.AgencyID,
JoinEnabled: command.JoinEnabled,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// AdminAddAgencyHost 由后台直接把目标用户添加为 Agency 普通 Host。
func (s *Service) AdminAddAgencyHost(ctx context.Context, command AdminAddAgencyHostInput) (hostdomain.CreateAgencyResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.AgencyID <= 0 || command.TargetUserID <= 0 {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, agency_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
// 一个直接添加动作最多创建/恢复 Host 身份并创建 membership 两类事实,因此一次占用两个连续 outbox ID。
return s.repository.AdminAddAgencyHost(ctx, AdminAddAgencyHostCommand{
CommandID: strings.TrimSpace(command.CommandID),
AdminUserID: command.AdminUserID,
AgencyID: command.AgencyID,
TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
MembershipID: s.idGenerator.NewInt64(),
EventID: s.nextEventIDRange(2),
NowMs: nowMs,
})
}
// GetHostProfile 读取主播身份事实。
func (s *Service) GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error) {
if s.repository == nil {
return hostdomain.HostProfile{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return hostdomain.HostProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return s.repository.GetHostProfile(ctx, userID)
}
// BatchGetHostProfiles 批量读取主播身份事实,缺失用户不返回占位对象。
func (s *Service) BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error) {
if len(userIDs) == 0 {
// 空批量请求是合法的无操作,调用方可以直接按空 map 组装结果。
return map[int64]hostdomain.HostProfile{}, nil
}
uniqueUserIDs := make([]int64, 0, len(userIDs))
seen := make(map[int64]bool, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
// 任一非法 ID 都表示调用方组装的批量请求不可信,不能静默跳过。
return nil, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
}
if seen[userID] {
continue
}
seen[userID] = true
uniqueUserIDs = append(uniqueUserIDs, userID)
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
return s.repository.BatchGetHostProfiles(ctx, uniqueUserIDs)
}
// GetBDProfile 按指定 role 读取普通 BD 或 BD Leader 身份事实。
func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
if s.repository == nil {
return hostdomain.BDProfile{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
role = normalizeBDRole(role)
if userID <= 0 || role == "" {
return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "user_id and role are required")
}
return s.repository.GetBDProfile(ctx, userID, role)
}
func normalizeBDRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case hostdomain.BDRoleBD:
return hostdomain.BDRoleBD
case hostdomain.BDRoleLeader:
return hostdomain.BDRoleLeader
default:
return ""
}
}
// GetCoinSellerProfile 读取币商身份事实,供 gateway 在发起币商转账前校验。
func (s *Service) GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error) {
if s.repository == nil {
return hostdomain.CoinSellerProfile{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return hostdomain.CoinSellerProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return s.repository.GetCoinSellerProfile(ctx, userID)
}
// ListActiveCoinSellersInMyRegion 返回当前用户所在区域的所有启用币商。
func (s *Service) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
// 当前区域属于 user-service 用户主事实gateway 不能传入 region_id 来扩大查询范围。
return s.repository.ListActiveCoinSellersInMyRegion(ctx, userID)
}
// GetUserRoleSummary 读取 App 我的页使用的轻量身份摘要,避免 gateway 为入口显隐发起多个角色 RPC。
func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
if s.repository == nil {
return hostdomain.UserRoleSummary{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return hostdomain.UserRoleSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return s.repository.GetUserRoleSummary(ctx, userID)
}
// ListAgencyMembers 读取 Agency 成员关系列表,第一阶段直接读事实表。
func (s *Service) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if agencyID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "agency_id is required")
}
return s.repository.ListAgencyMembers(ctx, agencyID, normalizeStatus(status))
}
// ListAgencyApplications 读取 Agency 申请列表,第一阶段直接读事实表。
func (s *Service) ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if agencyID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "agency_id is required")
}
return s.repository.ListAgencyApplications(ctx, agencyID, normalizeStatus(status))
}
func (s *Service) requireWriteDependencies() error {
// 写路径必须具备持久化层、ID 发号器和时钟;缺任何一个都会破坏事务事实或事件时间。
if s.repository == nil {
return xerr.New(xerr.Unavailable, "host repository is not configured")
}
if s.idGenerator == nil {
return xerr.New(xerr.Unavailable, "host id generator is not configured")
}
if s.now == nil {
return xerr.New(xerr.Unavailable, "host clock is not configured")
}
return nil
}
func (s *Service) nextEventIDRange(count int) int64 {
// 部分 host 事务会在同一命令里写多条 outbox这里一次性占用连续事件 ID避免下一条命令拿到 eventID+1 后和本事务内部事件冲突。
first := s.idGenerator.NewInt64()
for i := 1; i < count; i++ {
s.idGenerator.NewInt64()
}
return first
}
func normalizeAction(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizePageSize(value int) int {
if value <= 0 {
return 20
}
if value > 100 {
return 100
}
return value
}