
简介面向中小企业及个人开发者提供一套基于SpringBootMyBatisRedis的完整可运行Java微信小程序商城系统源码涵盖小程序端、Vue中后台与Java后台三端适合具备基础Java/前端知识的开发者学习或二次开发后台整合商品、订单、运费模板、规格、会员、运营、内容、统计报表、权限和设置等完整模块有助于理解真实电商系统的前后端交互与权限设计。包内共1529个文件压缩后约16.33MB其中404个Java文件负责业务逻辑、167个Vue文件构成中后台页面、187个JS脚本处理交互逻辑、61个XML与14个YML文件承载配置信息另有50个WXML小程序页面、SQL数据库脚本及Dockerfile等部署辅助文件结构覆盖前端、后端、小程序与运维配置。目前已有615人学习资源可直接导入开发工具运行并配合数据库初始化脚本与部署配置带来从代码阅读、环境搭建到功能扩展的完整学习路径。1. 小程序商城不是拼页面是拼后端设计很多人拿到一套“微信小程序商城”项目第一反应是去翻小程序的wxml和js却忽略了真正决定这套系统能不能撑住订单量的是后端怎么设计。这套 Java微信小程序商城系统源码采用三端分离后端用 Spring Boot MyBatis Redis中后台用 Vue Element UI小程序端是独立工程。这个拆法不是故意增加工作量而是让商品管理、订单流转、权限控制各司其职避免以后促销活动一上所有接口都在一个服务里互相挤占。接下来我会从后端鉴权、商品订单模型、前后端对接一直讲到打包部署和缓存坑适合准备拿这套源码二次开发或者想自己搭一套商城脚手架的 Java 工程师。2. 后端骨架与登录鉴权实现2.1 为什么把 admin 和 api 分成两个服务我在实际拆 mall4j 这类项目时最关注的是它如何隔离“管理端”和“用户端”。管理员操作商品、订单、运费模板流量小但并发要求不高小程序用户调用的商品浏览、加购、下单则是高并发主路径。如果把两套接口塞在同一个 Spring Boot 工程里权限校验和限流策略会互相干扰。常见的做法是拆成几个模块每个模块负责一类边界模块职责依赖framework公共配置、redis、mybatis、工具类无业务依赖admin-api管理端接口供 VueElement UI 调用frameworkwechat-api小程序/API 端接口frameworkbusiness-core订单、商品、会员等核心业务逻辑framework这种分离的优点在于管理端可以单独加RequiresPermissions做细粒度权限控制用户端则不必每次请求都扫描一遍后台所有菜单权限。同时部署时可以将管理端接口和用户端接口放在不同端口配合 Nginx 分开限流。2.2 Spring Boot MyBatis Redis 的配置细节登录鉴权依赖 Redis 存储 token 和验证码。application.yml里最关键的是 Redis 序列化策略很多二次开发的同学直接用默认的 JDK 序列化导致存入 Redis 的数据无法在命令行里直观检查甚至 key 出现乱码。spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 3000ms redis-template: key-serializer: org.springframework.data.redis.serializer.StringRedisSerializer value-serializer: com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.mall4j.admin.**.entity configuration: map-underscore-to-camel-case: true cache-enabled: false这里用GenericFastJsonRedisSerializer而不是 JDK 序列化器是为了让存进去的对象能被其他服务比如管理后台查询在线用户直接读取。cache-enabled: false是 MyBatis 二级缓存开关商城这种场景我一般建议关掉因为商品和订单的数据一致性要求高命中缓存用的应该是 Redis而不是 MyBatis 自带的本地缓存。2.3 JWT 登录与 Redis 刷新登录流程是用户用微信小程序登录时前端调起wx.login拿到临时 code传给后端后端通过这个 code 换取 openid再生成一个 JWT token。JWT 本身是明文签名如果只靠它自身过期无法解决用户注销和封禁问题所以要将 token 的 jti 存到 Redis。Service public class WxLoginService { Autowired private StringRedisTemplate redisTemplate; public LoginResponse wxLogin(String code) { String openid wechatClient.code2Session(code).getOpenid(); String userId userService.findOrCreate(openid); String jti UUID.randomUUID().toString().replace(-, ); // 设置半年过期但活动期间可以通过续签延长 redisTemplate.opsForValue().set(wx_token: jti, userId, 180, TimeUnit.DAYS); String token Jwts.builder() .setId(jti) .setSubject(userId) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); return new LoginResponse(token); } }这段代码里setId(jti)对应 JWT 的jti声明Redis 的 key 用wx_token:前缀便于批量剔除。后面每次请求到达拦截器时先解析 JWT 拿到jti再去 Redis 里查有没有对应的 userId。如果管理员在后台把用户禁用只要删除这条 Redis 记录该用户立刻失效不用等 JWT 自然过期。2.4 分页查询与缓存失效场景管理端商品列表通常要支持按名称、状态、价格区间筛选。MyBatis Plus 的Page对象配合 XML 里的selectPage很方便但我建议在 mapper XML 里手写 JOIN 查询而不是用 MyBatis Plus 的 Wrapper 直接查主表因为 SKU 表需要聚合查询。select idselectProductPage resultTypecom.mall4j.admin.product.vo.ProductVO SELECT p.product_id, p.product_name, p.shop_price, (SELECT SUM(s.stock) FROM sku s WHERE s.product_id p.product_id) AS total_stock FROM product_info p WHERE p.status #{status} ORDER BY p.update_time DESC /select这个查询里total_stock是子查询求和如果商品数量大N1 压力会很明显。我先把它用在管理后台列表展示用户端的小程序商品详情接口则直接查 Redis 缓存。3. 核心商品、运费与订单模型3.1 SKU 与规格值的数据结构电商系统最核心的表不是商品表而是 SKU 表。拿一件 T 恤举例颜色有黑/白尺码有 S/M/L光这些组合就是 6 个 SKU。每组规格组合对应不同的库存和价格必须拆分。CREATE TABLE sku ( sku_id bigint(20) NOT NULL AUTO_INCREMENT, product_id bigint(20) NOT NULL COMMENT 商品ID, price decimal(10,2) NOT NULL COMMENT 售价, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存, spec_key varchar(128) DEFAULT NULL COMMENT 规格键如:4_6_7, spec_value varchar(255) DEFAULT NULL COMMENT 规格值如:黑色_S, PRIMARY KEY (sku_id), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;spec_key是规格项 ID 的组合比如颜色 ID 是 4尺码 ID 是 6那一格就是4_6。前台拿到这个 SKU 后可以根据spec_key去查询规格名称前端渲染成可点击的规格按钮。这里要注意价格和库存必须冗余在sku表不能只存规格键再去关联查因为下单时库存扣减需要锁定当前行。3.2 运费模板的计算逻辑运费模板可能是这套系统里最容易被低估的模块。按件计费、按重量计费、满额包邮、指定地区特殊运费这些规则组合在一起如果用硬编码会非常痛苦。mall4j 的做法是把运费模板拆成地区和规则两张表。public BigDecimal calcFreight(OrderCreateVO order) { ListCartItem items order.getCartItems(); int totalPiece items.stream().mapToInt(CartItem::getCount).sum(); BigDecimal totalWeight items.stream() .map(item - item.getWeight().multiply(BigDecimal.valueOf(item.getCount()))) .reduce(BigDecimal.ZERO, BigDecimal::add); FreightTemplate template freightMapper.selectById(order.getFreightTemplateId()); // 先判断是否包邮 if (order.getTotalPrice().compareTo(template.getFreePriceLimit()) 0) { return BigDecimal.ZERO; } // 按件数计费 if (piece.equals(template.getChargeType())) { BigDecimal extra template.getFirstUnit() .multiply(BigDecimal.valueOf(Math.max(0, totalPiece - template.getFirstCount()))); return template.getFirstFee().add(extra); } // 按重量计费逻辑类似这里省略 return BigDecimal.ZERO; }常用的参数含义是chargeType决定计费方式firstCount为首件/首重数量firstFee为首件/首重费用extraUnit为续件/续重单位extraFee为续件/续重费用。实际项目中运费模板还需要把区域作为 JSON 数组存到字段里解析时用 H3 行政区划编码去匹配。如果某个订单收货地址匹配不到区域按兜底模板计费。3.3 订单状态机与事务一致性订单模块最容易出问题是超卖和状态错乱。超卖靠带有stock #{count}条件的 update 来防止Transactional public void createOrder(OrderCreateVO order) { for (CartItem item : order.getCartItems()) { int updated skuMapper.reduceStock(item.getSkuId(), item.getCount()); if (updated 0) { throw new BizException(商品库存不足); } } orderMapper.insert(order); // 写入支付回调后的订单状态表 orderStatusLogMapper.insert(new OrderStatusLog(order.getOrderId(), WAIT_PAY)); }reduceStock对应的 XML 是update idreduceStock UPDATE sku SET stock stock - #{count} WHERE sku_id #{skuId} AND stock #{count} /update库存扣减成功再创建订单事务提交后Redis 里的商品详情缓存必须同步置为失效。这里要小心 Spring 事务的传播行为同一个类内部调用createOrder方法时Transactional不会生效必须有独立的 Bean 类。另外订单状态流转建议用枚举维护不要散落硬编码字符串。状态说明可流转到WAIT_PAY待支付WAIT_SEND, CANCELWAIT_SEND待发货WAIT_RECEIVE, CANCELWAIT_RECEIVE待收货COMPLETE, REFUNDINGREFUNDING退款中REFUNDED, WAIT_SEND4. Vue 管理后台与小程序端对接4.1 动态路由与按钮级权限Vue Element UI 的管理后台登录后拿到用户的角色和权限码前端不能只根据角色去渲染菜单必须用后端返回的权限码来过滤动态路由。function filterRoutes(routes, permissionCodes) { const result [] routes.forEach(route { if (!route.meta || !route.meta.permission) { result.push(route) return } if (permissionCodes.includes(route.meta.permission)) { const childRoutes filterRoutes(route.children || [], permissionCodes) result.push({ ...route, children: childRoutes }) } }) return result }这里的permissionCodes是用户登录后从/api/menu/getUserMenu拿到的字符串数组。注意后端返回的菜单树需要带code字段前端再通过meta.permission来匹配路由。按钮级权限则用自定义指令v-permission控制比如“删除商品”按钮在无权限时直接移除 DOM而不是靠v-if判断防止用户手动修改前端代码后看到接口请求。4.2 小程序登录与请求封装小程序的app.js里先调wx.login然后把 code 发给后端换取 token最后拼接 header。为了让 401 能自动刷新 token请求封装需要统一处理。function request(url, method, data) { return new Promise((resolve, reject) { wx.request({ url: baseUrl url, method, data, header: { Authorization: wx.getStorageSync(token) }, success(res) { if (res.statusCode 401) { // 刷新token这里省略实现 wx.navigateTo({ url: /pages/login/index }) } else { resolve(res.data) } } }) }) }这里有个细节Authorization头部的值一定不要写死要从 storage 里动态读取。很多新人在 app.js 里用同步wx.getStorageSync(token)拿没问题但在请求回调里 token 可能已经变化所以要用闭包方式重新获取。最终下单时订单金额要以小程序端重新拉取的商品价格为准后端也要再校验一次否则改包可以修改请求参数。4.3 下单参数与库存扣减提交订单接口需要传的核心参数如下前端不能只传商品 ID 和数量参数类型说明skuIdLong唯一确定规格商品countInteger购买数量freightTemplateIdLong运费模板 IDaddressIdLong收货地址 IDuserRemarkString用户备注cartItemIdsListLong购物车选中项 ID小程序端把购物车选中项传给后端后端按cartItemIds查数据库校验归属用户再重新计算金额。我见过一个项目下单时只传productId结果同一件商品不同颜色是一个价用户选黑色下单收到的是白色。用skuId才能定位到具体库存和价格。下单成功后要清掉购物车中对应的cartItemIds如果清空逻辑放在前端用户中途退出会留下半清的购物车所以这个操作也要放在后端事务里。5. 打包部署与缓存一致性的几个坑5.1 三端打包的环境变量切换后端 Spring Boot 项目打包时不同环境要切换配置。我建议在bootstrap.yml里用spring.profiles.active选择 profile而不是手动改数据库地址。前端 Vue 项目则在vue.config.js里使用环境变量npm run build -- --mode production对应.env.production文件里写上VUE_APP_BASE_API/api小程序端则是在config/index.js里根据process.env.NODE_ENV切换 request 的 baseUrl。这里最常踩的坑是 Windows 打包后把/api写成了./api导致请求发到静态资源目录而不是网关。5.2 Redis 缓存穿透的极简解法商品详情如果不存在请求每次都会打到数据库。我一般会提前把空对象缓存到 Redis过期时间设成 60 秒这样恶意刷不存在的商品时Redis 就拦截住了。Cacheable(value product, key #productId, unless #result null) public ProductVO getProduct(Long productId) { ProductVO product productMapper.selectById(productId); if (product null) { // 缓存空值但不要用工具类直接返回 null否则 AOP 会当成未命中 return new ProductVO(); } return product; }注意unless条件判断的是返回值是否为空如果返回nullSpring Cache 仍然会执行方法。所以我用空对象代替 null并在业务方法里增加一个empty标记字段。另外每个 key 的过期时间要加随机值避免同一时刻大量商品缓存同时过期导致数据库压力瞬间拉满。5.3 MyBatis 单个数字字符比较的坑在 MyBatis XML 里写IF条件时如果参数是数字字符直接比较会出现问题if teststatus 1 AND p.status 1 /if当status的类型是String并且值是1时MyBatis OGNL 会把它当作数字 1 比较结果可能不成立。正确的写法是给条件加引号if teststatus 1 AND p.status 1 /if这个问题在 order 状态和商品状态的筛选接口里经常出现。排查方法也很简单打开 MyBatis 的日志把最终 SQL 输出出来如果该筛选条件没有生效多半就是 OGNL 类型转换问题。处理完这一处再检查一下 controller 里接收的RequestParam类型建议直接声明为Integer从源头避免类型歧义。本文还有配套的精品资源点击获取