Kaynağa Gözat

feat(identity): T4-3 AuthService 两步登录 + Redis 票据 + 惰性升级

- infra/redis: LoginTicketStore(loginticket:{ticket}→canonicalUserID,
  GETDEL 原子消费防重放,TTL 5min;含 RoundTrip/Miss/TTL/FailClosed 测试)
- identity/repository: 新增 UserRepository/IdentityRepository/MembershipRepository
  三接口 + 编译期断言(接口名带 Repository 后缀,struct 保留原名避免冲突)
- identity/token: 删 Claims.UserID / RefreshPayload.UserID(ADR-013 新表一律
  ULID,int 只存 t_legacy_mapping);新增 DeleteRefreshToken(48hex→rjti→白名单删)
- identity/service: AuthService
  · Login  ① 验账密 → 一次性 loginTicket + user + memberships[](无 token)
  · Issue  ② 消费 loginTicket + 服务端校验 (institutionId,campusId) 归属
  · Me / Refresh / Logout(尽力而为、幂等)
  · 防枚举:账号不存在/密码错/禁用/无哈希 统一 code 1001;
    对"账号不存在"路径 burnTiming 补跑 argon2 作 timing floor
  · 惰性升级(ADR-016):命中 md5_salt/sha_salt → argon2 重哈希写回,
    写回失败则阻断登录(半升级态不放行)
- 单测 17 例全绿(mock 仓储 + 真 Issuer + miniredis);
  断言纪律:自定义文案错误断言 code,真 sentinel 断言 errors.Is
Q 3 gün önce
ebeveyn
işleme
9d74dac806

+ 57 - 0
internal/identity/repository/interfaces.go

