name: 小程序登录与app-token鉴权设计
overview: 在 jzcf-api 模块内设计小程序登录接口、wx_user 用户表,并实现与后端 token(Bearer)在请求头前缀 / Redis 缓存 key / claim 上隔离的小程序专用 token(前缀 app)校验体系,所有小程序接口以 /api 开头。
todos:
- id: add-app-constants
content: 新增 AppConstants 定义 app 前缀、wx Redis key 与 openid/状态常量(位于 jzcf-common)
status: completed - id: add-wx-user-domain-mapper
content: 新增 WxUser 实体与 WxUserMapper 接口及 XML
status: completed
dependencies:- add-app-constants
- id: add-app-token-service
content: 新增 AppTokenService 实现 app-token 签发与解析(复用 token.* 配置,独立前缀与 Redis key)
status: completed
dependencies:- add-app-constants
- id: add-app-security
content: 新增 AppUserContextHolder、AppTokenFilter(构造器注入)与 AppSecurityConfig(FilterRegistrationBean 注册)
status: completed
dependencies:- add-app-token-service
- id: add-wx-login-service
content: 新增 WxLoginService 调用微信 code2session 自动注册与登录、bindPhone、getUserInfo
status: completed
dependencies:- add-app-token-service
- add-wx-user-domain-mapper
- id: add-wx-login-controller
content: 新增 WxLoginController 登录、userinfo、bindPhone 接口
status: completed
dependencies:- add-wx-login-service
- add-app-security
- id: config-and-sql
content: 放开 /api/** 放行、补充 yml 配置(wx.appid/wx.secret,复用 token.*)与 wx_user.sql 建表
status: completed
dependencies:- add-wx-user-domain-mapper
- add-app-security
- id: support-options-preflight
content: AppTokenFilter 放行 OPTIONS 预检请求与登录接口(isAnonymous 判断,删除 shouldNotFilter)
status: completed
dependencies:- add-app-security
- id: add-phone-bind
content: 新增手机号授权绑定 bindPhone(用库中 session_key 做 AES-128-CBC 解密更新 wx_user.phone)
status: completed
dependencies:- add-wx-login-service
- add-wx-user-domain-mapper
用户需求
在独立的 jzcf-api 小程序接口服务模块中,设计小程序登录功能,并与后端管理端(admin)的 token 校验体系在「前缀 / Redis 缓存 key / claim」维度上隔离。
产品概述
小程序后端以 /api 为统一前缀提供服务。新增 wx_user 小程序用户表,提供小程序登录接口(微信 code 换 openid 自动注册并签发专用 token)。小程序专用 token 以 app 为前缀,与后端 Bearer 体系通过请求头前缀、Redis 缓存 key、JWT claim 键进行区分,并通过独立的 Servlet 过滤器与独立的 Redis 缓存实现鉴权隔离。
注意:小程序 token 与后端 token 共用同一个
Authorization请求头 ,仅以前缀app(小程序)与Bearer(后端)区分;目前签名密钥沿用后端token.secret,未单独配置。
核心特性
- 小程序用户表
wx_user:存储 openid、unionid、昵称、头像、手机号、性别、session_key、状态、登录 IP/时间等。 - 小程序登录接口
POST /api/wx/login:接收微信code,调用微信code2session换取 openid/session_key,首次自动建号,返回app前缀 token 及用户信息(响应字段为token与user,user不含 sessionKey)。 - 小程序专用 token(前缀
app):基于 JWT(HS512)+ Redis 缓存,与后端 TokenService 在「前缀 / Redis key / claim」上隔离;签名密钥目前复用后端${token.secret}。 - 独立 token 校验过滤器
AppTokenFilter:通过FilterRegistrationBean仅注册到/api/*,解析app前缀 token 并注入轻量上下文AppUserContextHolder;登录接口与OPTIONS预检请求在isAnonymous中直接放行(CORS 由CorsFilter处理)。 - 与后端鉴权分离:framework 的
SecurityConfig对/api/**放行(permitAll()),小程序请求由 api 模块自有过滤器处理,不进入后端 Spring Security 的Bearer鉴权链路。 - 提供
GET /api/wx/userinfo校验接口,验证 app-token 鉴权链路(返回脱敏用户信息,不含 sessionKey)。 - 手机号授权绑定
POST /api/wx/bindPhone:接收微信getPhoneNumber返回的encryptedData/iv,用库中session_key做 AES-128-CBC 解密,更新wx_user.phone。
技术栈
- 沿用现有栈:Spring Boot 2.5、MyBatis、Redis(
jzcf-common/jzcf-framework)、JWT(io.jsonwebtoken)、AjaxResult/BaseEntity约定。 jzcf-api模块内新增 domain/mapper/service/controller/security/config 包,不引入对jzcf-system的依赖。
实现方案
总体策略
在 jzcf-api 模块内构建一套与后端平行的轻量鉴权体系:自定义 AppConstants(位于 jzcf-common)、独立 AppTokenService(JWT+Redis,key=wx_login_tokens:)、AppTokenFilter(仅 /api/*,解析 app 前缀)、AppUserContextHolder(仅存 wxUserId/openid)、WxLoginService(微信 code2session 自动注册)。在 framework SecurityConfig 中对 /api/** 放行,避免后端 JWT 过滤器与小程序请求冲突。
关键技术决策
- 请求头与前缀分离 :小程序 token 与后端 token 共用
Authorization头,AppTokenService按AppConstants.APP_TOKEN_PREFIX="app "前缀识别并剥离,后端JwtAuthenticationTokenFilter按Bearer识别,二者互不解析。 - Redis key 分离 :小程序登录态缓存使用
wx_login_tokens:前缀(key 内再拼接 UUID),与后端login_tokens:隔离,避免 key 冲突与越权读取。 - Filter 注册隔离 :
AppTokenFilter不带@Component/@Order,由AppSecurityConfig通过FilterRegistrationBean注册(new AppTokenFilter(appTokenService)注入依赖),urlPatterns=/api/*,order=-100,name=appTokenFilter;作用范围完全由 urlPatterns 控制,shouldNotFilter已删除。后端SecurityConfig对/api/**设permitAll(),由本过滤器独立校验并写入AppUserContextHolder(非 Spring SecurityContext,保持对后端体系零侵入)。 - 依赖注入方式 :
AppTokenFilter因由FilterRegistrationBean显式new创建,采用构造器注入AppTokenService;AppTokenService/WxLoginService/WxLoginController仍使用@Autowired字段注入。 - 微信登录 :调用
https://api.weixin.qq.com/sns/jscode2session,用RestTemplate发送 GET;openid 已存在则更新 session_key/登录信息,否则插入新wx_user并置默认状态/昵称。返回的wx_user会先setSessionKey(null)再序列化给前端,避免 session_key 下发客户端。 - OPTIONS 预检放行 :
AppTokenFilter.isAnonymous对OPTIONS方法直接返回 true,预检请求不含鉴权头,交由CorsFilter完成跨域处理,避免被拦截返回 401;POST /api/wx/login也在此跳过校验。 - token 失效处理 :
AppTokenFilter解析到loginUser == null时直接返回401 {"code":401,"msg":"令牌无效或已过期"},不抛异常;校验通过后会自动续期。 - 手机号授权绑定 :微信
getPhoneNumber返回encryptedData/iv,后端使用库内session_key(按 openid 查询)经WxDecryptUtils(标准javax.cryptoAES-128-CBC/PKCS5)解密得到phoneNumber并更新用户;要求携带 app-token(从AppUserContextHolder取 openid 查询 session_key)。session_key 为空时提示重新登录。 - 性能:token 解析走 Redis 缓存(O(1)),JWT 仅做签名校验;微信接口调用为登录时单次网络 IO,已做异常兜底。
实现要点
- 复用
BaseEntity、AjaxResult、RedisCache、IdUtils、StringUtils、IpUtils等现有工具,不重复造轮子。 application.yml(api)新增wx.appid/wx.secret;token 相关配置(header/secret/expireTime)复用token.*(token.header=Authorization、token.secret、token.expireTime=30),未单独配置小程序专用密钥与有效期。- 微信 appid/secret 从配置文件读取,不硬编码;网络调用设置异常兜底。
- 登录接口放匿名(filter 内对
POST /api/wx/login跳过校验)。
架构设计
#mermaid-svg-42QrEmSfjeCtvOHW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-42QrEmSfjeCtvOHW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-42QrEmSfjeCtvOHW .error-icon{fill:#552222;}#mermaid-svg-42QrEmSfjeCtvOHW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-42QrEmSfjeCtvOHW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-42QrEmSfjeCtvOHW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-42QrEmSfjeCtvOHW .marker.cross{stroke:#333333;}#mermaid-svg-42QrEmSfjeCtvOHW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-42QrEmSfjeCtvOHW p{margin:0;}#mermaid-svg-42QrEmSfjeCtvOHW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-42QrEmSfjeCtvOHW .cluster-label text{fill:#333;}#mermaid-svg-42QrEmSfjeCtvOHW .cluster-label span{color:#333;}#mermaid-svg-42QrEmSfjeCtvOHW .cluster-label span p{background-color:transparent;}#mermaid-svg-42QrEmSfjeCtvOHW .label text,#mermaid-svg-42QrEmSfjeCtvOHW span{fill:#333;color:#333;}#mermaid-svg-42QrEmSfjeCtvOHW .node rect,#mermaid-svg-42QrEmSfjeCtvOHW .node circle,#mermaid-svg-42QrEmSfjeCtvOHW .node ellipse,#mermaid-svg-42QrEmSfjeCtvOHW .node polygon,#mermaid-svg-42QrEmSfjeCtvOHW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-42QrEmSfjeCtvOHW .rough-node .label text,#mermaid-svg-42QrEmSfjeCtvOHW .node .label text,#mermaid-svg-42QrEmSfjeCtvOHW .image-shape .label,#mermaid-svg-42QrEmSfjeCtvOHW .icon-shape .label{text-anchor:middle;}#mermaid-svg-42QrEmSfjeCtvOHW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-42QrEmSfjeCtvOHW .rough-node .label,#mermaid-svg-42QrEmSfjeCtvOHW .node .label,#mermaid-svg-42QrEmSfjeCtvOHW .image-shape .label,#mermaid-svg-42QrEmSfjeCtvOHW .icon-shape .label{text-align:center;}#mermaid-svg-42QrEmSfjeCtvOHW .node.clickable{cursor:pointer;}#mermaid-svg-42QrEmSfjeCtvOHW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-42QrEmSfjeCtvOHW .arrowheadPath{fill:#333333;}#mermaid-svg-42QrEmSfjeCtvOHW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-42QrEmSfjeCtvOHW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-42QrEmSfjeCtvOHW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-42QrEmSfjeCtvOHW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-42QrEmSfjeCtvOHW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-42QrEmSfjeCtvOHW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-42QrEmSfjeCtvOHW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-42QrEmSfjeCtvOHW .cluster text{fill:#333;}#mermaid-svg-42QrEmSfjeCtvOHW .cluster span{color:#333;}#mermaid-svg-42QrEmSfjeCtvOHW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-42QrEmSfjeCtvOHW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-42QrEmSfjeCtvOHW rect.text{fill:none;stroke-width:0;}#mermaid-svg-42QrEmSfjeCtvOHW .icon-shape,#mermaid-svg-42QrEmSfjeCtvOHW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-42QrEmSfjeCtvOHW .icon-shape p,#mermaid-svg-42QrEmSfjeCtvOHW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-42QrEmSfjeCtvOHW .icon-shape .label rect,#mermaid-svg-42QrEmSfjeCtvOHW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-42QrEmSfjeCtvOHW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-42QrEmSfjeCtvOHW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-42QrEmSfjeCtvOHW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} OPTIONS 或 /api/wx/login
解析 app 前缀
无效
有效
小程序 微信code
WxLoginController /api/wx/login
WxLoginService.code2session
微信接口 jscode2session
WxUserMapper 查/插 wx_user
AppTokenService 生成 app-token
Redis wx_login_tokens:
返回 token+用户信息, sessionKey置空
小程序 携带 Authorization: app xxx 请求 /api/**
AppTokenFilter
直接放行
Redis校验/解析
401 令牌无效或已过期
AppUserContextHolder 注入 wxUserId/openid
业务 Controller
小程序 getPhoneNumber encryptedData/iv
WxLoginController /api/wx/bindPhone
AppUserContextHolder 取 openid
WxUserMapper 查 session_key
WxDecryptUtils AES解密
updateWxUser 更新 phone
后端 /login(Bearer)体系与以上链路完全独立,互不可达;二者仅靠 Authorization 头中的前缀区分。
注:
OPTIONS预检请求与POST /api/wx/login在AppTokenFilter.isAnonymous中直接放行,不进入 Redis/解密链路。
目录结构
jzcf-common/src/main/java/com/jzcf/common/constant/
└── AppConstants.java # [NEW] 小程序专用常量(位于 jzcf-common):
# APP_TOKEN_PREFIX="app "、WX_LOGIN_TOKEN_KEY="wx_login_tokens:"、
# APP_LOGIN_USER_KEY="wx_login_user_key"、APP_OPENID="openid"、
# WX_USER_NORMAL="0"、WX_USER_DISABLE="1"。
# 说明:没有独立的请求头常量,小程序 token 复用 token.header(Authorization)。
jzcf-api/src/main/java/com/jzcf/api/
├── domain/
│ ├── WxUser.java # [NEW] wx_user 表实体,继承 BaseEntity,含 openid/unionid/nickName/avatar/phone/gender/sessionKey/status/loginIp/loginDate 等字段。
│ └── dto/
│ ├── WxLoginBody.java # [NEW] 登录入参:code。
│ └── WxBindPhoneBody.java # [NEW] 绑定手机号入参:encryptedData、iv。
├── mapper/
│ └── WxUserMapper.java # [NEW] MyBatis Mapper:selectByOpenid、insertWxUser、updateLoginInfo、updateWxUser(含 phone 更新)。
├── security/
│ ├── AppTokenFilter.java # [NEW] 不含 @Component/@Order;构造器注入 AppTokenService;拦截 /api/*,解析 app- 前缀,注入 AppUserContextHolder;isAnonymous 放行 OPTIONS 与 /api/wx/login;无效 token 返回 401。
│ ├── AppUserContextHolder.java # [NEW] 线程级轻量上下文,保存当前 wxUserId/openid。
│ ├── WxLoginUser.java # [NEW] 轻量登录用户信息(userId/openid/nickName/avatar),与后端 LoginUser 隔离。
│ └── WxDecryptUtils.java # [NEW] 微信加密数据解密工具(AES-128-CBC/PKCS5),用于手机号授权绑定。
├── service/
│ ├── WxLoginService.java # [NEW] @Autowired 注入 WxUserMapper/AppTokenService;微信 code2session 登录逻辑:换 openid/session_key,自动注册/更新(sessionKey 下发前置空),调用 AppTokenService 签发 token;bindPhone 解密绑定手机号;getUserInfo(openid) 返回脱敏信息。
│ └── AppTokenService.java # [NEW] @Component + @Autowired(RedisCache);生成/解析 app-token(JWT HS512 + Redis),缓存 wxUserId↔token;读取 token.header/token.secret/token.expireTime(复用后端配置)。
├── config/
│ └── AppSecurityConfig.java # [NEW] 注册 AppTokenFilter(FilterRegistrationBean,/api/*,order=-100,name=appTokenFilter)。
└── controller/
├── WxLoginController.java # [NEW] @Autowired 注入 WxLoginService;POST /api/wx/login(code→token+userinfo);GET /api/wx/userinfo(脱敏信息);POST /api/wx/bindPhone(手机号授权绑定)。
└── TestController.java # [MODIFY] 已存在,保持 /api/test/test,可不变。
jzcf-api/src/main/resources/
├── mapper/
│ └── WxUserMapper.xml # [NEW] SQL 映射:按 openid 查询、插入、更新登录信息、更新用户。
└── application.yml # [MODIFY] 新增 wx.appid/wx.secret;token 相关复用 token.header/token.secret/token.expireTime。
jzcf-framework/src/main/java/com/jzcf/framework/config/
└── SecurityConfig.java # [MODIFY] authorizeHttpRequests 增加 .antMatchers("/api/**").permitAll(),放行小程序请求给自有过滤器处理。
sql/
└── wx_user.sql # [NEW] wx_user 建表语句(与 WxUser 实体字段一致,含 openid 索引)。