Java微信小程序商城系统源码解析:从鉴权到订单设计
2026/9/16 1:36:38 网站建设 项目流程

简介:面向中小企业及个人开发者,提供一套基于SpringBoot+MyBatis+Redis的完整可运行Java微信小程序商城系统源码,涵盖小程序端、Vue中后台与Java后台三端,适合具备基础Java/前端知识的开发者学习或二次开发;后台整合商品、订单、运费模板、规格、会员、运营、内容、统计报表、权限和设置等完整模块,有助于理解真实电商系统的前后端交互与权限设计。包内共1529个文件,压缩后约16.33MB,其中404个Java文件负责业务逻辑、167个Vue文件构成中后台页面、187个JS脚本处理交互逻辑、61个XML与14个YML文件承载配置信息,另有50个WXML小程序页面、SQL数据库脚本及Dockerfile等部署辅助文件,结构覆盖前端、后端、小程序与运维配置。目前已有615人学习,资源可直接导入开发工具运行,并配合数据库初始化脚本与部署配置,带来从代码阅读、环境搭建到功能扩展的完整学习路径。

1. 小程序商城不是拼页面,是拼后端设计

很多人拿到一套“微信小程序商城”项目,第一反应是去翻小程序的wxmljs,却忽略了真正决定这套系统能不能撑住订单量的,是后端怎么设计。这套 Java微信小程序商城系统源码采用三端分离:后端用 Spring Boot + MyBatis + Redis,中后台用 Vue + Element UI,小程序端是独立工程。这个拆法不是故意增加工作量,而是让商品管理、订单流转、权限控制各司其职,避免以后促销活动一上,所有接口都在一个服务里互相挤占。接下来我会从后端鉴权、商品订单模型、前后端对接一直讲到打包部署和缓存坑,适合准备拿这套源码二次开发,或者想自己搭一套商城脚手架的 Java 工程师。

2. 后端骨架与登录鉴权实现

2.1 为什么把 admin 和 api 分成两个服务

我在实际拆 mall4j 这类项目时,最关注的是它如何隔离“管理端”和“用户端”。管理员操作商品、订单、运费模板,流量小但并发要求不高;小程序用户调用的商品浏览、加购、下单,则是高并发主路径。如果把两套接口塞在同一个 Spring Boot 工程里,权限校验和限流策略会互相干扰。

常见的做法是拆成几个模块,每个模块负责一类边界:

模块职责依赖
framework公共配置、redis、mybatis、工具类无业务依赖
admin-api管理端接口,供 Vue+Element UI 调用framework
wechat-api小程序/API 端接口framework
business-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 id="selectProductPage" resultType="com.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是子查询求和,如果商品数量大,N+1 压力会很明显。我先把它用在管理后台列表展示,用户端的小程序商品详情接口则直接查 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

spec_key是规格项 ID 的组合,比如颜色 ID 是 4,尺码 ID 是 6,那一格就是4_6。前台拿到这个 SKU 后,可以根据spec_key去查询规格名称,前端渲染成可点击的规格按钮。这里要注意,价格和库存必须冗余在sku表,不能只存规格键再去关联查,因为下单时库存扣减需要锁定当前行。

3.2 运费模板的计算逻辑

运费模板可能是这套系统里最容易被低估的模块。按件计费、按重量计费、满额包邮、指定地区特殊运费,这些规则组合在一起,如果用硬编码会非常痛苦。mall4j 的做法是把运费模板拆成地区和规则两张表。

public BigDecimal calcFreight(OrderCreateVO order) { List<CartItem> 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 id="reduceStock"> UPDATE sku SET stock = stock - #{count} WHERE sku_id = #{skuId} AND stock >= #{count} </update>

库存扣减成功再创建订单,事务提交后,Redis 里的商品详情缓存必须同步置为失效。这里要小心 Spring 事务的传播行为:同一个类内部调用createOrder方法时,@Transactional不会生效,必须有独立的 Bean 类。另外订单状态流转建议用枚举维护,不要散落硬编码字符串。

状态说明可流转到
WAIT_PAY待支付WAIT_SEND, CANCEL
WAIT_SEND待发货WAIT_RECEIVE, CANCEL
WAIT_RECEIVE待收货COMPLETE, REFUNDING
REFUNDING退款中REFUNDED, WAIT_SEND

4. 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运费模板 ID
addressIdLong收货地址 ID
userRemarkString用户备注
cartItemIdsList<Long>购物车选中项 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条件判断的是返回值是否为空,如果返回null,Spring Cache 仍然会执行方法。所以我用空对象代替 null,并在业务方法里增加一个empty标记字段。另外每个 key 的过期时间要加随机值,避免同一时刻大量商品缓存同时过期,导致数据库压力瞬间拉满。

5.3 MyBatis 单个数字字符比较的坑

在 MyBatis XML 里写IF条件时,如果参数是数字字符,直接比较会出现问题:

<if test="status == 1"> AND p.status = 1 </if>

status的类型是String,并且值是"1"时,MyBatis OGNL 会把它当作数字 1 比较,结果可能不成立。正确的写法是给条件加引号:

<if test='status == "1"'> AND p.status = 1 </if>

这个问题在 order 状态和商品状态的筛选接口里经常出现。排查方法也很简单,打开 MyBatis 的日志,把最终 SQL 输出出来,如果该筛选条件没有生效,多半就是 OGNL 类型转换问题。处理完这一处,再检查一下 controller 里接收的@RequestParam类型,建议直接声明为Integer,从源头避免类型歧义。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询