@@ -0,0 +1,57 @@
+// 仓储接口(Service 层依赖抽象,供纯 mock 单测——不连 DB 也能覆盖 AuthService 逻辑)。
+//
+// 设计决策(依赖倒置 + 零返工):
+//   - Service 只依赖这 3 个**接口**(不 import 具体结构体);
+//     生产装配时注入具体实现(NewUserRepo(db) 等),单测时注入 mock。
+//   - 接口签名与具体仓储方法**逐一对齐**(入参/出参/error 语义完全一致),
+//     生产装配时具体结构体隐式满足接口——若后续改方法签名,main 装配处编译期即暴露。
+//
+// 命名约定:接口名带 "…Repository"(UserRepository/IdentityRepository/MembershipRepository),
+//   与本包内具体结构体(UserRepo/IdentityRepo/MembershipRepo)区分,避免同名冲突。
+//
+// 为什么在 repository 包内定义(而非 service 包):
+//   - 接口的"自然归属"是数据访问层(描述"能取哪些数据");
+//   - service 包 import repository 包取接口(service → repository,方向正确);
+//   - 若接口放 service 包,repository 包要 import service 包实现它 → 反向依赖,违反铁律。
+//
+// 平台级访问(同 user_repo.go 包注释):这 3 个接口的实现不继承 scope.BaseRepo——
+// 登录/用户/成员关系是平台级全局查询,多租户防线在后续业务域 Repository 才启动。
+package repository
+
+import (
+	"context"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// UserRepository 用户主表数据访问接口(*UserRepo 隐式满足)。
+type UserRepository interface {
+	// GetByCanonicalUserID 按 canonical_user_id(ULID)取单个用户(含密码三列——仅登录链路在内存消费)。
+	GetByCanonicalUserID(ctx context.Context, uid string) (*model.User, error)
+	// UpdatePasswordLazyUpgrade 惰性升级写回(argon2 重哈希 + salt 置 NULL + hash_version='argon2')。
+	UpdatePasswordLazyUpgrade(ctx context.Context, uid, newHash string) error
+}
+
+// IdentityRepository 登录身份数据访问接口(*IdentityRepo 隐式满足)。
+type IdentityRepository interface {
+	// GetByTypeIdentifier 按 (type, identifier) 唯一键取登录身份记录。
+	GetByTypeIdentifier(ctx context.Context, typ, identifier string) (*model.Identity, error)
+}
+
+// MembershipRepository 成员关系数据访问接口(*MembershipRepo 隐式满足)。
+type MembershipRepository interface {
+	// ListByCanonicalUserID 取某用户全部成员关系(登录第一步返回机构/校区选择列表用;空时返非 nil 空切片)。
+	ListByCanonicalUserID(ctx context.Context, uid string) ([]orgmodel.Membership, error)
+	// GetByUserInstitutionCampus 按 (canonical_user_id, institution_id, campus_id) 取唯一 membership
+	// (登录第二步 /auth/issue 选定作用域后取 role;同作用域多角色取 created_at 最新)。
+	GetByUserInstitutionCampus(ctx context.Context, uid string, institutionID, campusID int64) (*orgmodel.Membership, error)
+}
+
+// 编译期接口断言:具体仓储结构体须满足对应接口(防止"接口与实现签名漂移"——
+// 改任一侧方法签名时,main 装配处把具体实现当接口传参会编译失败,此处再显式兜一层)。
+var (
+	_ UserRepository       = (*UserRepo)(nil)
+	_ IdentityRepository   = (*IdentityRepo)(nil)
+	_ MembershipRepository = (*MembershipRepo)(nil)
+)

+ 547 - 0
internal/identity/service/auth_service.go

@@ -0,0 +1,547 @@
+// Package service 身份域业务逻辑(登录主链路编排:两步登录 / me / refresh / logout)。
+//
+// 分层职责(架构规格 §3.2 三层):
+//   - 本包(Service):编排 Repository(数据)+ token.Issuer(凭证)+ redis.LoginTicketStore(票据),
+//     只返回 **sentinel 业务错误**(不泄漏 gorm/redis 原错——那是 Repository/存储层的事);
+//   - Repository(identity/repository):平台级数据访问(本域不继承 scope.BaseRepo);
+//   - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。
+//
+// 两步登录协议(T4 契约,2026-11-06 拍板):
+//   ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
+//     memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
+//   ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
+//     (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
+//   移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
+//
+// 安全要点:
+//   - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
+//     文案"账号或密码错误"(code 1001),并对"账号不存在"路径补跑一次 argon2(timing floor),
+//     使响应时间不泄漏"该账号是否存在"。
+//   - **惰性升级(ADR-016)**:命中旧算法(md5_salt/sha_salt)即 argon2 重哈希写回 + 撤该用户 token;
+//     升级写回失败则**阻断登录**(凭证半升级态 = 安全漏洞,宁可让用户重试)。
+//   - **作用域校验**:② 必须服务端确认 (institutionId, campusId) 确属该用户的 membership,
+//     否则拒绝(客户端不能挑任意作用域签 token = 越权)。
+//
+// 依赖方向:service → repository(接口) + token + infra/redis + credential + model(全部为下层/同域),
+// 不依赖 handler/main(纯业务逻辑,可纯单测:mock repo + 真 Issuer + miniredis 票据)。
+package service
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	stderrors "errors"
+	"strings"
+	"time"
+
+	"yuansiwei/unified-backend/internal/identity/credential"
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/repository"
+	"yuansiwei/unified-backend/internal/identity/token"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	appredis "yuansiwei/unified-backend/internal/infra/redis"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
+// code 1001(认证类),msg 脱敏不区分具体原因。
+var errAuthFailed = apperrors.New(apperrors.CodeUnauth, "账号或密码错误")
+
+// defaultLoginTicketTTL 登录票据有效期(①→② 之间的窗口;5min 给前端留足操作时间,
+// 又不至于让泄露的 ticket 长期可换 token)。
+const defaultLoginTicketTTL = 5 * time.Minute
+
+// AuthService 身份域业务服务(两步登录 / me / refresh / logout 编排)。
+//
+// 字段(装配入口 NewAuthService 注入,均必填):
+//   - users / identities / memberships:三仓储接口(生产注入 *repository.UserRepo 等具体实现,
+//     单测注入 mock——依赖倒置,纯单测不连 DB)。
+//   - issuer:token 签发器(RS256 私钥 + access/refresh TTL + refresh 白名单/吊销存储,T4-1/T4-6 装配)。
+//   - tickets:登录票据存储(Redis GETDEL 原子消费,T4-3 新增)。
+//   - accessTTL:access 有效期(算 expiresAt + logout 撤销的剩余 TTL)。
+//   - pubKeys / issuerStr / revocation:验签三件套(Refresh 轮换后回读新 access 的 claims 用,
+//     走 stateless 的 token.Verify;与验签中间件同源,来自 config + 同一吊销存储)。
+type AuthService struct {
+	users       repository.UserRepository
+	identities  repository.IdentityRepository
+	memberships repository.MembershipRepository
+	issuer      *token.Issuer
+	tickets     *appredis.LoginTicketStore
+
+	accessTTL  time.Duration
+	ticketTTL  time.Duration
+	pubKeys    []string
+	issuerStr  string
+	revocation token.RevocationStore
+
+	// dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
+	// 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
+	dummyHash string
+}
+
+// Config AuthService 装配参数(T4-6 main 按此构造;全部必填,缺则 NewAuthService 报错)。
+type Config struct {
+	Users       repository.UserRepository
+	Identities  repository.IdentityRepository
+	Memberships repository.MembershipRepository
+	Issuer      *token.Issuer
+	Tickets     *appredis.LoginTicketStore
+	AccessTTL   time.Duration
+	PublicKeys  []string
+	IssuerStr   string
+	Revocation  token.RevocationStore
+	// LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
+	LoginTicketTTL time.Duration
+}
+
+// NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
+//
+// 入参:cfg Config:见 Config 字段说明。
+// 出参:
+//   - *AuthService:就绪服务。
+//   - error:必填依赖缺失(nil 检查,fail-fast——缺仓储/签发器/票据存储时不允许启动);
+//     AccessTTL ≤ 0 视为配置错误(access 不能无有效期,安全红线)。
+func NewAuthService(cfg Config) (*AuthService, error) {
+	if cfg.Users == nil || cfg.Identities == nil || cfg.Memberships == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "service: 仓储接口未注入(users/identities/memberships 必填)")
+	}
+	if cfg.Issuer == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "service: token.Issuer 未注入")
+	}
+	if cfg.Tickets == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "service: 登录票据存储未注入(redis.LoginTicketStore 必填)")
+	}
+	if cfg.AccessTTL <= 0 {
+		return nil, apperrors.New(apperrors.CodeInternal, "service: AccessTTL 必须 > 0(access 无有效期 = 安全红线)")
+	}
+	ttl := cfg.LoginTicketTTL
+	if ttl <= 0 {
+		ttl = defaultLoginTicketTTL
+	}
+	// 防枚举 timing floor:启动时生成一个 argon2 dummy(64MB/1 次,~百毫秒,仅启动开销)。
+	// 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。
+	dummy, _ := credential.HashArgon2("timing-equalization-dummy")
+	return &AuthService{
+		users:       cfg.Users,
+		identities:  cfg.Identities,
+		memberships: cfg.Memberships,
+		issuer:      cfg.Issuer,
+		tickets:     cfg.Tickets,
+		accessTTL:   cfg.AccessTTL,
+		ticketTTL:   ttl,
+		pubKeys:     cfg.PublicKeys,
+		issuerStr:   cfg.IssuerStr,
+		revocation:  cfg.Revocation,
+		dummyHash:   dummy,
+	}, nil
+}
+
+// ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R<T>)----
+
+// UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。
+type UserView struct {
+	ID         string `json:"id"`          // canonical_user_id(ULID)
+	Username   string `json:"username"`    // 用户名(可空)
+	RealName   string `json:"realName"`    // 真实姓名(可空)
+	PersonType string `json:"personType"`  // 业务身份:student/teacher/parent/staff
+	Status     string `json:"status"`      // active/disabled/merged
+}
+
+// MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
+type MembershipView struct {
+	MembershipID  int64  `json:"membershipId"`
+	InstitutionID int64  `json:"institutionId"`
+	CampusID      int64  `json:"campusId"`
+	Role          string `json:"role"` // 字符串角色码(super_admin/academic/...)
+	Status        string `json:"status"` // active/inactive
+}
+
+// LoginStep1 两步登录第一步出参(验账密成功,未签 token)。
+type LoginStep1 struct {
+	LoginTicket string           `json:"loginTicket"` // 一次性票据(② 消费;5min 有效,单次)
+	User        *UserView        `json:"user"`        // 登录用户基础信息
+	Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
+}
+
+// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户)。
+type TokenPair struct {
+	AccessToken   string     `json:"accessToken"`
+	RefreshToken  string     `json:"refreshToken"`
+	ExpiresAt     int64      `json:"expiresAt"` // access 过期(Unix 毫秒)
+	User          *UserView  `json:"user"`
+	Roles         []string   `json:"roles"`         // 选定作用域的角色码(通常单条)
+	InstitutionID int64      `json:"institutionId"` // 选定作用域
+	CampusID      int64      `json:"campusId"`
+	Permissions   []string   `json:"permissions"` // 占位:细粒度权限码(RBAC,T6 域补;当前空)
+}
+
+// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用)。
+type MeResult struct {
+	*UserView
+	Roles         []string         `json:"roles"`
+	InstitutionID int64            `json:"institutionId"` // 当前作用域(来自已验签 claims)
+	CampusID      int64            `json:"campusId"`
+	Memberships   []MembershipView `json:"memberships"`
+}
+
+// ---- 两步登录 ----
+
+// Login 两步登录**第一步**:验账密(惰性升级)→ 发一次性票据 + 用户信息 + 作用域列表。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - identityType string:登录身份类型(model.IdentityType* 之一:username/phone/email/wechat)。
+//     PC 当前走 "username"(账号);phone/email/wechat 后续开通。
+//   - identifier string:归一前标识(username 原值 / phone 11 位 / email 大小写混 / wechat open_id)。
+//   - password string:明文密码(仅内存调用栈,禁止日志/响应)。
+//
+// 出参:
+//   - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
+//   - error(*AppError):
+//     - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
+//     - ErrBadParam(2001):phone 非 11 位(入参格式非法);
+//     - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
+//
+// 时序纪律(防枚举 timing floor):
+//   "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
+//   "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
+func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) {
+	ident := s.normalize(identityType, identifier)
+	if identityType == model.IdentityTypePhone && !isValidPhone(ident) {
+		return nil, apperrors.ErrBadParam
+	}
+
+	it, err := s.identities.GetByTypeIdentifier(ctx, identityType, ident)
+	if err != nil {
+		// 区分"不存在"(统一文案,防枚举)与"DB 故障"(5002)
+		if stderrors.Is(err, apperrors.ErrNotFound) {
+			s.burnTiming(ctx, password) // timing floor:补跑一次 argon2
+			return nil, errAuthFailed
+		}
+		return nil, err // *AppError(ErrDBError 等)直接上抛
+	}
+
+	u, err := s.users.GetByCanonicalUserID(ctx, it.CanonicalUserID)
+	if err != nil {
+		if stderrors.Is(err, apperrors.ErrNotFound) {
+			// 身份指向的用户不存在(数据不一致,理论不该发生)→ 统一文案 + timing floor
+			s.burnTiming(ctx, password)
+			return nil, errAuthFailed
+		}
+		return nil, err
+	}
+	// 账号状态:disabled/merged 一律统一文案(不泄漏"该账号存在但被禁用")
+	if u.Status != model.UserStatusActive {
+		s.burnTiming(ctx, password)
+		return nil, errAuthFailed
+	}
+
+	// 密码三列缺失(无哈希):统一文案 + timing floor(不区分"无哈希"与"密码错")
+	if u.PasswordHash == nil || *u.PasswordHash == "" {
+		s.burnTiming(ctx, password)
+		return nil, errAuthFailed
+	}
+	var salt, version string
+	if u.PasswordSalt != nil {
+		salt = *u.PasswordSalt
+	}
+	if u.HashVersion != nil {
+		version = *u.HashVersion
+	}
+
+	res, err := credential.Check(ctx, password, *u.PasswordHash, salt, version)
+	if err != nil {
+		// 未知 hash_version / 数据污染:拒绝登录(非密码错误,但对外统一文案防枚举;
+		// 根因进日志,运维凭 ErrUnknownVersion 排查)
+		s.burnTiming(ctx, password)
+		return nil, errAuthFailed
+	}
+	if !res.Match {
+		return nil, errAuthFailed
+	}
+
+	// 惰性升级(ADR-016 后半段):命中旧算法 → argon2 重哈希写回;失败则阻断登录(半升级态不放行)
+	if res.NeedsUpgrade {
+		newHash, herr := credential.HashArgon2(password)
+		if herr != nil {
+			return nil, apperrors.WithCause(apperrors.ErrInternal, herr)
+		}
+		if uerr := s.users.UpdatePasswordLazyUpgrade(ctx, u.CanonicalUserID, newHash); uerr != nil {
+			// 升级写回失败:阻断(凭证已改语义但未落库,放行 = 旧凭证仍可登录 = 安全漏洞)
+			return nil, uerr // *AppError(ErrDBError 3001/5002)
+		}
+		// 注:首次登录场景无"该用户既有 token"需撤销(RevokeAll 用于"改密后踢旧端",非首登)。
+	}
+
+	// 取作用域列表(登录第一步展示用;空 = 该用户暂无机构归属,前端提示"联系管理员分配机构")
+	mergs, merr := s.memberships.ListByCanonicalUserID(ctx, u.CanonicalUserID)
+	if merr != nil {
+		return nil, merr // *AppError(ErrDBError)
+	}
+
+	// 发一次性登录票据(Redis;写失败阻断——无票据则无法完成②,不放行半套凭证)
+	ticket, terr := s.newTicket()
+	if terr != nil {
+		return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
+	}
+	if setErr := s.tickets.SetTicket(ctx, ticket, u.CanonicalUserID, s.ticketTTL); setErr != nil {
+		return nil, setErr // *AppError(ErrDBError)
+	}
+
+	return &LoginStep1{
+		LoginTicket: ticket,
+		User:        toUserView(u),
+		Memberships: toMembershipViews(mergs),
+	}, nil
+}
+
+// Issue 两步登录**第二步**:消费一次性票据 → 校验作用域归属 → 取 role → 签 access+refresh。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - loginTicket string:① 返回的一次性票据(32 hex;已消费/过期/不存在 → 拒绝)。
+//   - institutionID int64:选定机构(scope 根;0=平台级超管)。
+//   - campusID int64:选定校区(0=机构级/不限)。
+//
+// 出参:
+//   - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
+//   - error(*AppError):
+//     - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
+//     - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
+//     - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
+//
+// 安全要点:
+//   - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
+//   - 作用域**服务端校验**:(institutionID, campusID) 必须命中该用户的 membership(防客户端挑任意作用域)。
+//   - 用户状态**二次校验**:①→② 之间账号可能被禁用/合并,② 重新取 user 校验 active。
+func (s *AuthService) Issue(ctx context.Context, loginTicket string, institutionID, campusID int64) (*TokenPair, error) {
+	// 原子消费票据(Redis 故障 fail-closed;未命中返回 "")
+	uid, err := s.tickets.GetAndDeleteTicket(ctx, loginTicket)
+	if err != nil {
+		return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
+	}
+	if uid == "" {
+		return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
+	}
+
+	// 二次校验用户状态(①→② 间可能被禁用/合并)
+	u, err := s.users.GetByCanonicalUserID(ctx, uid)
+	if err != nil {
+		if stderrors.Is(err, apperrors.ErrNotFound) {
+			return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
+		}
+		return nil, err
+	}
+	if u.Status != model.UserStatusActive {
+		return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
+	}
+
+	// 作用域归属校验(防越权:客户端不能挑任意 (机构,校区))
+	m, err := s.memberships.GetByUserInstitutionCampus(ctx, uid, institutionID, campusID)
+	if err != nil {
+		if stderrors.Is(err, apperrors.ErrNotFound) {
+			return nil, apperrors.New(apperrors.CodeForbidden, "该机构/校区不属于当前账号")
+		}
+		return nil, err
+	}
+	if m.Status != "active" {
+		return nil, apperrors.New(apperrors.CodeForbidden, "该归属已失效,请重新登录")
+	}
+
+	claims := token.Claims{
+		Sub:           u.CanonicalUserID,
+		Roles:         []string{m.Role},
+		InstitutionID: institutionID,
+		CampusID:      campusID,
+	}
+	access, refresh, err := s.issuer.Issue(ctx, claims)
+	if err != nil {
+		return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
+	}
+	return &TokenPair{
+		AccessToken:   access,
+		RefreshToken:  refresh,
+		ExpiresAt:     time.Now().Add(s.accessTTL).UnixMilli(),
+		User:          toUserView(u),
+		Roles:         []string{m.Role},
+		InstitutionID: institutionID,
+		CampusID:      campusID,
+		Permissions:   []string{}, // RBAC 占位(T6 域补)
+	}, nil
+}
+
+// Me 登录态自查(/auth/me):凭已验签 claims 回读用户 + 当前全部作用域(菜单刷新恢复)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - claims token.Claims:验签中间件解析出的 claims(Sub 必填)。
+//
+// 出参:
+//   - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
+//   - error(*AppError):
+//     - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
+//     - ErrDBError(5002):取用户/成员关系 DB 故障。
+func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) {
+	u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub)
+	if err != nil {
+		return nil, err // ErrNotFound / ErrDBError
+	}
+	mergs, err := s.memberships.ListByCanonicalUserID(ctx, claims.Sub)
+	if err != nil {
+		return nil, err
+	}
+	return &MeResult{
+		UserView:      toUserView(u),
+		Roles:         claims.Roles,
+		InstitutionID: claims.InstitutionID,
+		CampusID:      claims.CampusID,
+		Memberships:   toMembershipViews(mergs),
+	}, nil
+}
+
+// Refresh refresh 轮换:用旧 refresh 换新 access + 新 refresh(旧串立即失效,防重放)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - oldRefresh string:旧 refresh 不透明串(48 hex)。
+//
+// 出参:
+//   - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
+//   - error(*AppError):
+//     - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
+//     - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
+//
+// 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
+// (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
+func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPair, error) {
+	newAccess, newRefresh, err := s.issuer.Rotate(ctx, oldRefresh)
+	if err != nil {
+		if stderrors.Is(err, token.ErrRefreshInvalid) {
+			return nil, apperrors.ErrTokenExpired // 1002:refresh 失效,前端跳登录
+		}
+		return nil, apperrors.WithCause(apperrors.ErrDBError, err)
+	}
+	// 回读新 access 的 claims(Sub/Roles/InstitutionID/CampusID)
+	claims, verr := token.Verify(ctx, s.pubKeys, s.revocation, s.issuerStr, newAccess)
+	if verr != nil {
+		// 理论上刚签发的 access 必可验签;失败属配置异常(公钥/issuer 不一致)→ 5001
+		return nil, apperrors.WithCause(apperrors.ErrInternal, verr)
+	}
+	u, uerr := s.users.GetByCanonicalUserID(ctx, claims.Sub)
+	if uerr != nil {
+		return nil, uerr // ErrNotFound / ErrDBError
+	}
+	return &TokenPair{
+		AccessToken:   newAccess,
+		RefreshToken:  newRefresh,
+		ExpiresAt:     time.Now().Add(s.accessTTL).UnixMilli(),
+		User:          toUserView(u),
+		Roles:         claims.Roles,
+		InstitutionID: claims.InstitutionID,
+		CampusID:      claims.CampusID,
+		Permissions:   []string{},
+	}, nil
+}
+
+// Logout 登出:吊销当前 access jti(黑名单)+ 删除 refresh 白名单键(幂等、尽力而为)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - refreshToken string:当前 refresh 串(删其白名单键,防再换 access)。
+//   - accessJTI string:当前 access 的 jti(拉黑;来自已验签 claims 的 ID)。
+//   - accessRemaining time.Duration:该 access 剩余有效期(黑名单键 TTL;≤0 时存储跳过写——本已过期)。
+//
+// 出参:
+//   - error:**登出对客户端恒为"成功"(本地态必清)**,本方法的错误仅用于服务端告警——
+//     Handler 层对 logout 恒返回 200(对齐前端 doLogout 的幂等语义:后端失败也清本地)。
+//     故调用方(Handler)可忽略 err 直接返回成功;此处保留 error 供日志/告警,不阻断登出。
+//   - 幂等:refresh 键已删/不存在 → DeleteRefreshToken 不报错(天然幂等)。
+func (s *AuthService) Logout(ctx context.Context, refreshToken, accessJTI string, accessRemaining time.Duration) error {
+	// 删 refresh 白名单(尽力而为;格式非法/Redis 故障都不阻断登出——access 30min 后自然过期 + 黑名单兜底)
+	if refreshToken != "" {
+		if err := s.issuer.DeleteRefreshToken(ctx, refreshToken); err != nil {
+			// 记录但不阻断(登出幂等):返回 err 供 Handler 记日志
+			return err
+		}
+	}
+	// 吊销当前 access(尽力而为;jti 为空或 remaining≤0 时存储跳过)
+	if accessJTI != "" {
+		if err := s.issuer.RevokeAll(ctx, accessJTI, accessRemaining); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ---- 私有工具 ----
+
+// normalize 入参归一(P0-02 数据归一规则):email 小写 / phone 去空白 / 其余 trim。
+// 归一在 Service 入参处理(Repository 不做归一,见 identity_repo.go 注释)。
+func (s *AuthService) normalize(identityType, identifier string) string {
+	switch identityType {
+	case model.IdentityTypeEmail:
+		return strings.ToLower(strings.TrimSpace(identifier))
+	default:
+		return strings.TrimSpace(identifier)
+	}
+}
+
+// burnTiming 防枚举 timing floor:对 dummyHash 跑一次 argon2 校验(丢弃结果),
+// 使"账号不存在"路径的 CPU 成本与"存在 + argon2 用户"同量级。
+// ctx 预留(当前 credential.VerifyArgon2 不消费 ctx,传 _ 占位)。
+func (s *AuthService) burnTiming(_ context.Context, password string) {
+	_ = credential.VerifyArgon2(s.dummyHash, password)
+}
+
+// newTicket 生成一次性登录票据(16 字节随机 → 32 hex)。
+// crypto/rand 故障概率≈0;失败返回 error(调用方阻断登录,不放行半套凭证)。
+func (s *AuthService) newTicket() (string, error) {
+	buf := make([]byte, 16)
+	if _, err := rand.Read(buf); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(buf), nil
+}
+
+// toUserView 用户实体 → 对外视图(密码三列 model 已 json:"-",此处再收口取可空字段)。
+func toUserView(u *model.User) *UserView {
+	if u == nil {
+		return nil
+	}
+	v := &UserView{ID: u.CanonicalUserID, PersonType: u.PersonType, Status: u.Status}
+	if u.Username != nil {
+		v.Username = *u.Username
+	}
+	if u.DisplayName != nil {
+		v.RealName = *u.DisplayName
+	}
+	return v
+}
+
+// toMembershipViews 成员关系切片 → 对外视图(空切片保持非 nil,JSON 序列化为 [] 而非 null)。
+func toMembershipViews(list []orgmodel.Membership) []MembershipView {
+	out := make([]MembershipView, 0, len(list))
+	for _, m := range list {
+		out = append(out, MembershipView{
+			MembershipID:  m.MembershipID,
+			InstitutionID: m.InstitutionID,
+			CampusID:      m.CampusID,
+			Role:          m.Role,
+			Status:        m.Status,
+		})
+	}
+	return out
+}
+
+// isValidPhone 手机号基本校验(11 位数字;中文手机号 1 开头,此处仅校验位数+数字,不过度限制)。
+func isValidPhone(p string) bool {
+	if len(p) != 11 {
+		return false
+	}
+	for i := 0; i < len(p); i++ {
+		if p[i] < '0' || p[i] > '9' {
+			return false
+		}
+	}
+	return true
+}

+ 422 - 0
internal/identity/service/auth_service_test.go

@@ -0,0 +1,422 @@
+// AuthService 单测(T4-3 验收):两步登录 + me + refresh + logout + 惰性升级。
+//
+// 用 mock 仓储 + 真 Issuer + miniredis 票据(不连真实 DB/Redis),覆盖 Service 编排逻辑。
+//
+// 断言纪律(响应契约 = code-based):
+//   - Service 返回"自定义文案"的业务错误时用 errors.New(code, customMsg)(如"账号或密码错误"),
+//     与 errors 包的 sentinel(如 ErrUnauth="未登录")**指针不同但 code 相同**;
+//     故对这类断言 **code**(response.Fail 只读 Code/Msg,不靠指针);
+//   - Service 返回"真 sentinel"(ErrNotFound/ErrBadParam/ErrTokenExpired 等)时断言 errors.Is。
+package service
+
+import (
+	"context"
+	"crypto/md5"
+	stderrors "errors"
+	"encoding/hex"
+	"testing"
+
+	"yuansiwei/unified-backend/internal/identity/credential"
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/token"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// codeOf 取业务错误码(*AppError.Code);非 *AppError 或 nil 时 Fatal(测试 bug)。
+// 用于断言"自定义文案错误"的 code(与 sentinel 指针不同但 code 相同)。
+func codeOf(t *testing.T, err error) int {
+	t.Helper()
+	if err == nil {
+		t.Fatal("err 为 nil(期望有业务错误)")
+		return 0
+	}
+	var ae *apperrors.AppError
+	if !stderrors.As(err, &ae) {
+		t.Fatalf("err 非 *AppError:类型 %T,值 %v", err, err)
+		return 0
+	}
+	return ae.Code
+}
+
+// ---- Login(两步第一步)----
+
+// TestLogin_Arion2 命中 argon2 密码:LoginStep1 含 loginTicket + user + memberships。
+func TestLogin_Arion2(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, err := credential.HashArgon2("correct-password")
+	if err != nil {
+		t.Fatalf("HashArgon2: %v", err)
+	}
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "testuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher",
+		Status: "active",
+	})
+
+	res, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "testuser", "correct-password")
+	if err != nil {
+		t.Fatalf("Login(argon2 命中): %v", err)
+	}
+	if res.LoginTicket == "" {
+		t.Error("LoginTicket 应非空")
+	}
+	if res.User == nil || res.User.ID != uid {
+		t.Errorf("User.ID = %v,期望 %q", res.User, uid)
+	}
+	if len(res.Memberships) != 1 {
+		t.Fatalf("Memberships 应有 1 条,实际 %d", len(res.Memberships))
+	}
+	if res.Memberships[0].InstitutionID != 11 {
+		t.Errorf("Membership[0].InstitutionID = %d,期望 11", res.Memberships[0].InstitutionID)
+	}
+}
+
+// TestLogin_WrongPassword 密码错 → errAuthFailed(1001)。
+func TestLogin_WrongPassword(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("correct-password")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "testuser")
+
+	_, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "testuser", "wrong-password")
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("密码错应 ErrUnauth(1001),实际 %v", err)
+	}
+}
+
+// TestLogin_UnknownIdentity 账号不存在 → errAuthFailed(1001),不泄漏"账号不存在"(防枚举)。
+func TestLogin_UnknownIdentity(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	_, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "ghost-user", "any-password")
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("账号不存在应 ErrUnauth(1001)(防枚举统一文案),实际 %v", err)
+	}
+}
+
+// TestLogin_DisabledUser 账号 disabled → errAuthFailed(1001)。
+func TestLogin_DisabledUser(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "disabled")
+	// 改 status 为 disabled(addUser 默认 active,这里手动改)
+	fx.users.byUID[uid].Status = model.UserStatusDisabled
+
+	_, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "disabled", "pw")
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("disabled 用户应 ErrUnauth(1001),实际 %v", err)
+	}
+}
+
+// TestLogin_NoHash 无密码哈希 → errAuthFailed(1001)。
+func TestLogin_NoHash(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	// hash="" 表示无哈希(addUser hashVersion="" → HashVersion=nil)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, "", "", "", model.IdentityTypeUsername, "nohash")
+
+	_, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "nohash", "pw")
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("无密码哈希应 ErrUnauth(1001),实际 %v", err)
+	}
+}
+
+// TestLogin_Md5Salt_LazyUpgrade md5_salt 用户命中 → 惰性升级到 argon2 + 发 ticket。
+func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	// 造 md5("password"+"salt") 的 hex 串
+	raw := md5.Sum([]byte("password" + "salt"))
+	md5Hash := hex.EncodeToString(raw[:])
+
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionMd5Salt, md5Hash, "salt", model.IdentityTypeUsername, "legacy")
+
+	res, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "legacy", "password")
+	if err != nil {
+		t.Fatalf("Login(md5_salt 命中): %v", err)
+	}
+	if res.LoginTicket == "" {
+		t.Error("LazyUpgrade 后应发 loginTicket")
+	}
+
+	// 断言惰性升级已写回:hashVersion 应为 argon2,salt 应为 nil
+	u := fx.users.byUID[uid]
+	if u.HashVersion == nil || *u.HashVersion != model.HashVersionArgon2 {
+		t.Errorf("升级后 hashVersion = %v,期望 argon2", u.HashVersion)
+	}
+	if u.PasswordSalt != nil {
+		t.Errorf("升级后 salt 应置 nil,实际 %q", *u.PasswordSalt)
+	}
+	if u.PasswordHash == nil || *u.PasswordHash == md5Hash {
+		t.Error("升级后 password_hash 应已替换为 argon2 PHC 串(非原 md5 值)")
+	}
+}
+
+// TestLogin_Phone_BadFormat phone 身份 identifier 非 11 位 → ErrBadParam(2001)。
+func TestLogin_Phone_BadFormat(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	_, err := fx.svc.Login(ctx, model.IdentityTypePhone, "12345", "pw")
+	if !stderrors.Is(err, apperrors.ErrBadParam) {
+		t.Fatalf("phone 非 11 位应 ErrBadParam(2001),实际 %v", err)
+	}
+}
+
+// ---- Issue(两步第二步)----
+
+// TestIssue_Happy 消费有效 ticket + 合法作用域 → TokenPair(真 access 可验签)。
+func TestIssue_Happy(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "testuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+
+	// 第一步:Login 拿 ticket
+	step1, err := fx.svc.Login(ctx, model.IdentityTypeUsername, "testuser", "pw")
+	if err != nil {
+		t.Fatalf("Login: %v", err)
+	}
+
+	// 第二步:Issue 签 token
+	pair, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+	if pair.AccessToken == "" {
+		t.Error("AccessToken 应非空")
+	}
+	if pair.RefreshToken == "" {
+		t.Error("RefreshToken 应非空")
+	}
+	if pair.InstitutionID != 11 {
+		t.Errorf("InstitutionID = %d,期望 11", pair.InstitutionID)
+	}
+
+	// 验证 access token 真可验签(RS256)
+	claims, verr := token.Verify(ctx, fx.pubKeys, nil, "https://unified.test", pair.AccessToken)
+	if verr != nil {
+		t.Fatalf("token.Verify(新签 access): %v", verr)
+	}
+	if claims.Sub != uid {
+		t.Errorf("claims.Sub = %q,期望 %q", claims.Sub, uid)
+	}
+	if claims.InstitutionID != 11 {
+		t.Errorf("claims.InstitutionID = %d,期望 11", claims.InstitutionID)
+	}
+}
+
+// TestIssue_InvalidTicket 无效/不存在的 ticket → ErrUnauth(1001)。
+func TestIssue_InvalidTicket(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	_, err := fx.svc.Issue(ctx, "invalid-ticket-hex", 11, 3)
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("无效 ticket 应 ErrUnauth(1001),实际 %v", err)
+	}
+}
+
+// TestIssue_ReplayTicket ticket 已消费(重放)→ ErrUnauth(1001)。
+func TestIssue_ReplayTicket(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "replay")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 22, CampusID: 5, Role: "academic", Status: "active",
+	})
+
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "replay", "pw")
+	// 第一次 Issue(成功)
+	if _, err := fx.svc.Issue(ctx, step1.LoginTicket, 22, 5); err != nil {
+		t.Fatalf("第一次 Issue: %v", err)
+	}
+	// 第二次 Issue(重放 → 票据已 GETDEL 删除 → 拒绝)
+	_, err := fx.svc.Issue(ctx, step1.LoginTicket, 22, 5)
+	if codeOf(t, err) != apperrors.CodeUnauth {
+		t.Fatalf("重放 ticket 应 ErrUnauth(1001),实际 %v", err)
+	}
+}
+
+// TestIssue_ScopeNotBelong 作用域不属于该用户 → ErrForbidden(1003)。
+func TestIssue_ScopeNotBelong(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "scopetest")
+	// 用户只有 instA/campus1 的 membership
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 1, Role: "teacher", Status: "active",
+	})
+
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "scopetest", "pw")
+	// 试图签 instB/campus99(不属于该用户)
+	_, err := fx.svc.Issue(ctx, step1.LoginTicket, 99, 99)
+	if codeOf(t, err) != apperrors.CodeForbidden {
+		t.Fatalf("作用域越权应 ErrForbidden(1003),实际 %v", err)
+	}
+}
+
+// ---- Me ----
+
+// TestMe_Happy 登录态自查:凭 claims 回读 user + memberships。
+func TestMe_Happy(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "meuser")
+	fx.memberships.add(uid,
+		orgmodel.Membership{MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active"},
+		orgmodel.Membership{MembershipID: 2, CanonicalUserID: uid, InstitutionID: 11, CampusID: 5, Role: "finance", Status: "active"},
+	)
+
+	claims := token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}
+	me, err := fx.svc.Me(ctx, claims)
+	if err != nil {
+		t.Fatalf("Me: %v", err)
+	}
+	if me.UserView == nil || me.UserView.ID != uid {
+		t.Errorf("Me.UserView.ID = %v,期望 %q", me.UserView, uid)
+	}
+	if len(me.Memberships) != 2 {
+		t.Errorf("Me.Memberships 应有 2 条,实际 %d", len(me.Memberships))
+	}
+}
+
+// TestMe_UnknownSub claims.Sub 对应用户不存在 → ErrNotFound(3001)。
+func TestMe_UnknownSub(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	claims := token.Claims{Sub: "00000000000000000000000000"}
+	_, err := fx.svc.Me(ctx, claims)
+	if !stderrors.Is(err, apperrors.ErrNotFound) {
+		t.Fatalf("Me(未知 sub) 应 ErrNotFound(3001),实际 %v", err)
+	}
+}
+
+// ---- Refresh ----
+
+// TestRefresh_Happy 用有效 refresh 轮换 → 新 TokenPair。
+func TestRefresh_Happy(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "refreshuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "refreshuser", "pw")
+	pair1, err := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+
+	// 用 pair1.RefreshToken 轮换
+	pair2, err := fx.svc.Refresh(ctx, pair1.RefreshToken)
+	if err != nil {
+		t.Fatalf("Refresh: %v", err)
+	}
+	if pair2.AccessToken == "" {
+		t.Error("Refresh 后 AccessToken 应非空")
+	}
+	if pair2.AccessToken == pair1.AccessToken {
+		t.Error("Refresh 后 access 应换新(jti 不同)")
+	}
+	if pair2.User == nil || pair2.User.ID != uid {
+		t.Errorf("Refresh 后 User.ID = %v,期望 %q", pair2.User, uid)
+	}
+
+	// 旧 refresh 应已失效(轮换后旧 jti 已删)
+	_, err = fx.svc.Refresh(ctx, pair1.RefreshToken)
+	if !stderrors.Is(err, apperrors.ErrTokenExpired) {
+		t.Fatalf("旧 refresh 重放应 ErrTokenExpired(1002),实际 %v", err)
+	}
+}
+
+// TestRefresh_InvalidRefresh 无效 refresh → ErrTokenExpired(1002)。
+func TestRefresh_InvalidRefresh(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	_, err := fx.svc.Refresh(ctx, "not-a-valid-refresh-token")
+	if !stderrors.Is(err, apperrors.ErrTokenExpired) {
+		t.Fatalf("无效 refresh 应 ErrTokenExpired(1002),实际 %v", err)
+	}
+}
+
+// ---- Logout ----
+
+// TestLogout_Happy 登出:删 refresh + 吊销 access(幂等,返回 nil)。
+func TestLogout_Happy(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	argon2Hash, _ := credential.HashArgon2("pw")
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addUser(uid, model.HashVersionArgon2, argon2Hash, "", model.IdentityTypeUsername, "logoutuser")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid,
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+
+	step1, _ := fx.svc.Login(ctx, model.IdentityTypeUsername, "logoutuser", "pw")
+	pair, _ := fx.svc.Issue(ctx, step1.LoginTicket, 11, 3)
+
+	// 登出(传 refresh + 一个 jti + 剩余 TTL)
+	err := fx.svc.Logout(ctx, pair.RefreshToken, "some-jti", 30*60*1e9) // 30min = 1.8e9 ns
+	if err != nil {
+		t.Fatalf("Logout: %v", err)
+	}
+
+	// 登出后旧 refresh 应失效(已删白名单)
+	_, err = fx.svc.Refresh(ctx, pair.RefreshToken)
+	if !stderrors.Is(err, apperrors.ErrTokenExpired) {
+		t.Errorf("Logout 后旧 refresh 应 ErrTokenExpired(1002),实际 %v", err)
+	}
+}
+
+// TestLogout_EmptyArgs 空参登出(幂等,不报错)。
+func TestLogout_EmptyArgs(t *testing.T) {
+	fx := newFixture(t)
+	ctx := context.Background()
+
+	err := fx.svc.Logout(ctx, "", "", 0)
+	if err != nil {
+		t.Fatalf("Logout(空参) 应 nil(幂等),实际 %v", err)
+	}
+}

