2026-06-30 16:01:08 +08:00

355 lines
16 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 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)
}
// RecyclePrettyDisplayID 后台强制回收用户正在占用的靓号,并恢复该用户默认短号。
func (s *Service) RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error) {
if s.identityRepository == nil {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
// 回收是后台写操作,只接受 pretty_id 定位当前占用记录用户、lease 和来源都在 repository 事务内重新读取校验。
command.AppCode = appcode.FromContext(ctx)
command.PrettyID = strings.TrimSpace(command.PrettyID)
command.Reason = strings.TrimSpace(command.Reason)
command.NowMs = s.now().UnixMilli()
if command.PrettyID == "" || command.OperatorAdminID <= 0 {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.InvalidArgument, "pretty display id recycle argument is invalid")
}
return s.identityRepository.RecyclePrettyDisplayID(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
}