377 lines
13 KiB
Go
377 lines
13 KiB
Go
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)
|
||
}
|