用户侧的业务都集中在这个模块:微信小程序登录如何换取 JWT、钱包余额的冻结与结算如何用乐观锁保证并发安全、订单生命周期如何用事务管理。本文逐一拆解。
一、模块定位
charge-user(端口 8086)是业务面最广的模块(49 个 Java 文件),也是全项目工程化最完整的模块:
- ✅ 唯一的全局异常处理(GlobalExceptionHandler)
- ✅ 唯一的统一响应体(ApiResult)
- ✅ JWT 拦截器 + CORS 配置
- ✅ MyBatis-Plus 乐观锁 / 逻辑删除 / 字段自动填充
- ⚠️ 但:7 个管理接口为 mock/硬编码数据(login 固定账号、statistics 随机数、admins/roles/permissions/op-logs/configs 为纯 mock)、管理员口令硬编码、无测试
二、模块结构
charge-user/.../user/
├── UserApplication.java
├── config/
│ ├── MybatisPlusConfig.java 乐观锁拦截器
│ ├── MybatisPlusMetaHandler.java 自动填充 createTime/updateTime
│ └── WebMvcConfig.java JWT 拦截器 + CORS
├── controller/ 7 个 Controller(AdminController 最大 355 行)
├── dto/ ApiResult + 各类请求/响应
├── entity/ 10 个实体(对应 10 张表)
├── exception/ GlobalExceptionHandler.java(全项目唯一)
├── interceptor/ JwtInterceptor.java
├── mapper/ 10 个空接口(纯 BaseMapper)
├── service/
│ ├── UserService.java + impl/UserServiceImpl.java ★唯一有接口抽象的
│ └── impl/{WalletService, OrderService, StationService, AdminService}.java
└── utils/
├── JwtUtils.java HS256 签发/解析
└── WxApiUtils.java code2Session
三、微信登录 + JWT 全链路(核心要点)
时序
① 小程序 uni.login() 取临时凭证 code
② POST /user/login/wx {code}
③ 后端 WxApiUtils 调微信接口 code2Session:
https://api.weixin.qq.com/sns/jscode2session
→ 返回 openid(用户唯一标识)+ session_key
④ 按 openid 查/建 cs_user
⑤ JwtUtils 签发 token(HS256,payload 含 userId + 过期时间)
⑥ 返回 {token, userInfo}
⑦ 小程序 uni.setStorageSync('cs_token')
JWT 关键点
java
// jjwt 0.11.5,HS256 对称加密
String token = Jwts.builder()
.setSubject(String.valueOf(userId))
.setExpiration(new Date(System.currentTimeMillis() + expire))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
| 问题 | 回答 |
|---|---|
| 为什么 HS256? | 对称加密,单服务一个密钥签发+校验,无需 RSA 公钥分发 |
| token 存哪? | 小程序本地 storage(无 httpOnly 保护,XSS 可窃取) |
| 服务端如何校验? | JwtInterceptor 拦截非放行路径 → 解析 Bearer token → userId 写入 request attribute |
⚠️ 已知缺陷
- JWT secret 明文硬编码在 application.yml:拿到源码可伪造任意用户 token。应环境变量注入 + 轮换。
- 登出是纯前端:只清本地 storage,token 在过期前仍有效。真登出需 Redis 黑名单。
- 无 refresh token :过期后只能重新登录。后端有
/user/login/refresh但前端从未调用(未被使用的接口)。 - 未登录跳转用 navigateTo 而非 reLaunch:用户可点返回键退回受保护页面。
四、钱包乐观锁并发控制(本项目质量最高的业务代码)
场景
充电下单需要冻结余额,充电结束结算解冻------同一用户的资金操作可能并发(多设备同时充电、充值+消费同时发生)。
方案:MyBatis-Plus 乐观锁
java
// CsWallet.java
@Version
private Integer version;
java
// MybatisPlusConfig.java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
实现原理(核心要点)
sql
-- 乐观锁拦截器自动改写 UPDATE:
UPDATE cs_wallet SET balance = ?, version = version + 1
WHERE id = ? AND version = #{旧version}
- 影响行数 = 1 → 更新成功
- 影响行数 = 0 → 版本冲突(他人已改)→ CAS 重试
乐观锁 vs 悲观锁(核心要点)
| 维度 | 乐观锁(本项目) | 悲观锁(SELECT FOR UPDATE) |
|---|---|---|
| 适用 | 低冲突(同一用户同时充电概率低) | 高冲突(秒杀、热点账户) |
| 等待 | 不等待,失败重试 | 等待锁释放 |
| 死锁 | 无死锁 | 可能死锁 |
| 吞吐 | 高 | 低 |
| 代价 | CAS 重试浪费 CPU | 持锁阻塞 |
结论话术:钱包场景读写冲突低,乐观锁免锁等待、无死锁、吞吐高;若未来做秒杀充值(高冲突),应评估悲观锁或 Redis 原子扣减。
五、订单事务管理
OrderService(150 行)
| 方法 | 事务 | 流程 |
|---|---|---|
| createOrder | @Transactional | 校验余额 → 冻结(乐观锁)→ 创建订单 → 写流水 |
| stopCharge | @Transactional | 结算 → 解冻 → 更新订单状态 → 写流水 |
事务边界注意事项(深入思考)
- Redis 操作不要放在事务内:Redis 不参与 JDBC 事务回滚,事务失败 Redis 已改 → 数据不一致
- 远程调用(IoTDB/推送)放事务提交后:远程调用可能超时,事务内调用会拉长锁持有时间
- @Transactional 失效场景:同类内部调用(this.xxx())、非 public 方法、异常被 catch 吞掉、自调用不走代理
⚠️ 已知缺陷:崩溃后冻结资金无法自动解冻
充电中途系统崩溃 → 订单状态停留在"充电中" → 冻结余额永远锁死。生产方案:
- 定时任务扫描超时未完成订单,自动结算/解冻
- 引入 Seata/Saga 分布式事务做补偿
六、数据库设计(10 张表)
| 表 | 实体 | 关键字段/设计 |
|---|---|---|
| cs_user | CsUser | openid、手机号 |
| cs_wallet | CsWallet | balance、frozen_amount、version(乐观锁) |
| cs_wallet_log | CsWalletLog | 流水:冻结/解冻/充值 |
| cs_order | CsOrder | 充电订单 |
| cs_recharge_order | CsRechargeOrder | 充值订单 |
| cs_charge_record | CsChargeRecord | 充电记录(与协议 chargeRecordId 对应) |
| cs_station | CsStation | 充电站 |
| cs_station_price | CsStationPrice | 分时电价 |
| cs_connector | CsConnector | 充电枪 |
| cs_alarm | CsAlarm | 告警 |
工程化配置:逻辑删除(global-config.logic-delete-field)、自动填充(createTime/updateTime)、雪花 ID(ASSIGN_ID)。
📌 数据库初始化说明
全项目未提供独立 .sql 建表脚本(无 Flyway/Liquibase),表结构由 Entity 注解定义。如需在新环境初始化数据库,可通过 JPA 的 ddl-auto=update 自动建表,或从 Entity 反向生成 DDL。
七、AdminController 的 mock 缺陷
355 行的 AdminController 中,以下接口返回写死数据(代码注释"供前端联调,后续可接真实表"):
| 接口 | 状态 |
|---|---|
| /admin/login | ⚠️ 固定账号 admin/Admin@123 硬编码,不查库 |
| /admin/statistics | ⚠️ Math.random() 生成趋势数据 |
| /admin/admins | ⚠️ mock |
| /admin/roles | ⚠️ mock |
| /admin/permissions | ⚠️ mock |
| /admin/op-logs | ⚠️ mock |
| /admin/configs | ⚠️ mock |
| /admin/overview 等 | ✅ 真实查库 |
八、常见问题与解答
- 微信 code2Session 的 code 能复用吗?(不能,一次性;且 5 分钟有效)
- JWT 和 Session 的区别?(状态 vs 无状态、分布式友好性、过期后失效问题)
- 乐观锁的 version 字段初始值?更新策略?(默认 0,每次成功更新 +1)
- 冻结金额和余额是什么关系?(balance 可用 + frozen_amount 冻结,下单扣冻结额而非直接扣 balance)
- 为什么 charge-user 是唯一有全局异常处理的模块?(其他模块没写------工程化不统一)
小结
用户服务是工程化最完整的一层:JWT 鉴权、乐观锁钱包、事务订单,加上唯一一个全局异常处理。同时它也是隐患最集中处------mock 数据、明文密钥、无建表脚本。下一篇 【从零搭建物联网智能充电桩系统】7、IoTDB 时序库与双端前端讲解数据存储选型与两个前端工程。