2026-04-27 02:29:42 +08:00

532 lines
21 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 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
}