فهرست منبع

feat(identity): T4-4 /auth/* 端点 handler(两步登录 + me/refresh/logout + 微信占位)

- identity/handler: Handler(依赖 svc + 可选 zap logger,New 装配 fail-fast)
  · POST /auth/login   两步① 验账密 → loginTicket + user + memberships(公开)
  · POST /auth/issue   两步② 消费票据 + 选作用域 → accessToken/refreshToken(公开,凭票据)
  · POST /auth/refresh 旧 refresh 换新对(公开,凭 refresh)
  · GET  /auth/me      登录态自查(受保护,读中间件写入的 claims)
  · POST /auth/logout  登出(受保护,**恒 200 幂等**:撤销尽力而为失败仅记日志)
  · GET  /auth/wechat/qrcode|status 微信扫码占位(code 5001 暂未开放,后端未就绪)
- 入参 DTO 全 camelCase(username/password/loginTicket/institutionId/campusId/refreshToken),
  validator 标签:login username required,max=64 + password required,min=6,max=128;
  issue loginTicket required,len=32;refresh refreshToken required,len=64(见下)
- claimsFromCtx 读 c.Locals(ClaimsCtxKey)(*token.Claims),由 T4-5 验签中间件写入;
  ClaimsCtxKey 导出常量与 infra/scope.CtxKey 同一纪律(两端用同一键)
- **修复 latent bug(refresh token 长度)**:真实 refresh = 32 字节随机 → hex 恒 **64** 字符,
  但 RefreshReq 校验误写 len=48(与 token.go 注释"48 字符 hex"一致地错)→ 合法 token 被拒 2001。
  改 validate len=64 + 更正 token.go(5 处)/ service.go(1 处)注释与 DeleteRefreshToken 报错文案
- 单测 22 例全绿(mock 仓储 + 真 Issuer + miniredis,fiber app.Test 端到端,断 body.code):
  Login 7(成功/密码错/未知账号/禁用/md5惰性升级/缺参/坏JSON)+ Issue 6(成功/无效票据/
  重放/越权/归属失效/缺参)+ Me 2 + Refresh 3 + Logout 2 + 微信 2
Q 3 روز پیش
والد
کامیت
2404d5c493

+ 239 - 0
internal/identity/handler/handler.go

@@ -0,0 +1,239 @@
+// Package handler 身份域 HTTP 接入层(/auth/* 端点,架构规格 §3.2 三层之"Handler")。
+//
+// 分层职责:
+//   - 本包(Handler):把请求体绑定/校验(validator)→ 调 service.AuthService(纯业务)→
+//     把 *AppError 映射为统一响应信封(response.Fail / OK)。**不写业务逻辑、不直接碰 DB/Redis**。
+//   - Service(identity/service):两步登录 / me / refresh / logout 编排(见其包注释)。
+//   - 验签中间件(identity/handler 的 auth.go,T4-5):受保护路由前置,验签 + 作用域注入;
+//     本包 Me/Logout 从 c.Locals 读取中间件写入的已验签 claims(见 ClaimsCtxKey)。
+//
+// 端点清单(路由挂载在 T4-6 main 完成;此处只定义方法):
+//   - POST /auth/login    两步登录①:验账密 → 一次性票据 + 用户 + 作用域列表(**公开**,无鉴权)
+//   - POST /auth/issue    两步登录②:消费票据 + 选作用域 → access+refresh(**公开**,凭票据)
+//   - POST /auth/refresh  refresh 轮换:旧 refresh 换新对(**公开**,凭 refresh)
+//   - GET  /auth/wechat/qrcode、GET /auth/wechat/status  微信扫码占位(暂未开放)
+//   - GET  /auth/me       登录态自查(**受保护**,需 Auth 中间件)
+//   - POST /auth/logout   登出(**受保护**,恒 200 幂等;撤销尽力而为)
+//
+// 契约要点:
+//   - HTTP 恒 200,真实状态在 body code(铁律 6;见 infra/response)。
+//   - 校验失败由 validator 内部写 code=2001 并返回 false,Handler `return nil` 终止(铁律 7)。
+//   - logout 对客户端**恒 200**(本地态必清):撤销失败只记日志,不阻断(对齐前端 doLogout 幂等语义)。
+package handler
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/service"
+	"yuansiwei/unified-backend/internal/identity/token"
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+	"yuansiwei/unified-backend/internal/infra/validator"
+)
+
+// ClaimsCtxKey 已验签 claims 在 fiber.Ctx 局部值(c.Locals)中的键。
+//
+// 由 T4-5 的 Auth 验签中间件在验签成功后写入(*token.Claims);本包 Me/Logout
+// 经 claimsFromCtx 读回。**中间件写入与 Handler 读取两端用同一常量**,避免字符串散落
+// (对齐 infra/scope 的 CtxKey 纪律)。受保护路由若无此值 = 中间件链配置错误,按未登录拒绝。
+const ClaimsCtxKey = "authClaims"
+
+// Handler 身份域 HTTP 处理器(启动装配一次,全进程共享)。
+//
+// 字段(装配入口 New 注入,均必填):
+//   - svc:身份域业务服务(两步登录 / me / refresh / logout 编排,T4-3)。
+//   - log:结构化日志器(logout 撤销失败的告警出口;nil 时静默不记,不阻断)。
+type Handler struct {
+	svc *service.AuthService
+	log *zap.Logger
+}
+
+// Config Handler 装配参数(T4-6 main 按此构造)。
+type Config struct {
+	// Svc 身份域业务服务(必填)。
+	Svc *service.AuthService
+	// Log 结构化日志器(可选;nil 时登出撤销失败不记日志,仍恒 200)。
+	Log *zap.Logger
+}
+
+// New 构造身份域 HTTP 处理器。
+//
+// 入参:cfg Config:见 Config 字段说明。
+// 出参:
+//   - *Handler:就绪处理器。
+//   - error:必填依赖缺失(Svc 为 nil,fail-fast——无业务服务不允许挂载端点)。
+func New(cfg Config) (*Handler, error) {
+	if cfg.Svc == nil {
+		return nil, apperrors.New(apperrors.CodeInternal, "handler: AuthService 未注入")
+	}
+	return &Handler{svc: cfg.Svc, log: cfg.Log}, nil
+}
+
+// ---- 请求体 DTO(入参;camelCase 与前端契约严格对齐,见 README 命名规范)----
+
+// LoginReq 两步登录① 请求体(PC 账号密码登录)。
+//
+// 字段:
+//   - Username:登录账号(用户名;归一/校验在 Service,此处仅校验非空与长度上限)。
+//   - Password:明文密码(6~128 位;**仅内存调用栈,禁止进日志/响应**)。
+type LoginReq struct {
+	Username string `json:"username" validate:"required,max=64"`
+	Password string `json:"password" validate:"required,min=6,max=128"`
+}
+
+// IssueReq 两步登录② 请求体(选定作用域后换取 token)。
+//
+// 字段:
+//   - LoginTicket:① 返回的一次性票据(32 字符 hex;已消费/过期/不存在 → 拒绝)。
+//   - InstitutionID:选定机构(scope 根;0=平台级超管,故不标 required)。
+//   - CampusID:选定校区(0=机构级/不限,故不标 required)。
+type IssueReq struct {
+	LoginTicket   string `json:"loginTicket" validate:"required,len=32"`
+	InstitutionID int64  `json:"institutionId"`
+	CampusID      int64  `json:"campusId"`
+}
+
+// RefreshReq refresh 轮换请求体。
+type RefreshReq struct {
+	// RefreshToken:旧 refresh 不透明串(64 字符 hex = 32 字节随机,Issue 返回的原样值;
+	// 见 token.Issue——hex.EncodeToString(32 字节) 恒 64 位)。
+	RefreshToken string `json:"refreshToken" validate:"required,len=64"`
+}
+
+// LogoutReq 登出请求体(refresh 可空——仅凭 access jti 也能拉黑当前 access)。
+type LogoutReq struct {
+	// RefreshToken:当前 refresh 串(删其白名单键;空则跳过,仅吊销 access)。
+	RefreshToken string `json:"refreshToken"`
+}
+
+// ---- 端点 ----
+
+// Login 两步登录**第一步**(POST /auth/login,公开)。
+//
+// 出参(成功):LoginStep1 → {loginTicket, user, memberships[]}(**不签 token**)。
+// 出参(失败):code 1001(统一文案防枚举)/ 2001(参数)/ 5002(DB·票据写失败)。
+func (h *Handler) Login(c *fiber.Ctx) error {
+	var req LoginReq
+	if !validator.Validate(c, &req) { // 校验失败:body code=2001 已写出
+		return nil
+	}
+	res, err := h.svc.Login(c.Context(), model.IdentityTypeUsername, req.Username, req.Password)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// Issue 两步登录**第二步**(POST /auth/issue,公开,凭一次性票据)。
+//
+// 出参(成功):TokenPair → {accessToken, refreshToken, expiresAt, user, roles, institutionId, campusId}。
+// 出参(失败):code 1001(票据失效)/ 1003(作用域越权/归属失效)/ 5002(签发写白名单失败)。
+func (h *Handler) Issue(c *fiber.Ctx) error {
+	var req IssueReq
+	if !validator.Validate(c, &req) {
+		return nil
+	}
+	res, err := h.svc.Issue(c.Context(), req.LoginTicket, req.InstitutionID, req.CampusID)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// Me 登录态自查(GET /auth/me,**受保护**——需 T4-5 Auth 中间件前置验签并写入 claims)。
+//
+// 出参(成功):MeResult → {user 字段 + roles, institutionId, campusId, memberships[]}(菜单刷新恢复)。
+// 出参(失败):code 1001(中间件未注入 claims = 未登录)/ 3001(用户不存在)/ 5002(DB 故障)。
+func (h *Handler) Me(c *fiber.Ctx) error {
+	claims := claimsFromCtx(c)
+	if claims == nil { // 公开路由误配或中间件未执行:按未登录拒绝
+		return response.Fail(c, apperrors.ErrUnauth)
+	}
+	res, err := h.svc.Me(c.Context(), *claims)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// Refresh refresh 轮换(POST /auth/refresh,公开,凭旧 refresh)。
+//
+// 出参(成功):TokenPair(新 access + 新 refresh;旧 refresh 立即失效)。
+// 出参(失败):code 1002(refresh 失效,前端跳重新登录)/ 5002(轮换写白名单/回读用户 DB 故障)。
+func (h *Handler) Refresh(c *fiber.Ctx) error {
+	var req RefreshReq
+	if !validator.Validate(c, &req) {
+		return nil
+	}
+	res, err := h.svc.Refresh(c.Context(), req.RefreshToken)
+	if err != nil {
+		return response.Fail(c, err)
+	}
+	return response.OK(c, res)
+}
+
+// Logout 登出(POST /auth/logout,**受保护**,对客户端**恒 200** 幂等)。
+//
+// 语义:撤销"尽力而为"——删 refresh 白名单 + 拉黑当前 access jti;任一步失败仅记告警日志,
+// 仍返回成功(前端本地态必清;access 30min 自然过期 + 黑名单兜底)。
+//
+// 出参(恒成功):code 200,data 省略(无数据体)。
+func (h *Handler) Logout(c *fiber.Ctx) error {
+	var req LogoutReq
+	// 尽力解析(refresh 可空;body 非 JSON 也不阻断——登出幂等,本地态必清)
+	_ = json.Unmarshal(c.Body(), &req)
+
+	claims := claimsFromCtx(c)
+	if claims == nil {
+		// 中间件未注入 claims(理论不该发生——logout 在受保护路由):仍恒 200,仅记日志
+		if h.log != nil {
+			h.log.Warn("logout: 无已验签 claims(中间件未注入),仅清本地态")
+		}
+		return response.OK(c, nil)
+	}
+	remaining := time.Duration(0)
+	if claims.ExpiresAt != nil {
+		remaining = time.Until(claims.ExpiresAt.Time) // 黑名单键 TTL = access 剩余有效期
+	}
+	if err := h.svc.Logout(c.Context(), req.RefreshToken, claims.ID, remaining); err != nil {
+		// 撤销失败不阻断登出(对齐前端 doLogout 幂等语义):记告警供排障
+		if h.log != nil {
+			h.log.Warn("logout 撤销失败(已忽略,前端本地态必清)",
+				zap.String("jti", claims.ID), zap.Error(err))
+		}
+	}
+	return response.OK(c, nil)
+}
+
+// WechatQrcode 微信扫码登录——获取二维码(GET /auth/wechat/qrcode,占位)。
+//
+// 现状:**后端微信扫码端点未就绪**(per-campus 授权链路待实现),返回"暂未开放"占位,
+// PC 前端微信 tab 据此显示"暂未开放"。真实实现落地后替换本方法(见 PC 前端契约)。
+func (h *Handler) WechatQrcode(c *fiber.Ctx) error {
+	return response.Fail(c, apperrors.New(apperrors.CodeInternal, "微信扫码登录暂未开放"))
+}
+
+// WechatStatus 微信扫码登录——轮询扫码状态(GET /auth/wechat/status?state=xxx,占位)。
+//
+// 现状:同 WechatQrcode,**后端未就绪**,返回"暂未开放"占位。
+func (h *Handler) WechatStatus(c *fiber.Ctx) error {
+	return response.Fail(c, apperrors.New(apperrors.CodeInternal, "微信扫码登录暂未开放"))
+}
+
+// claimsFromCtx 从 fiber.Ctx 局部值读取已验签 claims(T4-5 中间件写入 *token.Claims)。
+//
+// 出参:*token.Claims;未写入或类型不符时返回 nil(调用方按未登录拒绝)。
+func claimsFromCtx(c *fiber.Ctx) *token.Claims {
+	if c == nil {
+		return nil
+	}
+	if cl, ok := c.Locals(ClaimsCtxKey).(*token.Claims); ok {
+		return cl
+	}
+	return nil
+}

+ 732 - 0
internal/identity/handler/handler_test.go

@@ -0,0 +1,732 @@
+// Handler 单测(T4-4 验收):/auth/* 端点端到端(mock 仓储 + 真 Issuer + miniredis,不连真实 DB/Redis)。
+//
+// 覆盖:
+//   - 入参校验(validator)→ code 2001 / 2002
+//   - 两步登录①② → code 200(含 loginTicket / accessToken)
+//   - 防枚举统一文案 → code 1001(未知账号 / 密码错 / 禁用)
+//   - 票据失效 / 重放 / 作用域越权 → code 1001 / 1003
+//   - Me(受保护,读中间件写入的 claims)/ Logout(恒 200 幂等)/ Refresh
+//   - 微信占位 → code 5001(暂未开放)
+//
+// 断言纪律(响应契约 = code-based,HTTP 恒 200):只断 body.code(铁律 6),
+// 不依赖 HTTP 状态码(恒 200)。
+package handler
+
+import (
+	"context"
+	"crypto/md5"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	miniredis "github.com/alicebob/miniredis/v2"
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/identity/credential"
+	"yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/identity/repository"
+	"yuansiwei/unified-backend/internal/identity/service"
+	"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"
+)
+
+// ---- 仓储 mock(实现 repository 接口,纯内存;与 service 单测同源逻辑)----
+
+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
+}
+
+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
+}
+
+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
+}
+
+// 编译期断言:mock 满足 repository 接口(签名漂移即编译失败)。
+var (
+	_ repository.UserRepository       = (*mockUsers)(nil)
+	_ repository.IdentityRepository   = (*mockIdentities)(nil)
+	_ repository.MembershipRepository = (*mockMemberships)(nil)
+)
+
+// ---- fixture ----
+
+type fixture struct {
+	t           *testing.T
+	users       *mockUsers
+	identities  *mockIdentities
+	memberships *mockMemberships
+	handler     *Handler
+	pubKeys     []string
+	issuer      *token.Issuer
+}
+
+// newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService + Handler。
+func newFixture(t *testing.T) *fixture {
+	t.Helper()
+	mr, err := miniredis.Run()
+	if err != nil {
+		t.Fatalf("miniredis.Run: %v", err)
+	}
+	t.Cleanup(mr.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 := service.NewAuthService(service.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)
+	}
+	h, err := New(Config{Svc: svc})
+	if err != nil {
+		t.Fatalf("New Handler: %v", err)
+	}
+	return &fixture{t: t, users: users, identities: ids, memberships: mergs, handler: h, pubKeys: pubKeys, issuer: issuer}
+}
+
+// genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。
+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}
+}
+
+// addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。
+func (fx *fixture) addArgon2User(uid, username, password string) {
+	fx.t.Helper()
+	hash, err := credential.HashArgon2(password)
+	if err != nil {
+		fx.t.Fatalf("HashArgon2: %v", err)
+	}
+	fx.users.byUID[uid] = &model.User{
+		CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
+		PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
+	}
+	fx.identities.add(model.IdentityTypeUsername, username,
+		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username})
+}
+
+func ptr[T any](v T) *T { return &v }
+
+// ---- 响应解析 ----
+
+// envelope 统一响应信封(断言用;Data 留原始 JSON 供按需再解析)。
+type envelope struct {
+	Code    int             `json:"code"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data"`
+	Success bool            `json:"success"`
+}
+
+// do 发一个请求并解出统一信封(HTTP 恒 200,断 body.code)。
+func do(t *testing.T, app *fiber.App, method, path, body string, headers map[string]string) *envelope {
+	t.Helper()
+	var req *http.Request
+	if method == http.MethodGet {
+		req = httptest.NewRequest(method, path, nil)
+	} else {
+		req = httptest.NewRequest(method, path, strings.NewReader(body))
+	}
+	for k, v := range headers {
+		req.Header.Set(k, v)
+	}
+	resp, err := app.Test(req, -1)
+	if err != nil {
+		t.Fatalf("%s %s 请求失败: %v", method, path, err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("%s %s HTTP 状态码=%d(铁律 6 要求恒 200)", method, path, resp.StatusCode)
+	}
+	var env envelope
+	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
+		t.Fatalf("%s %s 解析响应失败: %v", method, path, err)
+	}
+	return &env
+}
+
+// newApp 按端点清单构建 fiber 应用(公开路由 + 受保护路由;受保护路由前挂 claims 中间件)。
+// withClaims 为 true 时向 /auth/me、/auth/logout 注入已验签 claims(模拟 T4-5 中间件写入)。
+func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
+	fx.t.Helper()
+	app := fiber.New(fiber.Config{DisableStartupMessage: true})
+
+	// 公开路由
+	app.Post("/auth/login", fx.handler.Login)
+	app.Post("/auth/issue", fx.handler.Issue)
+	app.Post("/auth/refresh", fx.handler.Refresh)
+	app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
+	app.Get("/auth/wechat/status", fx.handler.WechatStatus)
+
+	// 受保护路由(模拟 T4-5 中间件:把 claims 写入 c.Locals(ClaimsCtxKey))
+	auth := app.Group("/auth")
+	auth.Use(func(c *fiber.Ctx) error {
+		if claims != nil {
+			_ = c.Locals(ClaimsCtxKey, claims)
+		}
+		return c.Next()
+	})
+	auth.Get("/me", fx.handler.Me)
+	auth.Post("/logout", fx.handler.Logout)
+	return app
+}
+
+// login 调 ① 并返回 loginTicket(供 ② 用)。失败即 Fatal。
+func (fx *fixture) login(username, password string) string {
+	fx.t.Helper()
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"`+username+`","password":"`+password+`"}`, nil)
+	if env.Code != 200 {
+		fx.t.Fatalf("login code=%d msg=%s(期望 200)", env.Code, env.Message)
+	}
+	var step1 struct {
+		LoginTicket string `json:"loginTicket"`
+	}
+	if err := json.Unmarshal(env.Data, &step1); err != nil {
+		fx.t.Fatalf("解析 loginTicket 失败: %v", err)
+	}
+	if step1.LoginTicket == "" {
+		fx.t.Fatal("loginTicket 为空")
+	}
+	return step1.LoginTicket
+}
+
+// ---- Login(两步第一步)----
+
+// TestLogin_Success argon2 命中:code 200,data 含 loginTicket + user + memberships。
+func TestLogin_Success(t *testing.T) {
+	fx := newFixture(t)
+	fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
+	fx.memberships.add("01GAQF0Z6C1V9Y3E5H7K2M8B4T", orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: "01GAQF0Z6C1V9Y3E5H7K2M8B4T",
+		InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
+	})
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"testuser","password":"correct-password"}`, nil)
+
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
+	}
+	var step1 struct {
+		LoginTicket string `json:"loginTicket"`
+		User        struct {
+			ID         string `json:"id"`
+			PersonType string `json:"personType"`
+			Status     string `json:"status"`
+		} `json:"user"`
+		Memberships []struct {
+			InstitutionID int64  `json:"institutionId"`
+			CampusID      int64  `json:"campusId"`
+			Role          string `json:"role"`
+		} `json:"memberships"`
+	}
+	if err := json.Unmarshal(env.Data, &step1); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if step1.LoginTicket == "" {
+		t.Fatal("loginTicket 缺失")
+	}
+	if len(step1.LoginTicket) != 32 {
+		t.Fatalf("loginTicket 长度=%d(期望 32 hex)", len(step1.LoginTicket))
+	}
+	if step1.User.ID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
+		t.Fatalf("user.id=%q(期望 ULID)", step1.User.ID)
+	}
+	if len(step1.Memberships) != 1 || step1.Memberships[0].Role != "teacher" {
+		t.Fatalf("memberships=%+v(期望 1 条 teacher)", step1.Memberships)
+	}
+}
+
+// TestLogin_WrongPassword 密码错:code 1001(统一文案,防枚举)。
+func TestLogin_WrongPassword(t *testing.T) {
+	fx := newFixture(t)
+	fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"testuser","password":"wrong-password"}`, nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d msg=%s(期望 %d 统一文案)", env.Code, env.Message, apperrors.CodeUnauth)
+	}
+	if env.Success {
+		t.Fatal("success 应为 false")
+	}
+}
+
+// TestLogin_UnknownUsername 未知账号:code 1001(与密码错同码,防枚举)。
+func TestLogin_UnknownUsername(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"ghost","password":"whatever"}`, nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 统一文案,防枚举)", env.Code, apperrors.CodeUnauth)
+	}
+}
+
+// TestLogin_DisabledUser 禁用账号:code 1001(统一文案,不泄漏"存在但禁用")。
+func TestLogin_DisabledUser(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.users.byUID[uid].Status = model.UserStatusDisabled
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"testuser","password":"correct-password"}`, nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 统一文案)", env.Code, apperrors.CodeUnauth)
+	}
+}
+
+// TestLogin_Md5Salt_LazyUpgrade 命中 md5_salt:升级 argon2 后成功(code 200)。
+func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	salt := "salty"
+	// md5(password+salt) hex 小写(教务旧算法)
+	d := md5Hex("correct-password" + salt)
+	fx.users.byUID[uid] = &model.User{
+		CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
+		PasswordHash: &d, PasswordSalt: ptr(salt), HashVersion: ptr(model.HashVersionMd5Salt),
+	}
+	fx.identities.add(model.IdentityTypeUsername, "testuser",
+		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: "testuser"})
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login",
+		`{"username":"testuser","password":"correct-password"}`, nil)
+	if env.Code != 200 {
+		t.Fatalf("code=%d msg=%s(期望 200:惰性升级后放行)", env.Code, env.Message)
+	}
+	// 升级写回后 hash_version 应为 argon2
+	if fx.users.byUID[uid].HashVersion == nil || *fx.users.byUID[uid].HashVersion != model.HashVersionArgon2 {
+		t.Fatalf("hash_version=%v(期望升级后为 argon2)", fx.users.byUID[uid].HashVersion)
+	}
+}
+
+// TestLogin_BadParam 空账号:code 2001(validator 校验失败)。
+func TestLogin_BadParam(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"","password":"whatever"}`, nil)
+	if env.Code != apperrors.CodeBadParam {
+		t.Fatalf("code=%d(期望 %d 参数缺失)", env.Code, apperrors.CodeBadParam)
+	}
+}
+
+// TestLogin_BadFormat 非法 JSON:code 2002(参数格式错误)。
+func TestLogin_BadFormat(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/login", `{bad json`, nil)
+	if env.Code != apperrors.CodeBadFormat {
+		t.Fatalf("code=%d(期望 %d 格式错误)", env.Code, apperrors.CodeBadFormat)
+	}
+}
+
+// ---- Issue(两步第二步)----
+
+// TestIssue_Success 合法票据 + 归属作用域:code 200,data 含 accessToken + institutionId + roles。
+func TestIssue_Success(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	ticket := fx.login("testuser", "correct-password")
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
+	}
+	var pair struct {
+		AccessToken   string   `json:"accessToken"`
+		RefreshToken  string   `json:"refreshToken"`
+		InstitutionID int64    `json:"institutionId"`
+		CampusID      int64    `json:"campusId"`
+		Roles         []string `json:"roles"`
+	}
+	if err := json.Unmarshal(env.Data, &pair); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if pair.AccessToken == "" || pair.RefreshToken == "" {
+		t.Fatalf("accessToken/refreshToken 缺失: %+v", pair)
+	}
+	if pair.InstitutionID != 11 || pair.CampusID != 3 {
+		t.Fatalf("作用域=%d/%d(期望 11/3)", pair.InstitutionID, pair.CampusID)
+	}
+	if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
+		t.Fatalf("roles=%v(期望 [teacher])", pair.Roles)
+	}
+	// access 应可被本公钥验签
+	claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", pair.AccessToken)
+	if err != nil {
+		t.Fatalf("验签 access 失败: %v", err)
+	}
+	if claims.Sub != uid {
+		t.Fatalf("claims.Sub=%q(期望 %q)", claims.Sub, uid)
+	}
+}
+
+// TestIssue_InvalidTicket 非法/不存在票据:code 1001。
+func TestIssue_InvalidTicket(t *testing.T) {
+	fx := newFixture(t)
+	fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"00000000000000000000000000000000","institutionId":11,"campusId":3}`, nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 票据失效)", env.Code, apperrors.CodeUnauth)
+	}
+}
+
+// TestIssue_ReplayTicket 同一票据二次消费:code 1001(GETDEL 原子,防重放)。
+func TestIssue_ReplayTicket(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	ticket := fx.login("testuser", "correct-password")
+
+	app := fx.newApp(nil)
+	body := `{"loginTicket":"` + ticket + `","institutionId":11,"campusId":3}`
+	if env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil); env.Code != 200 {
+		t.Fatalf("首次消费 code=%d(期望 200)", env.Code)
+	}
+	// 重放:票据已被 GETDEL 消费,应失效
+	env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("重放 code=%d(期望 %d 票据已消费)", env.Code, apperrors.CodeUnauth)
+	}
+}
+
+// TestIssue_ScopeNotBelong 作用域不属于该账号:code 1003(越权)。
+func TestIssue_ScopeNotBelong(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	ticket := fx.login("testuser", "correct-password")
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+ticket+`","institutionId":99,"campusId":99}`, nil)
+	if env.Code != apperrors.CodeForbidden {
+		t.Fatalf("code=%d(期望 %d 作用域越权)", env.Code, apperrors.CodeForbidden)
+	}
+}
+
+// TestIssue_ScopeInactive 归属已失效(inactive):code 1003。
+func TestIssue_ScopeInactive(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "inactive",
+	})
+	ticket := fx.login("testuser", "correct-password")
+
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
+	if env.Code != apperrors.CodeForbidden {
+		t.Fatalf("code=%d(期望 %d 归属失效)", env.Code, apperrors.CodeForbidden)
+	}
+}
+
+// TestIssue_BadParam 票据长度非 32:code 2001。
+func TestIssue_BadParam(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"short","institutionId":11,"campusId":3}`, nil)
+	if env.Code != apperrors.CodeBadParam {
+		t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
+	}
+}
+
+// ---- Me(受保护)----
+
+// TestMe_Success 已验签 claims 注入:code 200,data 含 user + roles + 作用域 + memberships。
+func TestMe_Success(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}
+
+	app := fx.newApp(claims)
+	env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
+	}
+	var me struct {
+		ID            string   `json:"id"`
+		Roles         []string `json:"roles"`
+		InstitutionID int64    `json:"institutionId"`
+		CampusID      int64    `json:"campusId"`
+		Memberships   []struct {
+			Role string `json:"role"`
+		} `json:"memberships"`
+	}
+	if err := json.Unmarshal(env.Data, &me); err != nil {
+		t.Fatalf("解析 data 失败: %v", err)
+	}
+	if me.ID != uid {
+		t.Fatalf("id=%q(期望 %q)", me.ID, uid)
+	}
+	if len(me.Memberships) != 1 || me.Memberships[0].Role != "teacher" {
+		t.Fatalf("memberships=%+v(期望 1 条 teacher)", me.Memberships)
+	}
+}
+
+// TestMe_Unauth 无 claims(中间件未注入):code 1001(未登录)。
+func TestMe_Unauth(t *testing.T) {
+	fx := newFixture(t)
+	fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
+	app := fx.newApp(nil) // 无 claims
+	env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
+	if env.Code != apperrors.CodeUnauth {
+		t.Fatalf("code=%d(期望 %d 未登录)", env.Code, apperrors.CodeUnauth)
+	}
+}
+
+// ---- Refresh ----
+
+// TestRefresh_Success 合法 refresh:code 200,返回新 token 对。
+func TestRefresh_Success(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	ticket := fx.login("testuser", "correct-password")
+	app := fx.newApp(nil)
+	issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
+	if issueEnv.Code != 200 {
+		t.Fatalf("issue code=%d(期望 200)", issueEnv.Code)
+	}
+	var pair struct {
+		AccessToken  string `json:"accessToken"`
+		RefreshToken string `json:"refreshToken"`
+	}
+	_ = json.Unmarshal(issueEnv.Data, &pair)
+
+	// 用旧 refresh 轮换
+	env := do(fx.t, app, http.MethodPost, "/auth/refresh",
+		`{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
+	if env.Code != 200 {
+		t.Fatalf("refresh code=%d msg=%s(期望 200)", env.Code, env.Message)
+	}
+}
+
+// TestRefresh_Invalid 合法格式(64 hex)但不在白名单:code 1002(登录态失效,前端跳登录)。
+func TestRefresh_Invalid(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/refresh",
+		`{"refreshToken":"`+strings.Repeat("0", 64)+`"}`, nil)
+	if env.Code != apperrors.CodeTokenExpired {
+		t.Fatalf("code=%d(期望 %d 登录态失效)", env.Code, apperrors.CodeTokenExpired)
+	}
+}
+
+// TestRefresh_BadParam refresh 长度非 48:code 2001。
+func TestRefresh_BadParam(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"too-short"}`, nil)
+	if env.Code != apperrors.CodeBadParam {
+		t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
+	}
+}
+
+// ---- Logout(恒 200 幂等)----
+
+// TestLogout_Success 有 claims + refresh:恒 code 200。
+func TestLogout_Success(t *testing.T) {
+	fx := newFixture(t)
+	uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+	fx.addArgon2User(uid, "testuser", "correct-password")
+	fx.memberships.add(uid, orgmodel.Membership{
+		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
+		Role: "teacher", Status: "active",
+	})
+	ticket := fx.login("testuser", "correct-password")
+	app := fx.newApp(nil)
+	issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
+		`{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
+	var pair struct {
+		RefreshToken string `json:"refreshToken"`
+	}
+	_ = json.Unmarshal(issueEnv.Data, &pair)
+
+	// 带 claims 登出(模拟已验签中间件写入)
+	claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, ID: "some-jti"}
+	appAuth := fx.newApp(claims)
+	env := do(fx.t, appAuth, http.MethodPost, "/auth/logout",
+		`{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
+	if env.Code != 200 || !env.Success {
+		t.Fatalf("logout code=%d(期望恒 200 幂等)", env.Code)
+	}
+}
+
+// TestLogout_NoClaims 无 claims:仍恒 code 200(本地态必清)。
+func TestLogout_NoClaims(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodPost, "/auth/logout", `{}`, nil)
+	if env.Code != 200 {
+		t.Fatalf("logout code=%d(期望恒 200)", env.Code)
+	}
+}
+
+// ---- 微信占位 ----
+
+// TestWechatQrcode_Placeholder 微信扫码占位:code 5001(暂未开放)。
+func TestWechatQrcode_Placeholder(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
+	if env.Code != apperrors.CodeInternal {
+		t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
+	}
+}
+
+// TestWechatStatus_Placeholder 微信扫码状态占位:code 5001(暂未开放)。
+func TestWechatStatus_Placeholder(t *testing.T) {
+	fx := newFixture(t)
+	app := fx.newApp(nil)
+	env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=abc", "", nil)
+	if env.Code != apperrors.CodeInternal {
+		t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
+	}
+}
+
+// md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。
+func md5Hex(s string) string {
+	d := md5.Sum([]byte(s))
+	return hex.EncodeToString(d[:])
+}

+ 1 - 1
internal/identity/service/auth_service.go

@@ -403,7 +403,7 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
 //
 // 入参:
 //   - ctx context.Context:请求上下文。
-//   - oldRefresh string:旧 refresh 不透明串(48 hex)。
+//   - oldRefresh string:旧 refresh 不透明串(64 hex = 32 字节随机)。
 //
 // 出参:
 //   - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。

+ 5 - 5
internal/identity/token/token.go

@@ -176,7 +176,7 @@ func NewIssuer(privateKeyPEM, issuer string, accessTTL, refreshTTL time.Duration
 // 出参:
 //   - accessToken string:JWT(RS256 签名,含 sub/roles/institution_id/campus_id/jti +
 //     iss/iat/exp/nbf;exp=iat+accessTTL)。
-//   - refreshToken string:不透明串(48 字符 hex:32 字节随机,前 8 字节为 jti 因子)。
+//   - refreshToken string:不透明串(64 字符 hex = 32 字节随机,前 8 字节为 jti 因子)。
 //   - err        error:以下场景非 nil:
 //     - claims.Sub 为空 → 包装错误(调用方 bug,单测覆盖);
 //     - 随机数生成失败(熵源故障);
@@ -289,7 +289,7 @@ func Verify(ctx context.Context, publicKeys []string, revocation RevocationStore
 //
 // 入参:
 //   - ctx          context.Context:请求上下文。
-//   - oldRefresh   string:旧 refresh 不透明串(48 hex;格式非法直接 ErrRefreshInvalid)。
+//   - oldRefresh   string:旧 refresh 不透明串(64 hex = 32 字节随机;格式非法直接 ErrRefreshInvalid)。
 //
 // 出参:
 //   - accessToken  string:新 access(claims 从旧 refresh 的 Redis 载荷恢复,JTI 换新)。
@@ -364,11 +364,11 @@ func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration)
 //
 // 入参:
 //   - ctx        context.Context:请求上下文(透传给 Redis 删除)。
-//   - oldRefresh string:待失效的 refresh 不透明串(48 字符 hex,Issue 返回的原样值)。
+//   - oldRefresh string:待失效的 refresh 不透明串(64 字符 hex = 32 字节随机,Issue 返回的原样值)。
 //
 // 出参:
 //   - error:以下场景非 nil:
-//     - 串格式非法(非 48 hex)→ 包装错误(调用方传错,单测覆盖);
+//     - 串格式非法(非 64 hex)→ 包装错误(调用方传错,单测覆盖);
 //     - refreshStore 删除失败(Redis 不可用)→ 包装 ErrDBError 类错误(调用方按"登出降级"处理:
 //       本地态必清,refresh 失效可延后——access 30min 后自然过期,且已吊销的 jti 黑名单兜底)。
 //
@@ -379,7 +379,7 @@ func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration)
 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)")
+		return errors.New("token: 非法的 refresh token 格式(须为 64 字符 hex = 32 字节随机)")
 	}
 	rjti := hex.EncodeToString(raw[:8]) // 与 Rotate 一致的 jti 因子提取
 	if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {