2026-04-25 13:21:39 +08:00

377 lines
13 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"
"fmt"
"time"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// UserRepository 隔离用户主数据读写。
// 它只覆盖 users 主表视角和“创建用户+默认短号”的事务入口,避免查询用例依赖短号变更细节。
type UserRepository interface {
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
CreateUserWithIdentity(ctx context.Context, createdUser userdomain.User, identity userdomain.Identity) error
}
// IdentityRepository 隔离 display_user_id 的归属、解析、变更和靓号租约。
// 用户 service 通过该接口表达短号用例,不直接感知 MySQL 的 identity/change_log/lease 表布局。
type IdentityRepository interface {
GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error)
ResolveDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error)
ChangeDisplayUserID(ctx context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error)
ApplyPrettyDisplayUserID(ctx context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error)
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
}
// Repository 是兼容旧构造方式的组合接口。
// 新代码应优先让 Service 字段依赖 UserRepository 和 IdentityRepository而不是把所有方法塞回一个贫血大接口。
type Repository interface {
UserRepository
IdentityRepository
}
// IDGenerator 生成系统内部不可变 user_id。
type IDGenerator interface {
NewInt64() int64
}
// DisplayUserIDAllocator 为新用户生成默认短号候选。
type DisplayUserIDAllocator interface {
NextDisplayUserID(userID int64, attempt int) string
}
// Option 调整 user service 的运行时策略,测试可注入确定性发号器。
type Option func(*Service)
// NumericDisplayUserIDAllocator 基于 user_id 生成数字短号候选。
// 它只生成格式合法的候选,唯一性仍由 repository 事务保证。
type NumericDisplayUserIDAllocator struct{}
// NextDisplayUserID 返回 6 到 10 位数字短号候选。
func (NumericDisplayUserIDAllocator) NextDisplayUserID(userID int64, attempt int) string {
base := int64(100000)
space := int64(9_999_000_000)
value := base + ((userID + int64(attempt)) % space)
if value < base {
value = base - value
}
return fmt.Sprintf("%d", value)
}
// Service 承载用户主数据和短号用例。
// 字段按领域接口拆分,跨表事务仍保留在 repository 方法内,避免 service 层手动拼事务。
type Service struct {
userRepository UserRepository
identityRepository IdentityRepository
idGenerator IDGenerator
displayUserIDAllocator DisplayUserIDAllocator
now func() time.Time
allocateMaxAttempts int
displayUserIDCooldown time.Duration
}
// New 创建用户服务。
// 构造函数只强制要求 UserRepository如果同一实现也支持 IdentityRepository则自动接入短号用例。
func New(userRepository UserRepository, options ...Option) *Service {
svc := &Service{
userRepository: userRepository,
idGenerator: idgen.NewInt64Generator(0),
displayUserIDAllocator: NumericDisplayUserIDAllocator{},
now: time.Now,
allocateMaxAttempts: 8,
displayUserIDCooldown: 30 * 24 * time.Hour,
}
if identityRepository, ok := userRepository.(IdentityRepository); ok {
svc.identityRepository = identityRepository
}
for _, option := range options {
option(svc)
}
return svc
}
// WithIdentityRepository 注入独立短号 repository。
// 该 option 支持后续把用户主数据和短号 identity 拆成不同实现,同时不改变现有 New(memory/mysqlRepo) 调用。
func WithIdentityRepository(repository IdentityRepository) Option {
return func(s *Service) {
if repository != nil {
s.identityRepository = repository
}
}
}
// WithIDGenerator 注入 user_id 发号器。
func WithIDGenerator(generator IDGenerator) Option {
return func(s *Service) {
if generator != nil {
s.idGenerator = generator
}
}
}
// WithDisplayUserIDAllocator 注入短号候选生成器。
func WithDisplayUserIDAllocator(allocator DisplayUserIDAllocator) Option {
return func(s *Service) {
if allocator != nil {
s.displayUserIDAllocator = allocator
}
}
}
// WithClock 注入时间函数,保证冷却期测试不依赖真实时间。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
// WithDisplayUserIDPolicy 配置分配重试和修改冷却期。
func WithDisplayUserIDPolicy(maxAttempts int, cooldown time.Duration) Option {
return func(s *Service) {
if maxAttempts > 0 {
s.allocateMaxAttempts = maxAttempts
}
if cooldown >= 0 {
s.displayUserIDCooldown = cooldown
}
}
}
// GetUser 查询单个用户主状态。
func (s *Service) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
if userID <= 0 {
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
user, err := s.userRepository.GetUser(ctx, userID)
if err != nil {
return userdomain.User{}, err
}
return s.refreshExpiredUser(ctx, user, "")
}
// BatchGetUsers 批量查询用户主状态。
func (s *Service) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) {
if len(userIDs) == 0 {
return map[int64]userdomain.User{}, nil
}
for _, userID := range userIDs {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
}
}
if s.userRepository == nil {
return nil, xerr.New(xerr.Unavailable, "user repository is not configured")
}
users, err := s.userRepository.BatchGetUsers(ctx, userIDs)
if err != nil {
return nil, err
}
for userID, user := range users {
refreshed, err := s.refreshExpiredUser(ctx, user, "")
if err != nil {
return nil, err
}
users[userID] = refreshed
}
return users, nil
}
// CreateUser 创建用户主记录,并同时分配 active display_user_id。
// 该方法供注册和三方首次登录后续接入,避免出现没有短号的用户。
func (s *Service) CreateUser(ctx context.Context) (userdomain.User, userdomain.Identity, error) {
if s.userRepository == nil {
return userdomain.User{}, userdomain.Identity{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
userID := s.idGenerator.NewInt64()
nowMs := s.now().UnixMilli()
displayUserID := s.displayUserIDAllocator.NextDisplayUserID(userID, attempt)
if !userdomain.ValidDisplayUserID(displayUserID) {
continue
}
createdUser := userdomain.User{
UserID: userID,
DefaultDisplayUserID: displayUserID,
CurrentDisplayUserID: displayUserID,
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.StatusActive,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
identity := userdomain.Identity{
UserID: userID,
DisplayUserID: displayUserID,
DefaultDisplayUserID: displayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}
err := s.userRepository.CreateUserWithIdentity(ctx, createdUser, identity)
if err == nil {
return createdUser, identity, nil
}
if !xerr.IsCode(err, xerr.DisplayUserIDExists) {
return userdomain.User{}, userdomain.Identity{}, err
}
}
return userdomain.User{}, userdomain.Identity{}, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
// GetUserIdentity 按 user_id 查询当前 active 短号。
func (s *Service) GetUserIdentity(ctx context.Context, userID int64) (userdomain.Identity, error) {
if userID <= 0 {
return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.identityRepository == nil {
return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
identity, err := s.identityRepository.GetUserIdentity(ctx, userID)
if err != nil {
return userdomain.Identity{}, err
}
if identity.DisplayUserIDKind == userdomain.DisplayUserIDKindPretty && identity.DisplayUserIDExpiresAtMs > 0 && identity.DisplayUserIDExpiresAtMs <= s.now().UnixMilli() {
return s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
UserID: userID,
RequestID: "",
NowMs: s.now().UnixMilli(),
})
}
return identity, nil
}
// ResolveDisplayUserID 把短号解析成系统 user_id。
func (s *Service) ResolveDisplayUserID(ctx context.Context, displayUserID string) (userdomain.Identity, error) {
if !userdomain.ValidDisplayUserID(displayUserID) {
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDInvalid, "display_user_id format is invalid")
}
if s.identityRepository == nil {
return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
return s.identityRepository.ResolveDisplayUserID(ctx, displayUserID, s.now().UnixMilli())
}
// ChangeDisplayUserID 修改用户默认短号,并由 repository 在事务内维护当前展示快照和审计。
func (s *Service) ChangeDisplayUserID(ctx context.Context, userID int64, newDisplayUserID string, reason string, operatorUserID int64, requestID string) (userdomain.Identity, error) {
if userID <= 0 {
return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if !userdomain.ValidDisplayUserID(newDisplayUserID) {
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDInvalid, "display_user_id format is invalid")
}
if s.identityRepository == nil {
return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
return s.identityRepository.ChangeDisplayUserID(ctx, userdomain.DisplayUserIDChangeCommand{
UserID: userID,
NewDisplayUserID: newDisplayUserID,
Reason: reason,
OperatorUserID: operatorUserID,
RequestID: requestID,
ChangedAtMs: s.now().UnixMilli(),
CooldownMs: s.displayUserIDCooldown.Milliseconds(),
})
}
// 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) {
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 == "" {
return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPaymentRequired, "payment receipt is required")
}
if s.identityRepository == nil {
return userdomain.Identity{}, "", xerr.New(xerr.Unavailable, "identity repository is not configured")
}
nowMs := s.now().UnixMilli()
return s.identityRepository.ApplyPrettyDisplayUserID(ctx, userdomain.PrettyDisplayUserIDCommand{
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 {
return userdomain.Identity{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.identityRepository == nil {
return userdomain.Identity{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
return s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
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) {
user = user.NormalizeDisplayUserIDState()
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")
}
if _, err := s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{
UserID: user.UserID,
RequestID: requestID,
NowMs: nowMs,
}); err != nil {
return userdomain.User{}, err
}
if s.userRepository == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.GetUser(ctx, user.UserID)
}