ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+MyBatis+Redis构建微信小程序商城:状态管理与落地实践

SpringBoot+MyBatis+Redis构建微信小程序商城:状态管理与落地实践 简介一份面向中小企业及开发者的微信小程序电商系统完整源码后台采用Spring Boot、MyBatis、Redis管理端基于Vue与Element UI小程序端独立成包。系统覆盖商品管理、订单管理、运费模板、规格、会员、运营、内容、统计报表、权限等模块既能快速搭建商用商城也可作为学习前后端分离架构的实战案例。压缩包共含1529个文件整体仅16.33MB其中Java源码404个、Vue文件167个、JS文件187个还有XML配置、SQL脚本、WXML/WXSS页面文件及PNG图标等目录按mall4j/mall4v/mall4m三端分层结构清晰。目前已有615人学习下载适合需要完整电商前后端代码的读者参考。这套资源把后端服务、管理后台、小程序端完整打包重点模块如商品规格、运费模板、权限控制的实现都有迹可循方便二次开发、毕业设计或教学演示时直接参考。1. 商城系统的复杂度和状态数成正比这套技术栈把状态管住了做过几个商城项目会得出一个反直觉的结论真正让系统变复杂的不是SKU数量而是状态数量。用户登录态、购物车状态、库存状态、订单状态、支付回调状态——任何一个状态在并发下错位用户看到的结果就是错的。Java微信小程序商城这套组合后端 springbootmybatisredis前端 vueelement ui之所以常见是因为它把状态管理拆得很干净redis 管瞬时状态MySQL 管持久状态vue 只管界面状态。springboot 负责接口装配mybatis 负责把 SQL 控制权还给开发人员redis 承担 token、购物车和热点数据。本文按这个思路把“小程序端管理后台”的落地路径讲清楚适合准备做小程序商城的 Java 后端也适合想搞清楚这套栈里每个组件到底干了什么的全栈工程师。2. 后端工程骨架与数据库建模springbootmybatisredis 的落点2.1 为什么是这三个组件的组合而不是别的springboot 在 2020 年之后几乎是 Java Web 工程的默认起点它解决的是“配置地狱”和“依赖装配”问题。一个商城后台需要处理登录、商品、订单、支付回调、物流、售后这些模块如果全部手写 Spring XML 配置维护成本会迅速超过业务本身。springboot 的自动配置把数据源、事务、Web 容器都收敛到 application.yml 里团队新成员看一眼配置就能上手。mybatis 的价值在于 SQL 可控。商城业务里最常出现的不是简单 CRUD而是带条件的动态查询按分类、价格区间、关键字、销量排序这些查询的 SQL 在不同组合下差别很大。mybatis 的 XML 映射文件可以用if、choose动态拼 SQL比 JPA 的派生查询更适合让有经验的开发人员做 SQL 级优化。另外订单表的分页、订单项和商品表的关联查询在 XML 里写清楚执行计划是什么样一眼就能看出来。redis 在商城里的角色是“状态加速器”。登录 token 用 redis 存天然支持过期时间不用定期扫表清理购物车用 redis 的 Hash 结构读写都是内存级延迟商品详情这种读多写少的数据用 redis 字符串缓存可以挡掉 90% 的数据库压力秒杀场景下扣减库存用 redis 的原子操作避免超卖。把这三个组件组合起来本质上是把“持久化”和“瞬时状态”分层管理。2.2 工程目录结构怎么划分一个可维护的商城后台我一般按 module 划分而不是按 controller/service/mapper 三层堆在一起。下面是常见做法mall-admin/ ├── src/main/java/com/mall/ │ ├── config/ # redis、mybatis、web 拦截器配置 │ ├── controller/ # 接口层 │ │ ├── admin/ # 管理后台接口 │ │ └── applet/ # 小程序端接口 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # mybatis mapper 接口 │ ├── entity/ # 数据库实体 │ ├── dto/ # 接口入参出参 │ ├── common/ # 统一返回结构、异常处理 │ └── util/ # 工具类 ├── src/main/resources/ │ ├── mapper/ # mybatis XML │ ├── application.yml │ └── banner.txt └── pom.xmlcontroller 层按 admin 和 applet 分目录是因为两端的鉴权方式不同管理后台走账号密码登录小程序端走微信登录。分开以后拦截器注册时可以只拦截 applet 路径避免后台管理员被微信登录逻辑干扰。2.3 商城核心表设计商城系统的表一般不少于 12 张但最核心的是下面 6 张表名核心字段说明userid, openid, unionid, nickname, phoneopenid 唯一索引goodsid, category_id, name, price, stock, statusstatus 控制上架下架cartid, user_id, goods_id, quantity冗余 goods_name 用于展示ordersid, order_no, user_id, total_amount, statusorder_no 唯一索引order_itemid, order_id, goods_id, goods_name, price, quantity下单时快照商品信息payment_logid, order_no, pay_type, trade_no, amount, status记录支付回调商品表里的 price 用 decimal(10,2)不要用 float。订单表里的 status 用 tinyint加注释约定状态含义0待支付、1已支付、2已发货、3已完成、4已取消、5退款中。订单项表必须冗余商品名称和价格因为商品改价或删除后订单历史不能跟着变。2.3.1 订单号生成规则订单号是商城系统的隐性难点。直接用数据库自增 ID 会暴露销量而且跨分表时冲突。常见做法是日期 用户ID后四位 随机数 自增序列。redis 的 INCR 命令可以生成自增序列天然支持过期重置public String generateOrderNo(Long userId) { String dateStr LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String key order:seq: dateStr; Long seq redisTemplate.opsForValue().increment(key); redisTemplate.expire(key, 2, TimeUnit.DAYS); return dateStr String.format(%04d, userId % 10000) String.format(%04d, seq % 10000); }逻辑说明先取当天日期的 8 位字符串用 userId 取模补四位再用 redis 的自增值补四位。redis 的 increment 是原子操作并发下不会重复设置两天过期是为了防止 key 无限增长。这样生成的订单号可读、无序、带日期维度后续按天分表也用得上。2.4 mybatis XML 里的动态 SQL 写法商品列表查询是商城接口的基础。前台要做分类筛选和价格排序后台要做上下架管理两种场景共用一张表但查询条件不同。下面是商品列表查询的 XMLselect idlistGoods resultTypecom.mall.entity.Goods SELECT id, category_id, name, price, stock, status FROM goods where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY choose when testsortBy salessales_count DESC/when when testsortBy priceprice ASC/when otherwisecreate_time DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /select逻辑说明where标签会自动去除第一个 AND避免出现WHERE AND的 SQL 语法错误。choose是做排序字段白名单防止用户传入任意字段名导致 SQL 注入。分页用 LIMIT 加偏移量数据量超过 200 万之后要换成基于游标的分页但绝大多数商城项目用不到这一步。注意mybatis 里 LIKE 查询用 CONCAT 拼接不要直接写LIKE %#{keyword}%那样会被当作参数占位符处理SQL 直接报错。2.5 redis 在 springboot 中的配置切入点在 application.yml 里配置 redis 连接信息然后写一个配置类把 RedisTemplate 的序列化方式改成 JSONspring: redis: host: 127.0.0.1 port: 6379 password: ${REDIS_PASSWORD:} database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2工程里最好只用两种 key 前缀user:token:{openid}和cart:{userId}再加一个可选的goods:detail:{id}热点缓存。规范 key 前缀的作用是排查问题的时候能一眼看出是哪类数据redis-cli 里keys user:*就能列出所有登录态。pool 参数的 max-active 要大于业务高峰期预估的并发线程数否则 redis 连接会变成瓶颈。3. 微信小程序登录态与商品接口从 wx.login 到 redis 里的 token3.1 小程序登录的标准流程微信小程序商城和传统的账号密码登录不同它依赖微信提供的登录凭证。整体流程分四步小程序端调用wx.login拿到临时 code把 code 传到后端后端用 appid 和 secret 请求微信接口换取 openid 和 session_key后端生成自己的 token 返回给小程序端。后续所有请求都带这个 token而不是把 openid 放在前端每次传过来。这个流程的关键点是code 只能使用一次有效期 5 分钟。后端拿到 code 后没有缓存它这个概念直接用一次就作废。session_key 是微信用于解密手机号等敏感数据的密钥后端拿到后可以丢弃因为商城系统核心用 openid 标识用户身份。3.2 小程序前端 wx.login 代码在 app.js 的 onLaunch 里做登录不太够因为 onLaunch 执行时页面的 onLoad 已经开始了会出现“小程序刚进入的加载页面拿不到登录态”的问题。常见做法是封装一个 promise 化的登录函数function login() { return new Promise((resolve, reject) { wx.login({ success: (res) { if (res.code) { wx.request({ url: https://api.example.com/applet/login, method: POST, data: { code: res.code }, success: (resp) { const { token } resp.data.data wx.setStorageSync(token, token) resolve(token) }, fail: reject }) } else { reject(new Error(微信登录失败)) } }, fail: reject }) }) }逻辑说明先通过 wx.login 拿临时 code再把 code 发给后端接口换取自定义 token。token 存到本地 Storage后续请求从 Storage 读出放入 header。这段代码必须在 app.js 里先执行完再跳转页面否则首页接口会报 401。注意这里的方式操作的关键是 promise 化避免回调嵌套导致页面时序混乱。3.2.1 修改刚进入的加载页面如果小程序启动后第一个页面是首页首页 onLoad 里又要拿商品列表但此时 token 还没写入 Storage会导致首页接口 401。常见做法是做一个启动 loading 页登录成功前展示加载状态登录成功后用wx.reLaunch跳转到真实首页。也可以用wx.getStorageSync(token)判断本地是否已有合法 token有则跳过登录请求直接进入。3.3 后端 code2session 与 token 生成后端收到 code 后调用微信接口获取 openidPostMapping(/applet/login) public R login(RequestBody LoginDTO dto) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String result restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(result); String openid json.getString(openid); // 查询或创建用户 User user userMapper.selectByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); userMapper.insert(user); } // 生成 token 并存入 redis过期时间 7 天 String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue().set(user:token: token, user.getId().toString(), 7, TimeUnit.DAYS); return R.ok().put(token, token); }逻辑说明jscode2session 接口返回的内容里包含 openid、session_key 和 unionid如果关联了开放平台。这里用 UUID 去掉横杠后做 token把 token 作为 key、userId 作为 value 存入 redis设置 7 天过期。为什么不直接把 openid 当 token因为 openid 是明文别人拿到后可以伪造请求头。UUID 随机串不可猜测redis 过期时间也同时实现了“7 天免登录”效果。提示微信登录接口的 appid 和 secret 要放在后端配置里绝对不要写在小程序前端代码中。小程序代码经过压缩后字符串常量仍然能被人读到secret 泄露会导致任意账号被登录。3.4 商品接口的缓存读取策略商品详情是商城系统读流量最大的接口。每次请求都打数据库MySQL 的 TPS 很快会被打满。常见做法是缓存优先数据库兜底public Goods getGoodsDetail(Long goodsId) { String key goods:detail: goodsId; String cached redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(cached)) { return JSON.parseObject(cached, Goods.class); } Goods goods goodsMapper.selectById(goodsId); if (goods ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES); } return goods; }逻辑说明先从 redis 读没有命中再查库查到后写回缓存并设置 30 分钟过期。写入 JSON 字符串而不是序列化对象因为 JSON 可读性好排查问题的时候可以直接在 redis-cli 里看内容。30 分钟过期时间意味着商品改价后最多 30 分钟生效后台编辑商品时要主动删除对应缓存 key。更稳妥的做法是后台改商品时调用redisTemplate.delete(key)删除而不是等待过期因为删除缓存后下一次请求会自动回源数据库。3.5 购物车用 Hash 结构而不是 String购物车里一个商品对应一个数量天然适合 Hash 结构field 为商品 IDvalue 为数量。这样加购和改数量都只需要操作一个 field不需要整个购物车读出来再写回public void addCart(Long userId, Long goodsId, Integer quantity) { String key cart: userId; redisTemplate.opsForHash().increment(key, goodsId.toString(), quantity); }逻辑说明opsForHash().increment 是对 Hash 里的某个 field 做自增操作原子且不需要先读后写。商品数量为 0 时删除 field购物车结算时一次性读出所有 field再批量查商品表拿价格。注意购物车的商品数量在 Redis 里如果 redis 重启会丢所以提交订单前要重新从 MySQL 校验商品状态和库存。4. 管理后台 vueelement ui订单、商品、权限一张表管起来4.1 element ui 后台的工程结构管理后台的前端用 vue 加 element ui是电商领域最常见的搭配。vue 负责组件化页面element ui 提供表格、表单、弹窗、分页这些现成组件。工程内部一般按“页面 API 路由”三层组织src/ ├── api/ # 封装 axios 请求 │ ├── login.js │ ├── goods.js │ └── order.js ├── router/ # 路由配置带权限守卫 ├── views/ │ ├── Login.vue │ ├── goods/ │ │ ├── GoodsList.vue │ │ └── GoodsEdit.vue │ ├── order/ │ │ └── OrderList.vue │ └── dashboard/ ├── utils/ │ └── request.js # axios 实例 └── main.jsapi 目录按业务模块拆分的意义在于后端接口路径如果从/goods/list改成/admin/goods/list只需要改一个文件页面组件完全不感知。element ui 的项目几乎不需要自己写样式表格和表单组件直接组合就能达到后台管理系统的要求。4.2 axios 拦截器统一处理 token 和异常管理后台和小程序端的鉴权方式不同小程序端用 token管理后台用登录后返回的 JWT 或同款 token。axios 拦截器的作用是统一附加请求头和统一处理 401// utils/request.js import axios from axios import { MessageBox } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(admin_token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data // 业务码非 0 视为失败 if (res.code ! 0) { MessageBox.alert(res.msg, 请求失败, { type: error }) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(admin_token) router.push(/login) } return Promise.reject(error) } ) export default service逻辑说明请求拦截器从 localStorage 读取管理员 token附加到 Authorization 头。响应拦截器做两层处理业务返回码非 0 说明后端逻辑执行失败弹出错误提示HTTP 状态码 401 说明 token 缺失或过期清掉 token 并跳回登录页。timeout 设 15 秒因为后台列表导出等操作偶尔会比较慢太短的超时时间会导致误报。提示管理后台的 token 不要用 localStorage 以外的方案存储。sessionStorage 会在关闭浏览器后失效而后台管理系统通常希望保持一周左右的登录状态。4.3 商品列表页与分页组件的组合商品列表页面是后台使用频率最高的页面element ui 里的 el-table 加 el-pagination 是标准组合。表格需要展示商品图片、名称、价格、库存、状态还要支持上下架操作。分页参数直接对应后端接口的 pageNum 和 pageSizetemplate div classgoods-list el-table :datalist v-loadingloading el-table-column propname label商品名称 min-width180 / el-table-column propprice label价格 width100 / el-table-column propstock label库存 width100 / el-table-column label状态 width90 template slot-scope{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 上架 : 下架 }} /el-tag /template /el-table-column el-table-column label操作 width160 template slot-scope{ row } el-button typetext clicktoggleStatus(row)上下架/el-button el-button typetext clickhandleEdit(row)编辑/el-button /template /el-table-column /el-table el-pagination background layouttotal, prev, pager, next :totaltotal :current-page.syncquery.pageNum :page-sizequery.pageSize current-changeloadList / /div /template逻辑说明el-table-column 的 prop 直接绑定后端返回的字段名slot-scope 方式可以拿到当前行数据做自定义渲染。上下架按钮点击后调用后端接口成功后重新加载列表。分页组件通过 current-page.sync 双向绑定页码页码变化时触发 loadList 方法重新请求接口。需要注意的后端设计列表接口必须返回总条数 total由 SELECT COUNT 查出来前端分页组件才知道总共多少页。如果 total 每次都返回 0分页器会显示“共 0 条”看起来像 bug 实际上是没有查总数。4.4 订单状态流转的后台实现订单状态是商城后台逻辑最重的模块。订单表里 status 字段定义好枚举含义后后台要做的是“流转控制”而不是直接改状态。比如待支付订单不能直接改为已完成必须先支付、发货、然后完成。下面是一个简单的状态流转接口PostMapping(/admin/order/updateStatus) public R updateStatus(RequestBody OrderStatusDTO dto) { // dto: orderNo, targetStatus, operateType(发货/取消/完成) Orders order orderMapper.selectByOrderNo(dto.getOrderNo()); if (order null) { return R.error(订单不存在); } // 校验当前状态到目标状态是否合法 boolean allowed checkTransition(order.getStatus(), dto.getTargetStatus()); if (!allowed) { return R.error(状态不合法当前订单状态为 OrderStatusEnum.getName(order.getStatus())); } orderMapper.updateStatus(dto.getOrderNo(), dto.getTargetStatus()); // 记录操作日志 orderLogMapper.insertLog(dto.getOrderNo(), order.getStatus(), dto.getTargetStatus()); return R.ok(); } private boolean checkTransition(Integer current, Integer target) { // 0待支付 - 1已支付 / 4已取消 // 1已支付 - 2已发货 // 2已发货 - 3已完成 return (current 0 (target 1 || target 4)) || (current 1 target 2) || (current 2 target 3); }逻辑说明用显式的状态流转表控制订单状态。这种写法的优点是非法操作会被拦截比如待支付订单直接点发货会报错。状态变更记录在 order_log 表里售后纠纷时能查到谁在什么时间做了什么操作。建议把状态流转表定义成枚举类或配置表不要散落在业务代码各个方法里。后续如果想支持部分退款、退货等复杂状态只需要加枚举值并修改 checkTransition 方法即可。4.4.1 mybatis 的 update 语句返回影响行数状态更新的 mapper 方法返回值建议用 int 接收影响行数。如果影响行数为 0说明订单号不存在或状态已经被改过并发下两个请求同时改同一单。进一步还可以利用 SQL 层面的乐观锁update idupdateStatusByNo UPDATE orders SET status #{targetStatus}, update_time NOW() WHERE order_no #{orderNo} AND status #{currentStatus} /update增加AND status #{currentStatus}条件后即使两个请求同时到达也只有一个能更新成功另一个影响行数为 0。前端此时可以提示“订单状态已变化请刷新后重试”。这个写法不依赖 redis 分布式锁解决相同订单重复操作的简单场景足够了。4.5 管理后台和小程序端共用哪些接口共用的接口主要是商品查询和分类查询后台列表页需要知道有哪些商品小程序的商品列表也需要同样数据。差异在两端对不同状态的商品可见性不同——小程序端只显示 status1 的商品后台可以看到全部。可以用同一个接口加参数控制比如needAlltrue时返回全部状态小程序端不传该参数默认只返回上架商品。订单相关接口必须分开绝不能共用因为后台需要看到所有用户订单小程序端只能看到自己的订单。5. 缓存一致性、库存幂等与上线前的验证清单5.1 Redis 缓存与 MySQL 的最终一致性做法商城系统最常见的缓存一致性问题是后台改了商品价格小程序端展示还是旧价格。Redis 缓存和 MySQL 要做到强一致几乎不现实折中方案是“更新数据库后主动删缓存”。注意是删缓存而非更新缓存——更新缓存可能写入失败删除缓存即使失败下次查询也会自动回源数据库重新构建缓存。这个方案在数据一致性要求没那么高的场景下足够用。商品上下架操作可以按下面顺序执行// 1. 更新 MySQL 状态 goodsMapper.updateStatus(goodsId, targetStatus); // 2. 删除缓存等待下次查询回源 redisTemplate.delete(goods:detail: goodsId); // 3. 删除首页列表缓存如果有 redisTemplate.delete(goods:list:hot);执行顺序是“先库后缓存”。如果先删缓存再更新库在更新库之前有请求进来会把旧数据重新写回缓存导致缓存里又变成旧值。修改价格时同样遵循这个顺序。极端情况下如果删除缓存失败可以配合消息队列重试但对小型商城来说记录日志并手动处理已经足够。5.2 库存扣减的原子操作与重复提交防护库存扣减是商城系统里最需要小心并发的地方。以“减库存再创建订单”为例两个请求同时读到库存为 1各自判断库存足够然后同时减库存就会超卖。常见做法是把扣减操作收敛到一条 SQL 条件更新里public boolean deductStock(Long goodsId, Integer quantity) { int rows goodsMapper.deductStock(goodsId, quantity); return rows 1; }update iddeductStock UPDATE goods SET stock stock - #{quantity} WHERE id #{goodsId} AND stock #{quantity} /update逻辑说明SQL 的AND stock #{quantity}条件保证了只有库存足够时才能扣减成功。返回影响行数为 1 说明扣减成功为 0 说明库存不足此时不能创建订单。这个做法把判断和扣减合并成一条语句天然具备原子性不需要额外的分布式锁。多次点击下单按钮会导致重复创建订单。常见做法是后端幂等校验前端提交订单时带一个由时间戳和随机数生成的 requestId后端在 redis 里用setIfAbsent占位相同 requestId 的请求第二次会被拒绝// key 设置 10 分钟过期覆盖用户从下单到支付完成的完整周期 Boolean first redisTemplate.opsForValue() .setIfAbsent(order:req: requestId, 1, 10, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(first)) { return R.error(请勿重复提交); }setIfAbsent 只在 key 不存在的时候才会设置成功。第一个请求设置成功后占住位置第二个请求到来时发现 key 已存在直接返回失败。下单接口里还要加一层数据库层面的唯一索引防止更极端的情况——requestId 可以加到 orders 表里的一个字段并建唯一索引数据库兜底保证不会重复创建。5.3 上线前用哪些手段验证这套系统先做接口层面的验证再做压测。接口验证用 Postman 或 Apifox 编写一个简单的测试流程用户登录拿 token创建购物车提交订单模拟支付回调查看订单状态变化。这个流程能跑通说明核心链路没有断。压测方面Jmeter 里设置一个商品列表接口的线程组200 个线程并发循环 50 次观察三个指标90% 响应时间是否在 500ms 以内、错误率是否低于 1%、redis 的命中率是否在 80% 以上。如果响应时间变慢优先查看 MySQL 慢查询日志看列表接口有没有走到全表扫描如果 redis 命中率低检查缓存 key 是否被频繁删除。这里加一个小的压测条件思路便于判断是代码问题还是配置问题。最后检查 redis 的 key 数量是否持续增长。正常情况下user:token:前缀的 key 应该有 7 天过期自动清理cart:前缀的 key 数量与活跃用户数相关。如果某个前缀的 key 数量只增不减说明漏了过期时间设置这是商城项目最常踩的坑。用redis-cli --scan --pattern user:token:* | wc -l配合两次查看的时间差就能确认。记住一点商城系统的日常维护里Redis 的 key 数量和命中率比任何监控面板都更能反映问题。本文还有配套的精品资源点击获取
返回列表