关键词:Spring Boot 3 · Spring Security 无状态 · JWT HS256 · Refresh Token 轮换 + 宽限期 · 微信 code2session · 静默登录 目标读者:后端 / 运维 / 前端
一、业务目标
二期做到了「单机可用」,三期的命题是:让用户能够在换手机、重装小程序后拿回数据,同时不牺牲一期的「免登录可用」体验。
三个硬性业务约束:
| 约束 | 含义 | 实现 |
|---|---|---|
| 登录不能是门槛 | 未登录必须完整可用 | 游客模式 → LocalAdapter |
| 登录不能打断 | 用户不应看到「请先登录」弹窗 | wx.login 静默登录,失败静默降级 |
| 登录不能丢数据 | 游客期记录必须无损上云 | 登录后自动触发合并(第四期详解) |
对应的三个技术指标:
- 静默登录成功率 ≥ 95%(失败自动降级,用户无感);
- access token 有效期 120min,refresh 30 天并支持吊销;
- 全站无 Session,水平扩展无状态。
二、核心功能范围
| 功能 | 端 | 说明 |
|---|---|---|
| 微信授权登录 | 双端 | wx.login → code → 服务端 code2session → openid → 建/查用户 → 签发 Token |
| 账号密码注册/登录 | 双端 | BCrypt(10),注册即登录 |
| 双 Token 签发与轮换 | 双端 | access 120min / refresh 30 天,轮换 + 60s 宽限期 |
| 401 自动续期 | 前端 | 单飞刷新 + 一次重放 |
| 登出 / 全设备登出 | 双端 | 单设备删除 / 按 userId 全删 |
| 注销账号 | 双端 | 级联删除全部数据 |
| 用户资料 | 双端 | 头像昵称读写 |
| 会话复用 | 前端 | refresh 剩余 >7 天才复用,避免 token 膨胀 |
| 部署体系 | 运维 | install.sh / rollout.sh / verify.sh / systemd / Nginx / Certbot |
三、涉及的技术模块
bash
mood-backend/
├── security/ JwtTokenProvider(HS256)· JwtAuthFilter(OncePerRequest)· CurrentUser(ThreadLocal)
├── config/ SecurityConfig(无状态链 + 白名单 + BCrypt)· OpenApiConfig · props/{Jwt,Wechat}Properties
├── service/ AuthService(★ 轮换核心)· WechatService(code2session)
├── document/ User(uk_openid / uk_username)· RefreshToken(uk_user_device + TTL 7 天)
├── common/ Result · ErrorCode · BizException · GlobalExceptionHandler · TraceIdFilter · HashUtil
├── task/ TokenCleanupTask(每日 03:30 兜底清理 + 容量观测)
└── deploy/ install.sh · rollout.sh · verify.sh · nginx-*.conf · mood-backend.service · .env
emotion-monster/
├── app.js silentLogin / onLoginSuccess / canReuseSession / logout
├── utils/http.js ★ 401 单飞刷新 + 重放
└── utils/storage.js session 域同步直写
四、关键技术选型与实现要点
4.1 鉴权方案对比
| 方案 | 有状态 | 吊销 | 泄漏影响 | 结论 |
|---|---|---|---|---|
| Session + Cookie | 是 | 即时 | 单会话 | ❌ 小程序 Cookie 别扭,难扩展 |
| 单 JWT(长有效) | 否 | 无法 | 长期失控 | ❌ 不可接受 |
| Access + Refresh(静态 refresh) | 否 | 可 | refresh 长期有效 | ⚠️ 可用但弱 |
| Access + Refresh 轮换 + 宽限期(本项目) | 否 | 可 | 可检测重放 | ✅ 已选 |
| Opaque Token + Redis | 是(Redis) | 即时 | 可控 | 引入 Redis 时再评估 |
为什么选「轮换 + 宽限期」而不是简单的静态 refresh?
因为网络是不可靠的:客户端发出 refresh 请求、服务端已轮换、但响应在回程丢失 ------ 客户端手里只剩旧 token,而旧 token 已失效,用户被强制登出。宽限期 让旧 token 在 60 秒内仍可用一次,解决这个 race;同时若旧 token 在宽限期外 被使用,说明它可能被盗(重放),此时吊销该用户该设备的全部会话。
4.2 JWT 结构
java
// security/JwtTokenProvider.java:27-42
Key key = Keys.hmacShaKeyFor(secret.getBytes(UTF_8)); // 要求 secret ≥ 32 字节
// accessToken claims
sub = userId
typ = "access"
username = ""(缺省)
deviceId = ""(缺省)
iat / exp = now / now + accessTtlMinutes
解析异常映射:
java
// L44-52
ExpiredJwtException → ErrorCode.TOKEN_EXPIRED (20001)
JwtException / IllegalArgument → ErrorCode.TOKEN_INVALID (20002)
4.3 过滤器链与白名单
java
// config/SecurityConfig.java:38-54
http
.csrf(disable).formLogin(disable).httpBasic(disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/v1/auth/**", "/api/v1/health",
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html",
"/actuator/health").permitAll()
.anyRequest().authenticated())
.exceptionHandling(e -> e.authenticationEntryPoint(...)) // 401 输出 Result JSON
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
java
// security/JwtAuthFilter.java:37-59
// ① 读 Authorization: Bearer <token>
// ② 解析成功 → CurrentUser.set(userId, deviceId) + SecurityContextHolder
// ③ 解析失败 → 直接写 401 JSON,不继续过滤器链
// ④ finally 必定 CurrentUser.clear() + SecurityContextHolder.clearContext()
④ 是必须的:Tomcat 线程复用,不清理 ThreadLocal 会造成严重的用户数据串号。
java
// security/CurrentUser.java:22-28
public static String userId() {
String v = USER_ID.get();
if (v == null || v.isBlank()) throw new BizException(ErrorCode.TOKEN_INVALID);
return v;
}
这是全业务层取当前用户的唯一入口 ,所有查询强制按 CurrentUser.userId() 隔离。第五期做管理端时要在此基础上新增 userType 校验。
4.4 Refresh Token 轮换:本期最复杂的一段代码
java
// service/AuthService.java:155-164
public TokenResp refresh(RefreshReq req) {
String oldHash = HashUtil.sha256(req.refreshToken());
for (int attempt = 0; attempt < 2; attempt++) { // 最多两轮
TokenResp resp = tryRotate(req, oldHash);
if (resp != null) return resp;
}
throw new BizException(ErrorCode.TOKEN_INVALID); // CAS 连续失败
}
tryRotate(L171-229)的完整判定链:
kotlin
① findByTokenHash(oldHash)
未命中 → findByPreviousTokenHash(oldHash) (宽限期内复用)
② 命中 previous 但超出宽限期(60s)
→ deleteByUserIdAndDeviceId(吊销该用户该设备全部会话)
→ throw TOKEN_INVALID ★ 判定为重放攻击
③ revoked || expiresAt < now
→ delete(token) + throw TOKEN_EXPIRED
④ checkStatus(user) ★ 封禁账号不能靠有效 refresh 无限续期
⑤ CAS 原子轮换:
UpdateResult r = mongoTemplate.updateFirst(
Query(_id = token.id, <tokenHash|previousTokenHash> = oldHash),
Update.set("tokenHash", sha256(newRaw))
.set("previousTokenHash", token.getTokenHash()) ← 旧 hash 降级为宽限凭证
.set("rotatedAt", now)
.set("expiresAt", now + refreshTtlDays) ← 滑动过期
.set("createdAt", now).set("revoked", false));
if (r.getMatchedCount() == 0) return null; ← 并发未命中,触发第二轮
⑥ 签发新 accessToken 返回
三个容易忽略的细节:
- 轮换时不更新
deviceId(L208-210 注释明示)------否则会撞uk_user_device唯一索引,把用户踢下线; - 滑动过期 :每次轮换把
expiresAt推到now + 30 天,活跃用户永不过期; - 只存 hash :库里只有
sha256(raw),明文仅签发时回传一次,库被拖走也无法冒用。
4.5 微信登录与 openid 处理
java
// service/AuthService.java:114-147
WechatService.SessionResult session = wechatService.code2Session(req.code().trim());
User user = userRepository.findByOpenid(session.openid()).orElse(null);
boolean isNewUser = (user == null);
// 新用户:写入 openid / unionid / 默认昵称「微信用户」/ status=1
// 老用户:checkStatus + 按需覆盖昵称头像
// 新用户才 ensureSetting()
java
// service/WechatService.java:45-102
// 开发模式:未配 appid/secret 且 dev-mode=true → 伪 openid "dev-" + sha256(code).substring(0,24)
// 非 dev 且缺凭据 → SYSTEM_ERROR「服务端未配置微信 appid/secret」
// RestTemplate 连接 3s / 读 5s;按 String 接收后手工解析(微信返回 text/plain)
// errcode != "0" → 报错;40226 特判 ACCOUNT_LOCKED(账号存在风险,暂无法登录)
安全红线:
session_key不落库 (SessionResult只作临时传递);openid/unionid不下发 :toUserInfo(L343-346)只返回id/username/nickname/avatarUrl/createdAt;- 密码强度
^(?=.*[A-Za-z])(?=.*\d).{8,64}$,BCrypt(10) 存储,绝不明文或可逆加密。
4.6 前端:静默登录与会话复用
js
// app.js:89-103
silentLogin() {
wx.login({
success: (r) => {
if (!r.code) return;
http.post('/auth/wx-login', { code: r.code, deviceId: this.globalData.deviceId }, { auth: false })
.then(data => this.onLoginSuccess(data))
.catch(() => { /* 静默降级:保持游客态,本地数据不受影响 */ });
},
fail: () => {}
});
}
js
// app.js:13 + 78-84
const REUSE_MIN_REMAIN = 7 * 24 * 3600 * 1000;
canReuseSession() {
const s = storage.getSession();
if (!s || !s.refreshToken || !s.refreshExpiresAt) return false;
const exp = Date.parse(s.refreshExpiresAt);
if (isNaN(exp)) return false;
return exp - Date.now() > REUSE_MIN_REMAIN; // 剩余 <7 天才重新登录续期
}
为什么要 REUSE_MIN_REMAIN? 如果每次冷启动都 wx.login 换发新 refreshToken,refresh_tokens 集合会单调膨胀(每个用户每天一条)。详见 docs/refresh_tokens-数据增长诊断报告-v1.0.md。设 7 天阈值后,活跃用户的 token 文档数从 O(启动次数) 降为 O(月)。
4.7 前端:401 单飞刷新 + 重放
js
// utils/http.js:5, 20-58
let refreshTask = null;
function refreshAccessToken() {
if (refreshTask) return refreshTask; // ★ 并发 401 只刷新一次
const session = storage.getSession();
if (!session || !session.refreshToken) return Promise.resolve(false);
refreshTask = new Promise(resolve => {
wx.request({
url: BASE_URL + '/auth/refresh', method: 'POST',
data: { refreshToken: session.refreshToken, deviceId: storage.getDeviceId() },
success: (res) => {
const body = res.data || {};
if (res.statusCode === 200 && body.code === 0 && body.data) {
storage.setSession({ ...session,
accessToken: body.data.accessToken,
refreshToken: body.data.refreshToken,
refreshExpiresAt: body.data.refreshExpiresAt,
expiresAt: new Date(Date.now() + body.data.expiresIn * 1000).toISOString() });
resolve(true);
} else { storage.clearSession(); resolve(false); } // 续期失败 → 强制登出
},
fail: () => resolve(false)
});
}).then(ok => { refreshTask = null; return ok; }, () => { refreshTask = null; return false; });
return refreshTask;
}
js
// utils/http.js:84-95
if ((code === 20001 || code === 20002) && needAuth) { // 注意:是业务码,不是 HTTP 401
refreshAccessToken().then(ok => {
if (!ok) { reject(new Error('登录已失效,请重新登录')); return; }
doRequest(opts).then(resolve, reject); // 只重放一次
});
return;
}
关键认知 :后端鉴权失败返回的是 HTTP 200 + code=20001 (GlobalExceptionHandler 把 BizException 的 TOKEN_* 映射为 401,但业务异常默认 200)。前端必须按业务码判断,不能只看 HTTP 状态码。这是全项目最容易踩的坑,第五期管理端方案把它列为风险 #1。
五、数据流转与接口设计
5.1 登录时序
scss
小程序 后端 微信
│ wx.login() │
│────────────────────────────────────────────────────────▶│
│◀─────────── code(5 分钟有效,一次性)───────────────────│
│ POST /auth/wx-login {code, deviceId} │
│──────────────────▶│ │
│ │ GET sns/jscode2session │
│ │─────────────────────────────────────▶│
│ │◀───── {openid, session_key} ─────────│
│ │ findByOpenid → 无则建 user + setting │
│ │ newRefreshToken(): sha256 落库(upsert by userId+deviceId)
│ │ JwtTokenProvider 签 access │
│◀──────────────────│ │
│ {userId, accessToken, refreshToken, expiresIn,
│ refreshExpiresAt, isNewUser, user} │
│ │
│ storage.setSession(...) → Api.init(true) → 合并游客数据 → 拉云端设置
5.2 接口清单
| 方法 | 路径 | 说明 | 鉴权 |
|---|---|---|---|
| POST | /api/v1/auth/register |
账号密码注册(注册即登录),服务端强制 userType=USER |
否 |
| POST | /api/v1/auth/login |
账号密码登录 | 否 |
| POST | /api/v1/auth/wx-login |
{code, deviceId} → openid → Token |
否 |
| POST | /api/v1/auth/refresh |
{refreshToken, deviceId} 轮换续期 |
否 |
| POST | /api/v1/auth/logout |
{refreshToken} 单设备 |
否 |
| POST | /api/v1/auth/logout-all |
全设备登出 | 是 |
| GET / PUT | /api/v1/users/me |
资料读写 | 是 |
| DELETE | /api/v1/users/me |
注销,级联删除 | 是 |
| GET | /api/v1/health |
健康检查 | 否 |
5.3 错误码
SUCCESS 0 PARAM_ERROR 10001 PARAM_OUT_OF_RANGE 10002
TOKEN_EXPIRED 20001 TOKEN_INVALID 20002 ACCOUNT_LOCKED 20003 FORBIDDEN 20004
NOT_FOUND 30001 DUPLICATE 30003 INVALID_CREDENTIAL 30005
DATA_INVALID 40001 SYNC_CONFLICT 50001
SYSTEM_ERROR 90001 TOO_MANY_REQUESTS 90002
映射规则(GlobalExceptionHandler):
| 异常 | HTTP |
|---|---|
BizException(TOKEN_*) |
401 |
BizException(FORBIDDEN / ACCOUNT_LOCKED) |
403 |
BizException(TOO_MANY_REQUESTS) |
429 |
其它 BizException |
200(必须判 code) |
MethodArgumentNotValidException / HttpMessageNotReadable / IllegalArgument |
400 |
DuplicateKeyException |
200 + DUPLICATE |
其它 Exception |
500,仅记日志不泄漏堆栈 |
5.4 文档模型与索引
java
// document/RefreshToken.java
@CompoundIndex(name="uk_user_device", def="{'userId':1,'deviceId':1}", unique=true) // L19
@Indexed userId // L25
@Indexed(unique=true) tokenHash // L28
@Indexed previousTokenHash // L37
@Indexed(expireAfterSeconds = 604800) expiresAt // L50 TTL 7 天
// 字段:tokenHash(sha256) · previousTokenHash(宽限) · rotatedAt · deviceId · expiresAt · revoked
java
// document/User.java
@CompoundIndex(name="uk_openid", def="{'openid':1}", unique=true, sparse=true) // L17
@Indexed(unique=true, sparse=true) username // L24
// 字段:username · passwordHash(BCrypt) · nickname · avatarUrl · openid · unionid · status(1正常/0冻结/2注销中)
application.yml:15显式spring.data.mongodb.auto-index-creation: true------ 生产务必开启,否则唯一约束不生效,幂等与会话唯一性都无从谈起。
5.5 令牌清理
java
// task/TokenCleanupTask.java
@Scheduled(cron = "${app.task.token-cleanup-cron:0 30 3 * * *}") // 默认每日 03:30
deleteByExpiresAtBefore(now - 7 days); // 与 TTL 索引对齐,兜底 TTL 线程异常/历史脏数据
reportCapacity(); // total / valid / validRate% / active24h / storageMB / indexMB
设计定位:主力回收是 TTL 索引,本任务只兜底;多实例并发执行幂等,仅日志重复。
六、技术难点分析
难点 1:refresh_tokens 集合增长失控
现象:每次冷启动都签发新 refreshToken → 集合单调膨胀。
三层治理(已落地):
- upsert 而非 insert :按
(userId, deviceId)upsert,同设备复用同一文档; - 前端会话复用 :
REUSE_MIN_REMAIN = 7 天,剩余时间足够就不重新登录; - TTL + 定时任务 :
expiresAtTTL 7 天 + 每日 03:30 兜底清理 + 容量观测日志。
详见 docs/refresh_tokens-数据增长诊断报告-v1.0.md。
难点 2:并发 401 引发刷新风暴
问题:页面并发发出 7 个请求,access 恰好过期 → 7 次 refresh → 前 6 次成功后旧 refresh 已失效,后续全部失败 → 用户被登出。
解法 :模块级 refreshTask 单飞句柄(http.js:5)。并发请求共享同一个 Promise,只发一次刷新请求,其余 await 同一结果。
边界 :refresh 失败则 clearSession(),所有等待者统一收到失败 → 提示重新登录(而非部分成功部分失败的混乱状态)。
难点 3:轮换中的网络竞态
问题:refresh 请求成功但响应丢失,客户端旧 token 已失效。
解法 :previousTokenHash + 60s 宽限期(JwtProperties: refreshGraceSeconds=60)。宽限期内旧 token 可用一次;超出宽限期使用旧 token → 判定重放 → 吊销该用户该设备全部会话。
难点 4:dev 模式的正确开关
app.wechat.dev-mode 默认 true(application.yml:37),未配凭据时用 dev-sha256(code) 伪造 openid,方便本地开发。
风险:生产忘记关 → 任何人用任意 code 都能登录成「dev-xxx」用户,且可能撞到他人账号。
必须 :application-local.yml 与 deploy/mood-backend.env 中显式 WECHAT_DEV_MODE=false(当前 env 已配置)。上线 checklist 必须核验。
难点 5:注销与事务
AuthService.deleteAccount(L269)标注 @Transactional,但工程未配置 MongoTransactionManager,该注解实际不生效。
影响:注销过程中若中断,可能残留部分数据。
应对:MongoDB 单机无副本集不支持多文档事务。短期内按「先软删 user(status=2)→ 异步清理」改造;或部署副本集后启用事务。第五期处理。
七、部署方案与要点
7.1 交付的脚本
| 文件 | 职责 |
|---|---|
deploy/install.sh(238 行) |
环境初始化:识别 apt/dnf → 装 nginx+certbot → JDK17 (CentOS7 源无 JDK17,改下 Temurin)→ 建 mood 系统用户与目录 → 装 env(0600)→ systemd → logrotate(14 天)→ Mongo 检测 → 证书签发 → 防火墙 |
deploy/rollout.sh(62 行) |
备份旧 jar(保留最近 5 个)→ 替换 → restart → 轮询 health 最多 120s → 超时自动回滚 + 打印日志 |
deploy/verify.sh(84 行) |
8 项验收:systemd active → 8200 监听 → health 含 UP → 80 跳转 → HTTPS health → 证书剩余 >7 天 → 支持 TLS1.2 → Swagger 可访问;--api 追加注册→me→pull→moods→settings→无 Token 期望 401→注销 |
deploy/mood-backend.service |
User=mood、EnvironmentFile、JAVA_OPTS、SIGTERM + TimeoutStopSec=30、Restart=on-failure、StartLimitBurst=5、加固四项 |
deploy/nginx-mood.conf |
80→443 跳转、ACME 直通、TLSv1.2/1.3(小程序要求)、HSTS、limit_req 10r/s burst=20、keepalive 32、四 Forwarded 头、client_max_body_size 10m |
7.2 配置与密钥管理
优先级:环境变量 > application-local.yml > application.yml。
| 变量 | 说明 |
|---|---|
MONGODB_URI |
密码含 @ 必须编码为 %40;authSource=admin |
JWT_SECRET |
≥32 字节,务必更换并定期轮换 |
WECHAT_APP_ID / WECHAT_APP_SECRET |
小程序凭据 |
WECHAT_DEV_MODE |
生产必须 false |
SERVER_ADDRESS=127.0.0.1 / SERVER_PORT=8200 |
仅回环监听 |
⚠️ 现存风险 :
deploy/mood-backend.env含真实 Mongo 密码、JWT secret、微信 AppSecret 且已提交进仓库 (文件头注释自陈「请勿提交」)。处置 :立即轮换全部密钥 → 从 git 历史中清理(git filter-repo)→ 改为部署时由密钥管理服务/Ansible 下发。
7.3 灰度与回滚
rollout.sh 的回滚是文件级 + 健康检查驱动的:
bash
备份 jar → 替换 → systemctl restart → 轮询 /api/v1/health(≤120s)
└─ 超时/失败 → stop → 还原 jar → start → 打印 journalctl -n 100 → exit 1
建议补充:①启动前 curl 一次旧版本 health 作为基线;②保留最近 5 个 jar(已做);③关键发版先灰度单实例。
八、性能与安全考量
性能
| 项 | 现状 | 建议 |
|---|---|---|
| JWT 校验 | 纯 HMAC 计算,无 IO | ✅ 已最优 |
| 登录 QPS | 每次需访问微信接口(3s/5s 超时) | 微信侧限流时注意降级;可缓存 code → openid 结果(code 一次性,缓存窗口极短) |
| refresh | 2 次 Mongo 查询 + 1 次 updateFirst | 索引齐全,uk_user_device / tokenHash 唯一索引 |
| 线程池 | RestTemplate 连接 3s / 读 5s | ✅ 防止微信侧抖动拖垮线程池 |
安全
| 措施 | 状态 |
|---|---|
| 密码 BCrypt(10),不明文/可逆 | ✅ |
| refreshToken 只存 sha256 | ✅ |
| openid / session_key 不下发、不落库 | ✅ |
| 全站 HTTPS,TLS ≥ 1.2,HSTS | ✅(Nginx) |
| 后端仅监听回环,MongoDB 仅回环 | ✅ |
| systemd 加固(NoNewPrivileges 等) | ✅ |
| Nginx 限流 10r/s burst=20 | ✅ |
| 账号级限流 / 登录失败锁定 | ❌ 待补 |
| 密钥从仓库移除并轮换 | ❌ 待办(高优先级) |
| 注销事务语义 | ❌ 待补 |
| 全链路 traceId(端 → 服务端) | ❌ 小程序端未生成/透传 X-Trace-Id |
九、风险与应对
| 风险 | 等级 | 应对 |
|---|---|---|
| 密钥入库泄露 | 高 | 轮换全部密钥 + 清理 git 历史 + 改为部署注入 |
生产 WECHAT_DEV_MODE=true |
高 | 上线 checklist 核验;启动日志打印 dev-mode 告警 |
| 微信小程序 request 合法域名未配 | 高 | 生产必须已备案 HTTPS 域名并配置;verify.sh 已覆盖 |
| 401 风暴 | 中 | 前端单飞刷新(已实现) |
| 重放攻击 | 中 | 宽限期 + 超期吊销整设备会话(已实现) |
| 弱口令 / 撞库 | 中 | 密码强度正则;建议补失败次数限制与验证码 |
@Transactional 不生效 |
中 | 改异步清理或启用副本集事务 |
| 时区不一致 | 中 | 前后端统一 Asia/Shanghai;DateUtil.ZONE 固定 |
| 无 traceId 串联 | 中 | 小程序端生成 X-Trace-Id 并透传(第五期) |
十、验收标准与交付物
验收标准
-
verify.sh8 项全绿,--api冒烟 7 步通过(含无 Token 期望 401); - 微信登录:新用户自动建号 + 建 setting,老用户不覆盖已有昵称;
- refresh 轮换:连续两次 refresh 均成功;第二次用第一次的旧 token 在 60s 内仍成功一次;
- 重放检测:超过 60s 后用旧 token → 返回 20002 且该设备全部会话被吊销;
- 并发 401:同时发 10 个请求,只触发 1 次
/auth/refresh(抓包验证); - 全设备登出后,其它设备的 access/refresh 立即失效;
- 注销账号后,mood/period/setting/refreshToken/user 全部清除;
- 响应体中不含
openid/unionid/passwordHash/session_key; - 生产环境
WECHAT_DEV_MODE=false、JWT_SECRET非默认值且 ≥32 字节; -
rollout.sh模拟失败(替换成坏 jar)能自动回滚并恢复服务。
交付物
| 类型 | 内容 |
|---|---|
| 代码 | security/ config/ service/AuthService service/WechatService task/TokenCleanupTask |
| 前端 | app.js 静默登录/会话复用、utils/http.js 单飞刷新 |
| 部署 | deploy/ 9 个文件(install / rollout / verify / systemd / nginx×3 / env) |
| 文档 | 详细设计文档 v6.0(登录与同步版)、docs/refresh_tokens-数据增长诊断报告-v1.0.md |
| 接口 | Swagger http://localhost:8200/swagger-ui.html |
十一、后续优化方向
- 密钥治理:接入密钥管理(KMS / Vault / 云厂商 Secret Manager),禁止入库;
- 登录风控:IP + 账号维度失败次数限制、图形验证码、异地登录提醒;
- 端云 traceId 打通 :小程序端生成
X-Trace-Id并在错误上报中携带,彻底解决「用户报错无法定位」; - 会话管理页 :「我的设备」列表 + 单设备踢下线(复用
logout-all的按 deviceId 删除能力); - 注销异步化 :
status=2软删 + 定时任务清理,规避无事务问题; - 账号体系扩展 :为第五期管理端预留
userType(USER/ADMIN)与 JWT claim。