// Package memory 提供 user-service 的测试用内存 repository。 package memory import ( "context" "sync" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" userdomain "hyapp/services/user-service/internal/domain/user" ) // Repository 是 user-service 的测试用内存存储。 // 它不作为生产路径,只用于 service 和 transport 层单元测试。 type Repository struct { // mu 保护所有索引,测试中 auth/user service 可能共享同一 repository。 mu sync.RWMutex // users 保存 user_id -> User 主记录。 users map[int64]userdomain.User // activeDisplayIndex 只保存当前可解析的展示短号。 activeDisplayIndex map[string]int64 // liveDisplayIndex 保存 active/held/reserved/blocked 等不能被新用户占用的短号。 liveDisplayIndex map[string]int64 // lastChangeAtMs 记录默认短号最近修改时间,用于冷却期测试。 lastChangeAtMs map[int64]int64 // passwordAccounts 保存 user_id -> PasswordAccount。 passwordAccounts map[int64]authdomain.PasswordAccount // sessions 保存 session_id -> Session。 sessions map[string]authdomain.Session // sessionByRefresh 保存 refresh_token_hash -> session_id。 sessionByRefresh map[string]string // thirdParty 保存 provider:subject -> ThirdPartyIdentity。 thirdParty map[string]authdomain.ThirdPartyIdentity // leases 保存 lease_id -> PrettyDisplayUserIDLease。 leases map[string]userdomain.PrettyDisplayUserIDLease // activeLeaseByUser 保存 user_id -> active lease_id。 activeLeaseByUser map[int64]string // activeLeaseByPretty 保存 pretty_display_id -> active lease_id。 activeLeaseByPretty map[string]string // audits 保存登录审计,供测试断言。 audits []authdomain.LoginAudit } // NewRepository 创建空内存仓库。 func NewRepository() *Repository { return &Repository{ users: make(map[int64]userdomain.User), activeDisplayIndex: make(map[string]int64), liveDisplayIndex: make(map[string]int64), lastChangeAtMs: make(map[int64]int64), passwordAccounts: make(map[int64]authdomain.PasswordAccount), sessions: make(map[string]authdomain.Session), sessionByRefresh: make(map[string]string), thirdParty: make(map[string]authdomain.ThirdPartyIdentity), leases: make(map[string]userdomain.PrettyDisplayUserIDLease), activeLeaseByUser: make(map[int64]string), activeLeaseByPretty: make(map[string]string), } } // PutUser 写入用户主记录,测试可以用它准备数据。 func (r *Repository) PutUser(user userdomain.User) { r.mu.Lock() defer r.mu.Unlock() // 测试辅助写入也要规范化短号字段,避免 service 查询路径读到脏状态。 user = user.NormalizeDisplayUserIDState() r.users[user.UserID] = user if user.DefaultDisplayUserID != "" { // 默认短号即使不 active,也需要占用 live index。 r.liveDisplayIndex[user.DefaultDisplayUserID] = user.UserID } if user.CurrentDisplayUserID != "" { // 当前展示号可解析,也占用 live index。 r.activeDisplayIndex[user.CurrentDisplayUserID] = user.UserID r.liveDisplayIndex[user.CurrentDisplayUserID] = user.UserID } } // GetUser 查询单个用户。 func (r *Repository) GetUser(_ context.Context, userID int64) (userdomain.User, error) { r.mu.RLock() defer r.mu.RUnlock() user, ok := r.users[userID] if !ok { // 内存实现使用领域错误,便于 service 测试不依赖存储细节。 return userdomain.User{}, xerr.New(xerr.NotFound, "user not found") } return user.NormalizeDisplayUserIDState(), nil } // BatchGetUsers 查询多个用户,缺失用户不会出现在返回 map 中。 func (r *Repository) BatchGetUsers(_ context.Context, userIDs []int64) (map[int64]userdomain.User, error) { r.mu.RLock() defer r.mu.RUnlock() result := make(map[int64]userdomain.User, len(userIDs)) for _, userID := range userIDs { if user, ok := r.users[userID]; ok { // 缺失用户不出现在返回 map 中,保持与 MySQL 批量查询一致。 result[userID] = user.NormalizeDisplayUserIDState() } } return result, nil } // CreateUserWithIdentity 在同一临界区写入用户和 active display_user_id。 func (r *Repository) CreateUserWithIdentity(_ context.Context, user userdomain.User, identity userdomain.Identity) error { r.mu.Lock() defer r.mu.Unlock() if _, exists := r.users[user.UserID]; exists { // user_id 冲突代表发号器异常或测试构造重复。 return xerr.New(xerr.Conflict, "user_id already exists") } if _, exists := r.liveDisplayIndex[identity.DisplayUserID]; exists { // active/held/reserved/blocked 短号都不能被新用户占用。 return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists") } // 用户和默认短号在同一临界区写入,模拟 MySQL 事务原子性。 user = user.NormalizeDisplayUserIDState() user.DefaultDisplayUserID = identity.DisplayUserID user.CurrentDisplayUserID = identity.DisplayUserID user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault r.users[user.UserID] = user r.activeDisplayIndex[identity.DisplayUserID] = user.UserID r.liveDisplayIndex[identity.DisplayUserID] = user.UserID return nil } // GetUserIdentity 按 user_id 返回当前 active display_user_id。 func (r *Repository) GetUserIdentity(_ context.Context, userID int64) (userdomain.Identity, error) { r.mu.RLock() defer r.mu.RUnlock() user, ok := r.users[userID] if !ok { // 查询身份时用户必须存在。 return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found") } user = user.NormalizeDisplayUserIDState() if user.CurrentDisplayUserID == "" { // 理论上 CreateUserWithIdentity 会保证短号存在,该分支保护脏测试数据。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found") } return user.EffectiveIdentity(), nil } // ResolveDisplayUserID 只解析 active 短号归属。 func (r *Repository) ResolveDisplayUserID(_ context.Context, displayUserID string, nowMs int64) (userdomain.Identity, error) { r.mu.Lock() defer r.mu.Unlock() // 解析短号前先批量懒过期,模拟 MySQL 解析路径按输入触发恢复。 r.expireAllPrettyLocked(nowMs) userID, ok := r.activeDisplayIndex[displayUserID] if !ok { // 过期靓号或不存在短号都返回 not found。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDNotFound, "display_user_id not found") } user := r.users[userID].NormalizeDisplayUserIDState() return user.EffectiveIdentity(), nil } // ChangeDisplayUserID 原子维护旧短号释放、新短号激活和用户快照。 func (r *Repository) ChangeDisplayUserID(_ context.Context, command userdomain.DisplayUserIDChangeCommand) (userdomain.Identity, error) { r.mu.Lock() defer r.mu.Unlock() user, ok := r.users[command.UserID] if !ok { // 修改短号必须基于已有用户。 return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found") } user = user.NormalizeDisplayUserIDState() if user.DisplayUserIDExpired(command.ChangedAtMs) { // 修改默认短号前先恢复过期靓号,避免 held 默认号状态卡住。 if _, err := r.expirePrettyLocked(command.UserID, "", command.ChangedAtMs, command.RequestID); err != nil { return userdomain.Identity{}, err } user = r.users[command.UserID].NormalizeDisplayUserIDState() } if user.CurrentDisplayUserIDKind == userdomain.DisplayUserIDKindPretty { // 有 active 靓号时拒绝修改默认短号,避免过期恢复目标不清晰。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDPrettyActive, "pretty display_user_id is active") } if user.DefaultDisplayUserID == command.NewDisplayUserID { // 修改为相同默认短号是幂等成功。 return user.EffectiveIdentity(), nil } if ownerID, exists := r.liveDisplayIndex[command.NewDisplayUserID]; exists && ownerID != command.UserID { // 只要新短号被其他用户 live 占用,就不能修改。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists") } if lastChangedAt := r.lastChangeAtMs[command.UserID]; lastChangedAt > 0 && command.CooldownMs > 0 && command.ChangedAtMs-lastChangedAt < command.CooldownMs { // 冷却期按最后一次默认短号修改时间计算。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDCooldown, "display_user_id change is cooling down") } // 旧默认短号释放,新默认短号成为 active/live。 delete(r.activeDisplayIndex, user.CurrentDisplayUserID) delete(r.liveDisplayIndex, user.DefaultDisplayUserID) r.activeDisplayIndex[command.NewDisplayUserID] = command.UserID r.liveDisplayIndex[command.NewDisplayUserID] = command.UserID user.DefaultDisplayUserID = command.NewDisplayUserID user.CurrentDisplayUserID = command.NewDisplayUserID user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault user.CurrentDisplayUserIDExpiresAtMs = 0 user.UpdatedAtMs = command.ChangedAtMs r.users[command.UserID] = user r.lastChangeAtMs[command.UserID] = command.ChangedAtMs return user.EffectiveIdentity(), nil } // ApplyPrettyDisplayUserID 原子创建靓号租约,并把默认短号从 active 变为 held。 func (r *Repository) ApplyPrettyDisplayUserID(_ context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error) { r.mu.Lock() defer r.mu.Unlock() user, ok := r.users[command.UserID] if !ok { // 申请靓号必须基于已有用户。 return userdomain.Identity{}, "", xerr.New(xerr.NotFound, "user not found") } user = user.NormalizeDisplayUserIDState() if user.DisplayUserIDExpired(command.NowMs) { // 新申请前先恢复过期靓号。 if _, err := r.expirePrettyLocked(command.UserID, "", command.NowMs, command.RequestID); err != nil { return userdomain.Identity{}, "", err } user = r.users[command.UserID].NormalizeDisplayUserIDState() } if user.CurrentDisplayUserIDKind == userdomain.DisplayUserIDKindPretty { // 同一用户不能叠加 active 靓号。 return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPrettyActive, "pretty display_user_id is active") } if _, exists := r.liveDisplayIndex[command.PrettyDisplayID]; exists { // 靓号只要被 live 占用就不可申请。 return userdomain.Identity{}, "", xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display_user_id is not available") } // 默认短号从 active 变为 held,靓号成为 active。 expiresAtMs := command.NowMs + command.LeaseDurationMs lease := userdomain.PrettyDisplayUserIDLease{ LeaseID: command.LeaseID, DisplayUserID: command.PrettyDisplayID, UserID: command.UserID, Status: userdomain.PrettyLeaseStatusActive, StartsAtMs: command.NowMs, ExpiresAtMs: expiresAtMs, PaymentReceiptID: command.PaymentReceiptID, Source: command.Source, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } delete(r.activeDisplayIndex, user.CurrentDisplayUserID) r.activeDisplayIndex[command.PrettyDisplayID] = command.UserID r.liveDisplayIndex[command.PrettyDisplayID] = command.UserID r.leases[command.LeaseID] = lease r.activeLeaseByUser[command.UserID] = command.LeaseID r.activeLeaseByPretty[command.PrettyDisplayID] = command.LeaseID user.CurrentDisplayUserID = command.PrettyDisplayID user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindPretty user.CurrentDisplayUserIDExpiresAtMs = expiresAtMs user.UpdatedAtMs = command.NowMs r.users[command.UserID] = user return user.EffectiveIdentity(), command.LeaseID, nil } // ExpirePrettyDisplayUserID 恢复默认短号;重复过期返回当前默认身份,保证补偿任务可重试。 func (r *Repository) ExpirePrettyDisplayUserID(_ context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error) { r.mu.Lock() defer r.mu.Unlock() // 过期恢复使用同一内部方法,保证懒过期、定时过期和测试路径一致。 return r.expirePrettyLocked(command.UserID, command.LeaseID, command.NowMs, command.RequestID) } // SetPassword 为已有用户首次写入密码身份。 func (r *Repository) SetPassword(_ context.Context, account authdomain.PasswordAccount) error { r.mu.Lock() defer r.mu.Unlock() if _, exists := r.users[account.UserID]; !exists { // 不能为不存在用户设置密码身份。 return xerr.New(xerr.NotFound, "user not found") } if _, exists := r.passwordAccounts[account.UserID]; exists { // v1 只允许首次设置密码,不提供重置流程。 return xerr.New(xerr.PasswordAlreadySet, "password already set") } r.passwordAccounts[account.UserID] = account return nil } // FindPasswordByDisplayUserID 先解析当前 active display_user_id,再读取用户密码身份。 func (r *Repository) FindPasswordByDisplayUserID(_ context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error) { r.mu.Lock() defer r.mu.Unlock() r.expireAllPrettyLocked(nowMs) userID, exists := r.activeDisplayIndex[displayUserID] if !exists { // 不存在、过期或 held 默认号都不能登录密码。 return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found") } account, exists := r.passwordAccounts[userID] if !exists { // 未设置密码和用户不存在都向 service 层表现为 not found,再统一 AUTH_FAILED。 return authdomain.PasswordAccount{}, xerr.New(xerr.NotFound, "password account not found") } account.DisplayUserID = displayUserID return account, nil } // CreateSession 创建新的 refresh session。 func (r *Repository) CreateSession(_ context.Context, session authdomain.Session) error { r.mu.Lock() defer r.mu.Unlock() if _, exists := r.sessionByRefresh[session.RefreshTokenHash]; exists { // refresh token hash 理论上全局唯一,冲突视为异常。 return xerr.New(xerr.Conflict, "refresh token hash already exists") } r.sessions[session.SessionID] = session r.sessionByRefresh[session.RefreshTokenHash] = session.SessionID return nil } // FindActiveSessionByRefreshHash 查找未过期且未吊销的 session。 func (r *Repository) FindActiveSessionByRefreshHash(_ context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) { r.mu.RLock() defer r.mu.RUnlock() sessionID, exists := r.sessionByRefresh[refreshTokenHash] if !exists { // 找不到 refresh hash 按已吊销处理,避免暴露 token 是否曾存在。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } session := r.sessions[sessionID] if session.RevokedAtMs > 0 { // 已吊销 session 不能继续 refresh。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } if session.ExpiresAtMs <= nowMs { // 过期 session 与吊销 session 使用不同 reason。 return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired") } return session, nil } // ReplaceSession 原子吊销旧 session 并创建新 session。 func (r *Repository) ReplaceSession(_ context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error { r.mu.Lock() defer r.mu.Unlock() oldSession, exists := r.sessions[oldSessionID] if !exists || oldSession.RevokedAtMs > 0 { // 旧 session 不存在或已吊销时不能轮换。 return xerr.New(xerr.SessionRevoked, "session revoked") } // 轮换必须在同一临界区吊销旧 session 并写入新 session。 oldSession.RevokedAtMs = revokedAtMs oldSession.UpdatedAtMs = revokedAtMs r.sessions[oldSessionID] = oldSession r.sessions[newSession.SessionID] = newSession r.sessionByRefresh[newSession.RefreshTokenHash] = newSession.SessionID return nil } // RevokeSession 按 session_id 或 refresh token hash 吊销 session。 func (r *Repository) RevokeSession(_ context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) { r.mu.Lock() defer r.mu.Unlock() if sessionID == "" { // refresh token 登出先反查 session_id。 sessionID = r.sessionByRefresh[refreshTokenHash] } session, exists := r.sessions[sessionID] if !exists { // 未找到按 revoked 返回,保持幂等登出语义。 return false, xerr.New(xerr.SessionRevoked, "session revoked") } if session.RevokedAtMs > 0 { // 已吊销再次登出返回 true,表示目标已经处于退出状态。 return true, nil } session.RevokedAtMs = revokedAtMs session.UpdatedAtMs = revokedAtMs r.sessions[sessionID] = session return true, nil } // FindThirdPartyIdentity 查找 provider + subject 绑定。 func (r *Repository) FindThirdPartyIdentity(_ context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) { r.mu.RLock() defer r.mu.RUnlock() identity, exists := r.thirdParty[thirdPartyKey(provider, providerSubject)] if !exists { // provider + subject 未绑定时由 service 决定走注册。 return authdomain.ThirdPartyIdentity{}, xerr.New(xerr.NotFound, "third-party identity not found") } return identity, nil } // CreateThirdPartyUser 原子创建用户、默认短号、三方绑定和首个 session。 func (r *Repository) CreateThirdPartyUser(_ context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error { r.mu.Lock() defer r.mu.Unlock() key := thirdPartyKey(thirdParty.Provider, thirdParty.ProviderSubject) if _, exists := r.thirdParty[key]; exists { // 并发注册同一三方身份时返回冲突。 return xerr.New(xerr.Conflict, "third-party identity already exists") } if _, exists := r.liveDisplayIndex[identity.DisplayUserID]; exists { // 默认短号冲突由 service 有限重试。 return xerr.New(xerr.DisplayUserIDExists, "display_user_id already exists") } if _, exists := r.users[user.UserID]; exists { return xerr.New(xerr.Conflict, "user_id already exists") } user = user.NormalizeDisplayUserIDState() // 用户、默认短号、三方绑定和首个 session 在同一临界区写入,模拟事务原子性。 user.DefaultDisplayUserID = identity.DisplayUserID user.CurrentDisplayUserID = identity.DisplayUserID user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault r.users[user.UserID] = user r.activeDisplayIndex[identity.DisplayUserID] = user.UserID r.liveDisplayIndex[identity.DisplayUserID] = user.UserID r.thirdParty[key] = thirdParty r.sessions[session.SessionID] = session r.sessionByRefresh[session.RefreshTokenHash] = session.SessionID return nil } // RecordLoginAudit 记录登录审计,内存实现仅用于测试断言。 func (r *Repository) RecordLoginAudit(_ context.Context, audit authdomain.LoginAudit) error { r.mu.Lock() defer r.mu.Unlock() r.audits = append(r.audits, audit) return nil } func thirdPartyKey(provider string, providerSubject string) string { // 三方绑定使用 provider + subject 作为唯一键。 return provider + ":" + providerSubject } func (r *Repository) expireAllPrettyLocked(nowMs int64) { for userID, user := range r.users { // 调用方已持有写锁;这里批量清理所有过期靓号。 user = user.NormalizeDisplayUserIDState() if user.DisplayUserIDExpired(nowMs) { _, _ = r.expirePrettyLocked(userID, "", nowMs, "lazy_expire") } } } func (r *Repository) expirePrettyLocked(userID int64, leaseID string, nowMs int64, _ string) (userdomain.Identity, error) { user, ok := r.users[userID] if !ok { // 恢复过期靓号必须找到用户主记录。 return userdomain.Identity{}, xerr.New(xerr.NotFound, "user not found") } user = user.NormalizeDisplayUserIDState() if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty { // 非 pretty 状态重复过期按幂等成功返回当前身份。 return user.EffectiveIdentity(), nil } if user.CurrentDisplayUserIDExpiresAtMs > nowMs { // 未到期时拒绝恢复,防止提前释放用户购买的靓号。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not expired") } activeLeaseID := r.activeLeaseByUser[userID] if leaseID != "" && activeLeaseID != "" && activeLeaseID != leaseID { // 指定 lease_id 不匹配当前 active lease 时拒绝,避免旧任务误释放新租约。 return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not active") } if activeLeaseID != "" { // 租约记录标记 expired,并清理 active lease 索引。 lease := r.leases[activeLeaseID] lease.Status = userdomain.PrettyLeaseStatusExpired lease.UpdatedAtMs = nowMs r.leases[activeLeaseID] = lease delete(r.activeLeaseByPretty, lease.DisplayUserID) delete(r.activeLeaseByUser, userID) } // 靓号释放,默认短号恢复为 active/live。 delete(r.activeDisplayIndex, user.CurrentDisplayUserID) delete(r.liveDisplayIndex, user.CurrentDisplayUserID) r.activeDisplayIndex[user.DefaultDisplayUserID] = userID r.liveDisplayIndex[user.DefaultDisplayUserID] = userID user.CurrentDisplayUserID = user.DefaultDisplayUserID user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault user.CurrentDisplayUserIDExpiresAtMs = 0 user.UpdatedAtMs = nowMs r.users[userID] = user return user.EffectiveIdentity(), nil }