+ 210 - 0
internal/identity/service/auth_service_test_helpers.go

@@ -0,0 +1,210 @@
+// AuthService 单测公共设施(RSA 密钥生成 + 仓储 mock + 真 Issuer + miniredis 票据)。
+//
+// 设计:Service 依赖倒置(依赖 repository 接口 + token.Issuer + redis.LoginTicketStore),
+// 故单测用 **mock 仓储**(纯内存,不连 DB)+ **真 token.Issuer**(RS256 真签发/验签)+
+// **miniredis 票据存储**(真 Redis 语义,含 GETDEL 原子消费)—— 覆盖 Service 编排逻辑而不碰真实 DB/Redis。
+//
+// RSA 密钥:测试内即时生成 RS2048 私钥(PKCS1 PEM)+ 公钥(PKIX PEM),不依赖 config/dev.json
+// (测试须独立于环境配置;密钥仅内存,不落盘/不入库)。
+package service
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+	"time"
+
+	miniredis "github.com/alicebob/miniredis/v2"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/token"
+	"yuansiwei/unified-backend/internal/infra/config"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	appredis "yuansiwei/unified-backend/internal/infra/redis"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。
+// 出参:(privatePEM, []publicPEM);公钥列表单元素(密钥轮换场景可多元素,测试用单钥)。
+func genRSAKey(t *testing.T) (string, []string) {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("生成 RSA 私钥: %v", err)
+	}
+	privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
+	pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
+	if err != nil {
+		t.Fatalf("序列化公钥: %v", err)
+	}
+	pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
+	return privPEM, []string{pubPEM}
+}
+
+// ---- 仓储 mock(实现 repository 接口,纯内存)----
+
+// mockUsers 用户仓储 mock(按 canonical_user_id 索引;UpdatePasswordLazyUpgrade 原地改指针,
+// 保证"升级后取用户"拿到新值——惰性升级测试依赖此语义)。
+type mockUsers struct {
+	byUID map[string]*model.User
+}
+
+func (m *mockUsers) GetByCanonicalUserID(_ context.Context, uid string) (*model.User, error) {
+	u, ok := m.byUID[uid]
+	if !ok {
+		return nil, apperrors.ErrNotFound
+	}
+	return u, nil
+}
+func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash string) error {
+	u, ok := m.byUID[uid]
+	if !ok {
+		return apperrors.ErrNotFound
+	}
+	u.PasswordHash = &newHash
+	u.PasswordSalt = nil
+	hv := model.HashVersionArgon2
+	u.HashVersion = &hv
+	return nil
+}
+
+// mockIdentities 身份仓储 mock(按 (type, identifier) 索引)。
+type mockIdentities struct {
+	byTypeIdent map[string]map[string]*model.Identity
+}
+
+func newMockIdentities() *mockIdentities {
+	return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}}
+}
+func (m *mockIdentities) add(typ, identifier string, it *model.Identity) {
+	if m.byTypeIdent[typ] == nil {
+		m.byTypeIdent[typ] = map[string]*model.Identity{}
+	}
+	m.byTypeIdent[typ][identifier] = it
+}
+func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) {
+	it, ok := m.byTypeIdent[typ][identifier]
+	if !ok {
+		return nil, apperrors.ErrNotFound
+	}
+	return it, nil
+}
+
+// mockMemberships 成员关系仓储 mock(ListBy + GetBy 两个查询路径,取 created_at 最新对齐 repo 语义)。
+type mockMemberships struct {
+	byUser map[string][]orgmodel.Membership
+}
+func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) {
+	m.byUser[uid] = append(m.byUser[uid], ms...)
+}
+func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) {
+	list := m.byUser[uid]
+	if list == nil {
+		return []orgmodel.Membership{}, nil
+	}
+	return list, nil
+}
+func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) {
+	var best *orgmodel.Membership
+	for i := range m.byUser[uid] {
+		mm := &m.byUser[uid][i]
+		if mm.InstitutionID == inst && mm.CampusID == campus {
+			if best == nil || mm.CreatedAt.After(best.CreatedAt) {
+				best = mm
+			}
+		}
+	}
+	if best == nil {
+		return nil, apperrors.ErrNotFound
+	}
+	return best, nil
+}
+
+// ---- fixture(一个场景的全套依赖)----
+
+// testFixture mock 仓储 + 真 Issuer + miniredis 票据。
+type testFixture struct {
+	t           *testing.T
+	users       *mockUsers
+	identities  *mockIdentities
+	memberships *mockMemberships
+	svc         *AuthService
+	pubKeys     []string
+}
+
+// newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService。
+// 仓储 mock 已建空,调用方按需 addUser/addIdentity/addMembership(见 helper 方法)。
+func newFixture(t *testing.T) *testFixture {
+	t.Helper()
+	mr, err := miniredis.Run()
+	if err != nil {
+		t.Fatalf("miniredis.Run: %v", err)
+	}
+	t.Cleanup(mr.Close)
+
+	// 单一共享 Client:Refresh/Revocation/Ticket 三个 store 都嵌入 *Client,共用一个连接池,
+	// 只需 Close 一次(避免多 client 的 Close 顺序问题)。
+	rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
+	if err != nil {
+		t.Fatalf("NewClient: %v", err)
+	}
+	t.Cleanup(func() { _ = rc.Close() })
+
+	privPEM, pubKeys := genRSAKey(t)
+	refreshStore := appredis.NewRefreshStore(rc)
+	revocation := appredis.NewRevocationStore(rc, "revoked")
+	tickets := appredis.NewLoginTicketStore(rc)
+
+	issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
+	if err != nil {
+		t.Fatalf("NewIssuer: %v", err)
+	}
+	users := &mockUsers{byUID: map[string]*model.User{}}
+	ids := newMockIdentities()
+	mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}}
+	svc, err := NewAuthService(Config{
+		Users:          users,
+		Identities:     ids,
+		Memberships:    mergs,
+		Issuer:         issuer,
+		Tickets:        tickets,
+		AccessTTL:      30 * time.Minute,
+		PublicKeys:     pubKeys,
+		IssuerStr:      "https://unified.test",
+		Revocation:     revocation,
+		LoginTicketTTL: 5 * time.Minute,
+	})
+	if err != nil {
+		t.Fatalf("NewAuthService: %v", err)
+	}
+	return &testFixture{t: t, users: users, identities: ids, memberships: mergs, svc: svc, pubKeys: pubKeys}
+}
+
+// addUser 造一个用户 + 可选身份 + 可选 membership(测试夹具快捷构造)。
+// 入参:uid ULID;hashVersion 密码版本;hash/salt 密码三列;typ/identifier 登录身份(可为空则不建)。
+func (fx *testFixture) addUser(uid, hashVersion, hash, salt, typ, identifier string) {
+	fx.t.Helper()
+	u := &model.User{
+		CanonicalUserID: uid,
+		PersonType:      model.PersonTypeStaff,
+		Status:          model.UserStatusActive,
+	}
+	if hashVersion != "" {
+		u.HashVersion = &hashVersion
+	}
+	if hash != "" {
+		u.PasswordHash = &hash
+	}
+	if salt != "" {
+		u.PasswordSalt = &salt
+	}
+	fx.users.byUID[uid] = u
+	if typ != "" && identifier != "" {
+		fx.identities.add(typ, identifier, &model.Identity{
+			CanonicalUserID: uid, Type: typ, Identifier: identifier,
+		})
+	}
+}

