355 lines
16 KiB
Go
355 lines
16 KiB
Go
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
|
||
}
|