package user import ( "context" "strings" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" ) // ApplyPrettyDisplayUserID 申请临时靓号并让它在租期内覆盖当前展示号。 func (s *Service) ApplyPrettyDisplayUserID(ctx context.Context, userID int64, prettyDisplayUserID string, leaseDurationMs int64, paymentReceiptID string, requestID string) (userdomain.Identity, string, error) { if userID <= 0 { // 靓号租约必须绑定明确用户。 return userdomain.Identity{}, "", xerr.New(xerr.InvalidArgument, "user_id is required") } if !userdomain.ValidDisplayUserID(prettyDisplayUserID) { // 靓号也复用 display_user_id 格式规则。 return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDInvalid, "display_user_id format is invalid") } if leaseDurationMs <= 0 { // 没有正租期无法计算过期恢复时间。 return userdomain.Identity{}, "", xerr.New(xerr.InvalidArgument, "lease_duration_ms is required") } if paymentReceiptID == "" { // v1 要求先完成支付再申请靓号,避免 user-service 自己处理账务。 return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPaymentRequired, "payment receipt is required") } if s.identityRepository == nil { // 靓号涉及多表事务,必须由 identity repository 完成。 return userdomain.Identity{}, "", xerr.New(xerr.Unavailable, "identity repository is not configured") } // LeaseID 在服务层生成,repository 只负责原子保存和冲突判断。 nowMs := s.now().UnixMilli() return s.identityRepository.ApplyPrettyDisplayUserID(ctx, userdomain.PrettyDisplayUserIDCommand{ AppCode: appcode.FromContext(ctx), LeaseID: idgen.New("pdu_lease"), UserID: userID, PrettyDisplayID: prettyDisplayUserID, LeaseDurationMs: leaseDurationMs, PaymentReceiptID: paymentReceiptID, Source: "payment", RequestID: requestID, OperatorUserID: userID, NowMs: nowMs, }) } // ExpirePrettyDisplayUserID 主动触发靓号过期恢复;定时任务和懒过期应复用同一事务。 func (s *Service) ExpirePrettyDisplayUserID(ctx context.Context, userID int64, leaseID string, requestID string) (userdomain.Identity, error) { if userID <= 0 { // 过期恢复需要明确用户,lease_id 只是可选校验条件。 return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.identityRepository == nil { // 没有 identity repository 无法恢复默认短号。 return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } return s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, LeaseID: leaseID, RequestID: requestID, NowMs: s.now().UnixMilli(), }) } func (s *Service) refreshExpiredUser(ctx context.Context, user userdomain.User, requestID string) (userdomain.User, error) { nowMs := s.now().UnixMilli() if !user.DisplayUserIDExpired(nowMs) { // 当前展示号仍有效时直接返回用户快照。 return user, nil } if s.identityRepository == nil { // 发现过期但无法恢复时不能继续返回过期靓号。 return userdomain.User{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 过期恢复必须走 identity repository 事务,同步更新 users 和短号记录。 if _, err := s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{ AppCode: appcode.FromContext(ctx), UserID: user.UserID, RequestID: requestID, NowMs: nowMs, }); err != nil { return userdomain.User{}, err } if s.userRepository == nil { // 恢复后需要重新读取 users 快照,确保返回的展示号来自持久化结果。 return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } return s.userRepository.GetUser(ctx, user.UserID) } // ListAvailablePrettyDisplayIDs 返回当前用户等级允许申请的靓号池号码。 func (s *Service) ListAvailablePrettyDisplayIDs(ctx context.Context, userID int64, page int32, pageSize int32, requestID string) ([]userdomain.PrettyDisplayID, int64, error) { if userID <= 0 { // 可申请列表必须绑定当前登录用户,避免客户端自报等级或轨道。 return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.identityRepository == nil { // 靓号池和短号唯一性由 identity repository 统一持久化。 return nil, 0, xerr.New(xerr.Unavailable, "identity repository is not configured") } if s.levelProfileReader == nil { // 没有等级投影时不能返回可申请列表,否则会绕过等级区间限制。 return nil, 0, xerr.New(xerr.Unavailable, "level profile reader is not configured") } levels, err := s.levelProfileReader.GetUserLevelProfile(ctx, appcode.FromContext(ctx), userID, requestID) if err != nil { return nil, 0, err } return s.identityRepository.ListAvailablePrettyDisplayIDs(ctx, userdomain.AvailablePrettyDisplayIDQuery{ AppCode: appcode.FromContext(ctx), UserID: userID, Page: normalizePage(page), PageSize: normalizePageSize(pageSize), Levels: levels, }) } // ApplyPrettyDisplayIDFromPool 申请靓号池号码,成功后长期覆盖当前展示号。 func (s *Service) ApplyPrettyDisplayIDFromPool(ctx context.Context, userID int64, prettyID string, requestID string) (userdomain.Identity, string, error) { prettyID = strings.TrimSpace(prettyID) if userID <= 0 || prettyID == "" { // 靓号池申请只接受 pretty_id,不接受客户端自报号码、等级或租期。 return userdomain.Identity{}, "", xerr.New(xerr.InvalidArgument, "pretty_id is required") } if s.identityRepository == nil { return userdomain.Identity{}, "", xerr.New(xerr.Unavailable, "identity repository is not configured") } if s.levelProfileReader == nil { return userdomain.Identity{}, "", xerr.New(xerr.Unavailable, "level profile reader is not configured") } levels, err := s.levelProfileReader.GetUserLevelProfile(ctx, appcode.FromContext(ctx), userID, requestID) if err != nil { return userdomain.Identity{}, "", err } leaseID := idgen.New("pdu_lease") identity, err := s.identityRepository.ApplyPrettyDisplayIDFromPool(ctx, userdomain.PrettyDisplayIDPoolApplyCommand{ AppCode: appcode.FromContext(ctx), UserID: userID, PrettyID: prettyID, LeaseID: leaseID, Levels: levels, RequestID: requestID, NowMs: s.now().UnixMilli(), }) if err != nil { return userdomain.Identity{}, "", err } return identity, leaseID, nil } // ListPrettyDisplayIDPools 返回后台靓号池分页列表。 func (s *Service) ListPrettyDisplayIDPools(ctx context.Context, status string, levelTrack string, page int32, pageSize int32) ([]userdomain.PrettyDisplayIDPool, int64, error) { if s.identityRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 后台列表只做参数归一化,不在服务层补默认筛选;空 status/level_track 表示查询全部。 return s.identityRepository.ListPrettyDisplayIDPools(ctx, userdomain.PrettyDisplayIDPoolQuery{ AppCode: appcode.FromContext(ctx), Status: strings.TrimSpace(status), LevelTrack: strings.TrimSpace(levelTrack), Page: normalizePage(page), PageSize: normalizePageSize(pageSize), }) } // CreatePrettyDisplayIDPool 创建后台靓号池配置。 func (s *Service) CreatePrettyDisplayIDPool(ctx context.Context, command userdomain.PrettyDisplayIDPoolCommand) (userdomain.PrettyDisplayIDPool, error) { if s.identityRepository == nil { return userdomain.PrettyDisplayIDPool{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 池 ID、应用和时间由服务端生成,后台只提交业务配置和操作者,避免跨应用或伪造创建时间。 command.AppCode = appcode.FromContext(ctx) command.PoolID = idgen.New("pretty_pool") command.NowMs = s.now().UnixMilli() normalizePrettyDisplayIDPoolCommand(&command) // 创建和更新共用同一个校验函数,保证等级区间、轨道、状态和规则类型行为一致。 if err := validatePrettyDisplayIDPoolCommand(command); err != nil { return userdomain.PrettyDisplayIDPool{}, err } return s.identityRepository.CreatePrettyDisplayIDPool(ctx, command) } // UpdatePrettyDisplayIDPool 更新后台靓号池配置。 func (s *Service) UpdatePrettyDisplayIDPool(ctx context.Context, command userdomain.PrettyDisplayIDPoolCommand) (userdomain.PrettyDisplayIDPool, error) { if s.identityRepository == nil { return userdomain.PrettyDisplayIDPool{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 更新时 pool_id 来自路径,其他字段来自 body;统一 trim 后再校验,避免空格绕过必填判断。 command.AppCode = appcode.FromContext(ctx) command.PoolID = strings.TrimSpace(command.PoolID) command.NowMs = s.now().UnixMilli() normalizePrettyDisplayIDPoolCommand(&command) if command.PoolID == "" { return userdomain.PrettyDisplayIDPool{}, xerr.New(xerr.InvalidArgument, "pool_id is required") } if err := validatePrettyDisplayIDPoolCommand(command); err != nil { return userdomain.PrettyDisplayIDPool{}, err } return s.identityRepository.UpdatePrettyDisplayIDPool(ctx, command) } // GeneratePrettyDisplayIDs 按靓号池规则批量生成号码。 func (s *Service) GeneratePrettyDisplayIDs(ctx context.Context, command userdomain.PrettyDisplayIDGenerateCommand) (userdomain.PrettyDisplayIDGenerationBatch, error) { if s.identityRepository == nil { return userdomain.PrettyDisplayIDGenerationBatch{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 批量生成由服务端生成 batch_id 和当前时间,客户端只能选择池、规则和数量,审计操作者来自后台登录态。 command.AppCode = appcode.FromContext(ctx) command.PoolID = strings.TrimSpace(command.PoolID) command.RuleType = strings.TrimSpace(command.RuleType) command.RuleConfigJSON = strings.TrimSpace(command.RuleConfigJSON) command.BatchID = idgen.New("pretty_batch") command.NowMs = s.now().UnixMilli() if command.PoolID == "" || command.Count <= 0 || command.OperatorAdminID <= 0 { return userdomain.PrettyDisplayIDGenerationBatch{}, xerr.New(xerr.InvalidArgument, "generate pretty display ids argument is invalid") } if command.RuleType == "" { // 空规则按历史默认 AABBCC 处理,旧后台和旧池配置不需要补字段也能继续生成。 command.RuleType = userdomain.PrettyDisplayIDRuleAABBCC } if !userdomain.ValidPrettyDisplayIDRuleType(command.RuleType) { return userdomain.PrettyDisplayIDGenerationBatch{}, xerr.New(xerr.InvalidArgument, "rule_type is invalid") } return s.identityRepository.GeneratePrettyDisplayIDs(ctx, command) } // ListPrettyDisplayIDs 返回后台靓号分页列表。 func (s *Service) ListPrettyDisplayIDs(ctx context.Context, query userdomain.PrettyDisplayIDQuery) ([]userdomain.PrettyDisplayID, int64, error) { if s.identityRepository == nil { return nil, 0, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 查询条件只做 trim 和分页收敛,来源、状态、池 ID 的合法性由 repository SQL 作为精确筛选处理。 query.AppCode = appcode.FromContext(ctx) query.Page = normalizePage(query.Page) query.PageSize = normalizePageSize(query.PageSize) query.PoolID = strings.TrimSpace(query.PoolID) query.Source = strings.TrimSpace(query.Source) query.Status = strings.TrimSpace(query.Status) query.Keyword = strings.TrimSpace(query.Keyword) return s.identityRepository.ListPrettyDisplayIDs(ctx, query) } // SetPrettyDisplayIDStatus 切换未占用靓号的状态。 func (s *Service) SetPrettyDisplayIDStatus(ctx context.Context, command userdomain.PrettyDisplayIDStatusCommand) (userdomain.PrettyDisplayID, error) { if s.identityRepository == nil { return userdomain.PrettyDisplayID{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 状态修改不接收 display_user_id,必须以 pretty_id 定位池表记录,防止同内容历史记录被误改。 command.AppCode = appcode.FromContext(ctx) command.PrettyID = strings.TrimSpace(command.PrettyID) command.Status = strings.TrimSpace(command.Status) command.Reason = strings.TrimSpace(command.Reason) command.NowMs = s.now().UnixMilli() if command.PrettyID == "" || command.OperatorAdminID <= 0 || !userdomain.ValidPrettyDisplayIDStatusChange(command.Status) { return userdomain.PrettyDisplayID{}, xerr.New(xerr.InvalidArgument, "pretty display id status argument is invalid") } return s.identityRepository.SetPrettyDisplayIDStatus(ctx, command) } // AdminGrantPrettyDisplayID 后台直接发放靓号。 func (s *Service) AdminGrantPrettyDisplayID(ctx context.Context, command userdomain.AdminGrantPrettyDisplayIDCommand) (userdomain.Identity, string, error) { if s.identityRepository == nil { return userdomain.Identity{}, "", xerr.New(xerr.Unavailable, "identity repository is not configured") } // 后台发放没有池限制和等级限制,但 pretty_id、lease_id、应用和时间仍由服务端生成,保证审计链完整。 command.AppCode = appcode.FromContext(ctx) command.PrettyID = idgen.New("pretty") command.LeaseID = idgen.New("pdu_lease") command.DisplayUserID = strings.TrimSpace(command.DisplayUserID) command.Reason = strings.TrimSpace(command.Reason) command.NowMs = s.now().UnixMilli() // 靓号内容允许 Unicode 字母、数字和组合标记;target/admin 必须来自可信登录态或明确目标用户。 if command.TargetUserID <= 0 || command.OperatorAdminID <= 0 || !userdomain.ValidAdminPrettyDisplayUserID(command.DisplayUserID) { return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDInvalid, "pretty display_user_id format is invalid") } identity, leaseID, err := s.identityRepository.AdminGrantPrettyDisplayID(ctx, command) if err != nil { return userdomain.Identity{}, "", err } return identity, leaseID, nil } func validatePrettyDisplayIDPoolCommand(command userdomain.PrettyDisplayIDPoolCommand) error { if command.Name == "" || command.MinLevel < 0 || command.MaxLevel < command.MinLevel || command.OperatorAdminID <= 0 { return xerr.New(xerr.InvalidArgument, "pretty display id pool argument is invalid") } if !userdomain.ValidPrettyDisplayIDPoolTrack(command.LevelTrack) || !userdomain.ValidPrettyDisplayIDPoolStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "pretty display id pool argument is invalid") } if command.RuleType == "" { command.RuleType = userdomain.PrettyDisplayIDRuleAABBCC } if !userdomain.ValidPrettyDisplayIDRuleType(command.RuleType) { return xerr.New(xerr.InvalidArgument, "rule_type is invalid") } return nil } func normalizePrettyDisplayIDPoolCommand(command *userdomain.PrettyDisplayIDPoolCommand) { command.Name = strings.TrimSpace(command.Name) command.LevelTrack = strings.TrimSpace(command.LevelTrack) command.RuleType = strings.TrimSpace(command.RuleType) command.RuleConfigJSON = strings.TrimSpace(command.RuleConfigJSON) command.Status = strings.TrimSpace(command.Status) if command.RuleType == "" { command.RuleType = userdomain.PrettyDisplayIDRuleAABBCC } } func normalizePage(page int32) int32 { if page <= 0 { return 1 } return page } func normalizePageSize(pageSize int32) int32 { if pageSize <= 0 { return 20 } if pageSize > 100 { return 100 } return pageSize }