+ 47 - 13
internal/identity/token/token.go

@@ -18,7 +18,7 @@
 //
 //	// 登录成功后签发一对凭证
 //	access, refresh, err := iss.Issue(ctx, token.Claims{
-//	    Sub: canonicalUID, UserID: uid, Roles: []string{"2"},
+//	    Sub: canonicalUID, Roles: []string{"student"},
 //	    InstitutionID: 11, CampusID: 3,
 //	})
 //	// 响应 {accessToken: access, refreshToken: refresh, user: ...}
@@ -88,16 +88,20 @@ type RevocationStore interface {
 // Claims access token 的业务声明(嵌入 jwt.RegisteredClaims)。
 //
 // 字段(与 ADR-014 契约对齐):
-//   - Sub:canonical_user_id(ULID,26 字符;**禁止**用旧 int user_id 作 sub)。
-//   - UserID:内部 int 用户 ID(兼容过渡期旧表外键查询;新表一律 Sub)。
-//   - Roles:角色码数组(7 角色字典:"1"超管/"2"教务/"3"财务/"4"老师/"5"学生/"6"家长/"8"市场;
-//     市场登录禁用,正常不会出现 "9")。
-//   - InstitutionID / CampusID:多租户作用域(Repository scoping 依据,scope.FromCtx 来源)。
+//   - Sub:canonical_user_id(ULID,26 字符;**禁止**用旧 int user_id 作 sub,ADR-013/D1)。
+//   - Roles:角色码数组(**字符串码**,T4 契约 2026-11-06 拍板,与 T1 DDL/T2 model 对齐——
+//     super_admin/academic/finance/teacher/student/parent/market;市场登录禁用,正常不出现 market)。
+//     弃用旧注释的数字码("1"~"8"):数字码是旧 PHP 系统的遗留,新后端统一字符串码,
+//     PC 前端 LoginResult.roles 须同步由 number[] 改为 string[]。
+//   - InstitutionID / CampusID:多租户作用域(Repository scoping 依据,scope.FromFiber 来源;
+//     0 表示平台级/不限校区,超管视角)。
 //   - JTI:走嵌入的 RegisteredClaims.ID(JSON 名 "jti")——吊销/轮换锚点;
 //     **不要**再自定义 jti 字段(浅层字段会遮蔽 RegisteredClaims.ID,导致解析侧 ID 恒空)。
+//
+// 注:原"UserID int64 内部 ID 兼容过渡期"字段已**移除**——ADR-013 拍板"旧 int 只存 t_legacy_mapping,
+// 新表外键一律 ULID",token 里保留 int 会诱导下游误用旧 int;刷新载荷 RefreshPayload 同步移除。
 type Claims struct {
 	Sub           string   `json:"sub"`
-	UserID        int64    `json:"uid"`
 	Roles         []string `json:"roles"`
 	InstitutionID int64    `json:"institution_id"`
 	CampusID      int64    `json:"campus_id"`
@@ -108,11 +112,12 @@ type Claims struct {
 // 业务载荷存 Redis——refresh 泄露时服务端可即时删除,JWT 做不到)。
 //
 // 字段:
-//   - Sub / UserID / Roles / InstitutionID / CampusID:与 access 同源(刷新时原样带入新 access)。
+//   - Sub / Roles / InstitutionID / CampusID:与 access 同源(刷新时原样带入新 access)。
 //   - CreatedAt time.Time:签发时刻(审计 + 防"未来 refresh")。
+//
+// 注:原 UserID int64 字段已移除(同 Claims 说明——新后端一律 ULID,int 只存 legacy_mapping)。
 type RefreshPayload struct {
 	Sub           string    `json:"sub"`
-	UserID        int64     `json:"uid"`
 	Roles         []string  `json:"roles"`
 	InstitutionID int64     `json:"institution_id"`
 	CampusID      int64     `json:"campus_id"`
@@ -179,8 +184,8 @@ func NewIssuer(privateKeyPEM, issuer string, accessTTL, refreshTTL time.Duration
 //
 // 样例:
 //
-//	access, refresh, err := iss.Issue(ctx, token.Claims{Sub: uid, UserID: 123,
-//	    Roles: []string{"4"}, InstitutionID: 11, CampusID: 3})
+//	access, refresh, err := iss.Issue(ctx, token.Claims{Sub: uid,
+//	    Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3})
 func (iss *Issuer) Issue(ctx context.Context, claims Claims) (accessToken, refreshToken string, err error) {
 	if claims.Sub == "" {
 		return "", "", errors.New("token: Issue 要求 claims.Sub(canonical_user_id)非空")
@@ -205,7 +210,7 @@ func (iss *Issuer) Issue(ctx context.Context, claims Claims) (accessToken, refre
 	}
 	rjti := hex.EncodeToString(buf[:8])
 	payload, _ := jsonMarshal(RefreshPayload{
-		Sub: claims.Sub, UserID: claims.UserID, Roles: claims.Roles,
+		Sub: claims.Sub, Roles: claims.Roles,
 		InstitutionID: claims.InstitutionID, CampusID: claims.CampusID,
 		CreatedAt: now,
 	})
@@ -319,7 +324,7 @@ func (iss *Issuer) Rotate(ctx context.Context, oldRefresh string) (string, strin
 		return "", "", fmt.Errorf("token: 删除旧 refresh 失败: %w", err) // 删除失败不签发(宁可让用户重登,不放重放窗口)
 	}
 	access, refresh, err := iss.Issue(ctx, Claims{
-		Sub: rp.Sub, UserID: rp.UserID, Roles: rp.Roles,
+		Sub: rp.Sub, Roles: rp.Roles,
 		InstitutionID: rp.InstitutionID, CampusID: rp.CampusID,
 	})
 	if err != nil {
@@ -354,6 +359,35 @@ func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration)
 	return iss.revocation.AddRevoked(ctx, jti, ttl)
 }
 
+// DeleteRefreshToken 删除指定 refresh token 的白名单键(登出/撤销 refresh 链路的入口,
+// 架构规格 §7.4 登出链路——让该 refresh 串**立即**失效,前端跳登录后该串再也换不出新 access)。
+//
+// 入参:
+//   - ctx        context.Context:请求上下文(透传给 Redis 删除)。
+//   - oldRefresh string:待失效的 refresh 不透明串(48 字符 hex,Issue 返回的原样值)。
+//
+// 出参:
+//   - error:以下场景非 nil:
+//     - 串格式非法(非 48 hex)→ 包装错误(调用方传错,单测覆盖);
+//     - refreshStore 删除失败(Redis 不可用)→ 包装 ErrDBError 类错误(调用方按"登出降级"处理:
+//       本地态必清,refresh 失效可延后——access 30min 后自然过期,且已吊销的 jti 黑名单兜底)。
+//
+// 幂等语义:refresh 键不存在(已轮换/已登出/已过期)时 DeleteRefresh 天然幂等(见 RefreshStore 接口),
+// 本方法不区分"删了"与"本来就没有",统一返回 nil——登出接口按幂等处理,前端无需关心。
+//
+// 与 Rotate 的关系:Rotate 是"换新"(删旧+发新),本方法是"只删不发"(登出场景不需要新凭证)。
+func (iss *Issuer) DeleteRefreshToken(ctx context.Context, oldRefresh string) error {
+	raw, err := hex.DecodeString(oldRefresh)
+	if err != nil || len(raw) != 32 {
+		return errors.New("token: 非法的 refresh token 格式(须为 48 字符 hex)")
+	}
+	rjti := hex.EncodeToString(raw[:8]) // 与 Rotate 一致的 jti 因子提取
+	if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {
+		return fmt.Errorf("token: 删除 refresh 白名单失败: %w", err)
+	}
+	return nil
+}
+
 // ---- 私有工具 ----
 
 // jtiFromClaims 从解析后的 claims 中取 jti(兼容 string / float64 两种 JSON 解码形态;

+ 94 - 0
internal/infra/redis/login_ticket_store.go

@@ -0,0 +1,94 @@
+// 登录票据存储(两步登录①→②衔接,Redis 一次性凭据)。
+//
+// 键空间:loginticket:{ticket} → canonical_user_id(ULID,26 字符)。
+// TTL 由调用方传入(Service 层固定 5min,给前端②留够操作窗口)。
+//
+// 安全设计(一次性 + 原子消费):
+//   - ② 消费用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)——
+//     同一 ticket 并发双请求时,只有一个能取到值,另一个取到 nil → 拒绝。
+//     若用 GET + DEL 两步,存在 TOCTOU 竞态(两次 GET 都成功、两次都签 token = 重放漏洞)。
+//   - ① 签发后 ticket 即写入 Redis,**不在 JWT/响应体里保留**(响应体只回 ticket 字符串本身);
+//   - TTL 过期后键自动删除,未②的 ticket 自然失效(无需手动清理)。
+//
+// 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。
+// 消费方(identity/service AuthService)直接 *LoginTicketStore 持有,不经过 token 包。
+package redis
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	gredis "github.com/redis/go-redis/v9"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// loginTicketKeyPrefix 键前缀(硬编码,与 refresh 白名单 "refresh:" 对称设计——
+// 登录票据前缀固定,运维不需差异化命名;与吊销黑名单前缀走配置的差异见 revocation_store.go 注释)。
+const loginTicketKeyPrefix = "loginticket:"
+
+// LoginTicketStore 登录票据存储(*Client 直接实现,装配时构造)。
+type LoginTicketStore struct {
+	*Client
+}
+
+// NewLoginTicketStore 构造登录票据存储。
+//
+// 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
+// 出参:*LoginTicketStore:就绪存储。
+func NewLoginTicketStore(rc *Client) *LoginTicketStore {
+	return &LoginTicketStore{Client: rc}
+}
+
+// ticketKey 拼键:loginticket:{ticket}。
+// ticket 为 32 字符 hex(16 字节随机),不含 ":",无需转义。
+func (s *LoginTicketStore) ticketKey(ticket string) string {
+	return loginTicketKeyPrefix + ticket
+}
+
+// SetTicket 写入登录票据(两步登录①验账密成功后调用)。
+//
+// 入参:
+//   - ctx             context.Context:请求上下文。
+//   - ticket          string:一次性票据(32 字符 hex,调用方生成)。
+//   - canonicalUserID string:票据对应的用户 ULID(② 消费后据此取 user + membership)。
+//   - ttl             time.Duration:票据有效期(建议 5min;过期后键自动删除,未②即失效)。
+//
+// 出参:
+//   - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
+//     调用方(AuthService.Login)必须阻断①响应(票据写失败 = 无法完成②,返回"系统繁忙")。
+func (s *LoginTicketStore) SetTicket(ctx context.Context, ticket, canonicalUserID string, ttl time.Duration) error {
+	if err := s.Client.Set(ctx, s.ticketKey(ticket), canonicalUserID, ttl).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写登录票据 ticket=%s 失败: %w", ticket, err))
+	}
+	return nil
+}
+
+// GetAndDeleteTicket 原子消费登录票据(两步登录②签 token 前调用)。
+//
+// 入参:
+//   - ctx    context.Context:请求上下文。
+//   - ticket string:① 返回的一次性票据。
+//
+// 出参:
+//   - string:canonical_user_id(ULID);票据不存在/已消费/已过期时返回 ""。
+//   - error:
+//     - nil:Redis 查询成功(无论命中与否——命中返回 ULID,未命中返回 "");
+//     - 非 nil:Redis 故障(连接失败/超时)——**调用方(AuthService.Issue)必须阻断**,
+//       把本次请求按"票据失效"处理(安全红线:票据消费故障不可降级为"无票据"放行)。
+//
+// 实现说明:用 **GETDEL**(Redis 6.2+ 单命令原子 GET+DEL)而非 GET+DEL 两步——
+// 并发安全:同一 ticket 双请求并发时,只有一个能取到值,另一个取到 nil(键已删)→ 拒绝。
+// go-redis v9 的 GetDel 返回 *stringCmd,Nil 语义:键不存在时 .Result() 返回 ("",gredis.Nil)。
+func (s *LoginTicketStore) GetAndDeleteTicket(ctx context.Context, ticket string) (string, error) {
+	v, err := s.Client.GetDel(ctx, s.ticketKey(ticket)).Result()
+	switch {
+	case err == nil:
+		return v, nil // 命中:返回 ULID(键已被 GETDEL 原子删除)
+	case err == gredis.Nil:
+		return "", nil // 未命中:票据不存在/已消费/已过期(② 应报"票据已失效,请重新登录")
+	default:
+		return "", errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 消费登录票据 ticket=%s 失败: %w", ticket, err))
+	}
+}

+ 81 - 0
internal/infra/redis/store_test.go

@@ -234,3 +234,84 @@ func TestRevocationStore_DefaultPrefix(t *testing.T) {
 		t.Fatal("空前缀应回落默认前缀 revoked,键 revoked:jti-dp 应存在")
 	}
 }
+
+// ---- LoginTicketStore ----
+
+// TestLoginTicketStore_RoundTrip 写-消费往返:SetTicket 后 GetAndDeleteTicket 应取回 ULID 且键已删(一次性)。
+func TestLoginTicketStore_RoundTrip(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	lt := NewLoginTicketStore(rc)
+	ctx := context.Background()
+
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	if err := lt.SetTicket(ctx, "tk-rt", uid, time.Minute); err != nil {
+		t.Fatalf("SetTicket: %v", err)
+	}
+	// 第一次消费:取到 ULID
+	got, err := lt.GetAndDeleteTicket(ctx, "tk-rt")
+	if err != nil {
+		t.Fatalf("GetAndDeleteTicket: %v", err)
+	}
+	if got != uid {
+		t.Fatalf("往返 ULID 不符:期望 %q,实际 %q", uid, got)
+	}
+	// 键应已被 GETDEL 原子删除(一次性):再消费取回 ""
+	again, err := lt.GetAndDeleteTicket(ctx, "tk-rt")
+	if err != nil {
+		t.Fatalf("二次消费不应报错(未命中非故障): %v", err)
+	}
+	if again != "" {
+		t.Fatalf("一次性票据二次消费应返回空串(已消费),实际 %q(= 重放漏洞)", again)
+	}
+}
+
+// TestLoginTicketStore_Miss 未签发的票据:GetAndDeleteTicket 返回 ("", nil)(非故障)。
+func TestLoginTicketStore_Miss(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	lt := NewLoginTicketStore(rc)
+	ctx := context.Background()
+
+	got, err := lt.GetAndDeleteTicket(ctx, "tk-never")
+	if err != nil {
+		t.Fatalf("未命中时不应报错: %v", err)
+	}
+	if got != "" {
+		t.Fatalf("未签发的票据应返回空串,实际 %q", got)
+	}
+}
+
+// TestLoginTicketStore_TTL TTL 生效:写入后 FastForward 超过 TTL,消费应返回 ""(键过期自动删)。
+func TestLoginTicketStore_TTL(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	lt := NewLoginTicketStore(rc)
+	ctx := context.Background()
+
+	if err := lt.SetTicket(ctx, "tk-ttl", "01GAQF0Z6C1V9Y3E5H7K2M8B4T", 5*time.Second); err != nil {
+		t.Fatalf("SetTicket: %v", err)
+	}
+	mr.FastForward(6 * time.Second) // 超过 TTL:键过期
+	got, err := lt.GetAndDeleteTicket(ctx, "tk-ttl")
+	if err != nil {
+		t.Fatalf("GetAndDeleteTicket: %v", err)
+	}
+	if got != "" {
+		t.Fatalf("TTL 过期后票据应失效(返回空串),实际 %q", got)
+	}
+}
+
+// TestLoginTicketStore_FailClosed 关键安全语义:Redis 故障时 GetAndDeleteTicket 必须返回 error,
+// 不可降级为"无票据"(= 放行任意用户签 token,安全红线)。
+func TestLoginTicketStore_FailClosed(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	lt := NewLoginTicketStore(rc)
+	ctx := context.Background()
+
+	mr.Close() // 模拟连接故障
+	if _, err := lt.GetAndDeleteTicket(ctx, "tk-fc"); err == nil {
+		t.Fatal("Redis 故障后 GetAndDeleteTicket 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
+	}
+}