记一次基于ruoyi-vue新增小程序服务模块,小程序登陆相关


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 及用户信息(响应字段为 tokenuseruser 不含 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 头,AppTokenServiceAppConstants.APP_TOKEN_PREFIX="app " 前缀识别并剥离,后端 JwtAuthenticationTokenFilterBearer 识别,二者互不解析。
  • 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 创建,采用构造器注入 AppTokenServiceAppTokenService/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.isAnonymousOPTIONS 方法直接返回 true,预检请求不含鉴权头,交由 CorsFilter 完成跨域处理,避免被拦截返回 401;POST /api/wx/login 也在此跳过校验。
  • token 失效处理AppTokenFilter 解析到 loginUser == null 时直接返回 401 {"code":401,"msg":"令牌无效或已过期"},不抛异常;校验通过后会自动续期。
  • 手机号授权绑定 :微信 getPhoneNumber 返回 encryptedData/iv,后端使用库内 session_key(按 openid 查询)经 WxDecryptUtils(标准 javax.crypto AES-128-CBC/PKCS5)解密得到 phoneNumber 并更新用户;要求携带 app-token(从 AppUserContextHolder 取 openid 查询 session_key)。session_key 为空时提示重新登录。
  • 性能:token 解析走 Redis 缓存(O(1)),JWT 仅做签名校验;微信接口调用为登录时单次网络 IO,已做异常兜底。

实现要点

  • 复用 BaseEntityAjaxResultRedisCacheIdUtilsStringUtilsIpUtils 等现有工具,不重复造轮子。
  • application.yml(api)新增 wx.appid/wx.secret;token 相关配置(header/secret/expireTime)复用 token.*token.header=Authorizationtoken.secrettoken.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/loginAppTokenFilter.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 索引)。
相关推荐
2601_956743683 小时前
技术选型指南|D‑coding 小程序开发,定制能力如何转化项目服务价值
小程序·coding·开发经验
常宇佳4 小时前
vue3 $refs使用方法
前端·vue.js·typescript
༄沐࿆风࿆࿆4 小时前
Spring Boot 3 + Vue 3 全栈外卖系统:RabbitMQ异步解耦、MySQL/Neo4j双引擎推建
vue.js·spring boot·java-rabbitmq
weishuangyun1237 小时前
微信小程序怎么选公司?上线流程详解
大数据·小程序
伟大的兔神13 小时前
我做了一个本地优先的 AI 图片工作台:Loomora v1.0.0 正式发布
前端·javascript·vue.js
Flynt15 小时前
我扒了Fantastic-admin 6.0的源码,聊聊"AI时代版本答案"这个称号
vue.js·ai编程·cursor
show43316 小时前
2026微信小程序视频转文字多语种识别引擎技术选型:22种方言25种外语挑战
微信小程序·小程序·音视频
码艺-Alimjan17 小时前
Vue项目源码备份最佳实践:无视node_modules,打包体积仅2MB
前端·javascript·vue.js
风月说与山鬼19 小时前
十二、Vue插件
前端·vue.js