简介:这套Java微信小程序商城源码整合了完整的后台管理系统,技术架构采用SpringMVC、MyBatis、Spring、Maven和MySQL,属于Java Web开发中常用的组合;前端使用H5和CSS3,后台页面基于Bootstrap-Ace搭建,主要面向Java开发人员、高校学生和有快速搭建商城需求的团队。功能上覆盖商品发布、物流管理、评价系统、优惠券、运费系统、在线客服、在线支付、在线退款以及微信配置管理,基本形成从商品上架、订单处理到售后退款的完整电商流程。压缩包采用7z格式,大小约20.5MB,包含前端小程序工程与后端Java项目,Maven目录结构清晰,可导入IDE直接部署运行,也便于二次扩展。目前已有5047人学习下载,这套源码能帮助读者理解主流框架整合方式、微信小程序与后端接口的联调过程,并提供可直接落地的商城业务参考实现;对于正在做微信商城或毕业设计的开发者,还能通过源码对照学习数据库设计、后台权限控制和前后端交互细节。
1. 先拆清楚:这套JAVA微信小程序商城源码,到底能干什么
做小程序商城最怕的不是没人下单,而是你花两周把前端页面画完了,回头发现后台连商品都没法上架。我之前接外包时踩过这个坑,客户要“和小米商城差不多的后台”,结果光是商品多规格和运费模板就返工了三次。后来拿到这套JAVA微信小程序商城源码,第一反应是:后台功能齐得有点意外。
这套资源的技术栈是 springmvc + mybatis + spring + maven + mysql,小程序端用 h5 + css3,管理后台用的 bootstrap-ace 模板。功能覆盖商品发布、物流管理、评价系统、优惠券、运费系统、在线客服、在线支付、在线退款、微信管理。适合两类人:一类是小团队想快速上线微信商城,不想从零写注册登录、支付回调、后台管理这些基础模块;另一类是正在学 SSM 全栈的开发者,想找一份能跑通的完整项目做参考。接下来我按拆解顺序讲:先看架构和数据库,再跑小程序端和后台,最后把支付、事务这类高频翻车点单独拉出来说。
2. SSM+MySQL这个老组合为什么现在还扛打:架构拆解与数据库设计
2.1 从请求到数据库:springmvc、mybatis、spring各管哪一段
这个项目的后端是典型的 SSM 三层结构,没有用 Spring Boot,而是传统的 springmvc + spring + mybatis 通过 Maven 聚合。很多年轻开发者一上来就只学过 Spring Boot,见到 web.xml、spring-mvc.xml、applicationContext.xml 一堆配置文件就发怵。实际上这套结构并不复杂,它的请求流转路径很清楚。
浏览器或小程序发起 HTTP 请求后,先到 DispatcherServlet,由它根据 URL 映射找到对应的 Controller。Controller 负责接收参数、调用 Service 层、返回 JSON 或视图。Service 层处理业务逻辑,比如下单时要校验库存、计算优惠券抵扣、生成订单号。Service 再调用 MyBatis 的 Mapper 接口,Mapper XML 里写的是实际执行的 SQL。Spring 容器在这里主要管两件事:对象的依赖注入和数据库事务的边界控制。
// 典型的下单流程,控制层只做参数接收和结果返回 @Controller @RequestMapping("/order") public class OrderController { @Autowired private OrderService orderService; @RequestMapping(value = "/create", method = RequestMethod.POST) @ResponseBody public Result createOrder(@RequestBody OrderCreateDTO dto) { // 参数校验放在 Service 里做,控制层保持轻薄 Long orderId = orderService.createOrder(dto); return Result.success(orderId); } }这段代码的逻辑很清楚:Controller 不写业务,只负责把 JSON 请求体转成 DTO 对象,然后交给 Service。@ResponseBody表示直接返回 JSON,不走视图解析器。这里的OrderCreateDTO是接收参数的对象,字段对应小程序端提交的商品 ID、数量、收货地址 ID、优惠券 ID。你拿到源码后,如果要改下单逻辑,重点看 Service 而不是 Controller。
MyBatis 部分有一个容易忽略的细节:Mapper 接口和 XML 文件通过 namespace 绑定,XML 里parameterType和resultType的路径要写全。这个项目里用的都是全限定类名,比如com.xxx.shop.entity.Order,如果你把包名改了,这里必须同步改,否则启动时就会报Invalid bound statement。
2.2 核心数据表拆解:商品、订单、优惠券、运费模板怎么设计的
我拆任何商城项目,第一件事永远是看数据库脚本。这套源码的 SQL 脚本里有二十多张表,最核心的四类是商品表、订单表、优惠券表、运费模板表。它们的字段设计决定了整个商城的业务边界。
先看商品表,它把商品基本信息、库存、销量放在同一张表里,但 SKU 级别的库存是单独拆出去的。这意味着一个商品可以有多个规格,比如颜色、尺码,每个规格对应一条 SKU 记录,库存挂在 SKU 上而不是商品上。当用户在商品详情页选择了“黑色 XL”,小程序端传过来的是 skuId,后端扣库存时扣的是 SKU 表的 stock 字段。
CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(200) NOT NULL COMMENT '商品名称', `subtitle` varchar(500) DEFAULT NULL COMMENT '副标题', `main_image` varchar(500) DEFAULT NULL COMMENT '主图URL', `detail_html` text COMMENT '详情页富文本', `category_id` int(11) NOT NULL COMMENT '分类ID', `price` decimal(10,2) NOT NULL COMMENT '默认价格,SKU价格优先', `status` tinyint(4) DEFAULT '1' COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `sku` ( `id` int(11) NOT NULL AUTO_INCREMENT, `product_id` int(11) NOT NULL, `spec_json` varchar(500) NOT NULL COMMENT '规格JSON,如{"颜色":"黑","尺码":"XL"}', `stock` int(11) NOT NULL DEFAULT '0' COMMENT 'SKU级库存', `price` decimal(10,2) NOT NULL COMMENT 'SKU价格', PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 有两个设计要点。第一,商品表用detail_html存富文本,小程序端直接渲染这个 HTML 字段,省去了富文本转 JSON 的麻烦。第二,spec_json用 JSON 字符串存规格,查询时不需要拆表,但如果要做“按规格筛选库存”这种后台统计,JSON 字段就不方便,你可以后续改成规格值 ID 关联表。这个取舍在大多数商城场景下没问题。
订单表的设计同样有讲究。主订单和子订单在同一张表里通过parent_id区分,而不是拆成 order_main 和 order_item 两张表。用户一次结算多个商品时,前端会生成一个主订单号,每个商品对应一条子订单记录,parent_id指向主订单。这样做的好处是后台“订单列表”页面查询简单,一条 SQL 按主订单号就能查出所有商品明细,坏处是表数据量大的时候行数膨胀得比较快。
退款逻辑上,订单表上有refund_status字段,0 表示未申请,1 表示申请中,2 表示已退款。在线退款接口通过微信支付退款 API 把金额原路退回,项目里用的是weixin-java-pay这个库,你看到WxPayService.refund()这样的调用就是退款入口。
优惠券表要重点看它和订单的关联方式。券的核销状态是冗余在订单上的,也就是说用户下单时,如果用了优惠券,订单表会记录coupon_id和coupon_amount两个字段。这样做的原因是:优惠券的金额在结算时必须快照,如果后续后台改了券名称或折扣比例,已经生成的订单不受影响。
2.3 Maven工程结构与三个关键配置文件的实际作用
打开源码目录,第一层是父 pom.xml,然后 modules 下面分了四个子模块:shop-common、shop-backend、shop-api、shop-admin。shop-common 放公共工具类和统一返回体,shop-api 是小程序端调用的接口模块,shop-admin 是后台管理接口,shop-backend 是后台管理页面的静态资源。
<!-- 父pom里统一锁定依赖版本,子模块不再重复写版本号 --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>4.3.18.RELEASE</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.4.6</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.46</version> </dependency> </dependencies> </dependencyManagement>Maven 多模块最常见的坑是依赖版本不一致,父 pom 用dependencyManagement锁版本,子模块引用时不用写 version,能避免 spring 4.x 和 spring 5.x 混用的诡异问题。这个项目用的是 spring 4.3.18,对应的是 Servlet 3.0 的配置方式,不走注解启动类。
数据库连接池用的是 druid,配置在 applicationContext.xml 里。druid 在这里不只是连接池,还承担了慢 SQL 监控和 SQL 注入防火墙的角色。你启动项目后,访问http://localhost:8080/druid/index.html能看到 SQL 执行时间排行榜,上线后最好把这个页面的访问权限关掉,不然 SQL 语句、表结构、执行频率全部暴露。
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 初始连接数5,最大活跃连接数50 --> <property name="initialSize" value="5"/> <property name="maxActive" value="50"/> <!-- 连接空闲超过60秒后开始回收 --> <property name="minEvictableIdleTimeMillis" value="60000"/> <property name="validationQuery" value="SELECT 1"/> </bean>这里的参数需要根据实际部署调整。initialSize是启动时预建的连接数,maxActive是峰值连接上限,如果商城预计并发不高,maxActive 设置 20 就够了,设大了反而浪费内存。validationQuery这个配置非常重要,它会在每次从连接池拿连接时执行一次SELECT 1来检测连接是否有效,防止 MySQL 因 wait_timeout 断开了空闲连接,导致小程序端偶发 500 错误。
mybatis 的配置文件相对简单,核心是下划线转驼峰和二级缓存。项目里开启了map-underscore-to-camel-case: true,这样数据库的create_time字段能自动映射到 Java 对象的createTime属性,不用在每个 resultMap 里手写映射。
3. 把小程序端跑起来:商品列表、微信登录与下单链路
3.1 小程序前端骨架:h5+css3技术下的页面组织方式
这个项目的小程序前端不是用原生 WXML 写的,而是把 h5+css3 的结构套在了微信小程序的组件里。开发者工具打开miniprogram/目录,能看到每个页面由四个文件组成:.wxml管结构、.wxss管样式、.js管逻辑、.json管页面配置。熟悉 h5 的人上手很快,class 选择器、flex 布局、rem 单位这些 CSS 概念在 wxss 里照用不误。
商品列表页的 wxml 结构比较典型,用的是wx:for循环渲染商品卡片。每个商品卡片包含图片、标题、价格三个元素,点击卡片通过navigateTo跳转到详情页,把商品 ID 拼接在 URL 后面。
<view class="product-list"> <view class="product-card" wx:for="{{productList}}" wx:key="id" bindtap="goDetail">// 商品请求封装:所有接口统一走这个函数,方便切换线上/测试地址 function request(url, method, data) { const baseUrl = 'https://yourdomain.com/api' return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail: reject }) }) }这段封装的逻辑值得沿用:所有接口返回统一结构{code: 0, msg: '', data: {}},前端只判断 code 是否为 0。token 从本地缓存读取并放在 header 里,后端通过 token 识别用户身份。你拿到源码后,第一步就是把请求地址改成自己的服务器域名,微信小程序后台要配置合法域名,wx.request只支持 HTTPS 和已备案域名,这是上线前的硬性门槛。
3.2 微信登录与 openid 换取:session_key 的保存与校验
小程序登录和网页登录最大的不同是:小程序端调用wx.login()拿到一个临时 code,然后把这个 code 发给后端,后端拿 code 去微信接口服务换取 openid 和 session_key。openid 是用户在某个小程序下的唯一标识,session_key 用于后续解密手机号、获取用户信息。
// 后端登录接口,用code换openid的核心逻辑 @RestController @RequestMapping("/wx") public class WxLoginController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody Map<String, String> params) { String code = params.get("code"); // 向微信接口服务发起请求,appid和secret从配置文件读取 WxMaJscode2SessionResult session = wxMaService.getUserService() .getSessionInfo(code); String openid = session.getOpenid(); // 检查用户是否已存在,不存在则自动注册 User user = userService.findOrCreate(openid); // 生成自定义token,后续请求带这个token String token = UUID.randomUUID().toString().replace("-", ""); redisService.set(token, user.getId(), 7 * 24 * 3600); return Result.success(token); } }这段流程有三点要特别注意。第一,session_key不能直接存数据库,它是微信官方要求保密的密钥,只在解密用户敏感信息时用,用完可以从缓存里删除,或者设置和 token 相同的过期时间。第二,真实项目中不要用UUID.randomUUID()当 token,这个值太容易预测,我一般会用SecureRandom生成 32 字节随机数然后做 Base64 编码。第三,token 存 Redis 并设置 7 天过期,小程序端每次请求都会带这个 token,后端用一个拦截器校验,未登录的请求统一返回 401。
实际运行时你可能会遇到一个问题:开发环境下wx.login返回的 code 只有 5 分钟有效期,后端如果网络慢或者微信接口服务超时,会报 40029 错误码,提示 code 无效。遇到这种情况,第一反应是看服务器时间和真实时间是否同步,微信接口验签依赖时间戳,服务器时钟偏差超过 5 分钟就会出问题,执行ntpdate ntp.aliyun.com重新校时即可。
3.3 购物车与订单结算:从本地缓存到服务端下单的完整链路
购物车这个模块,项目里采用的是“本地存 + 服务端同步”的策略,不是纯服务端存储。用户加购时只操作本地缓存wx.setStorageSync('cartList'),等到用户点击结算时,把购物车数据整体提交到服务端创建订单。这样做的优点是购物车操作零延迟,不依赖网络状态,缺点是换设备后购物车不同步。对于小商城来说这个取舍是合理的。
下单接口是整条链路里逻辑最重的地方,它需要同时完成库存校验、优惠券计算、运费计算、订单生成、库存扣减五件事。项目里用@Transactional把整个方法包起来,任何一个环节失败都会回滚,保证不会出现“订单生成了但库存没扣”的脏数据。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 锁定用户使用的优惠券,防止重复使用 Coupon coupon = couponMapper.selectByIdForUpdate(dto.getCouponId()); if (coupon == null || coupon.getStatus() != 0) { throw new BizException("优惠券不可用"); } // 2. 逐条校验商品库存,这里的forUpdate是悲观锁 List<OrderItemDTO> items = dto.getItems(); for (OrderItemDTO item : items) { Sku sku = skuMapper.selectByIdForUpdate(item.getSkuId()); if (sku.getStock() < item.getQuantity()) { throw new BizException("商品库存不足: " + sku.getSpecJson()); } } // 3. 计算订单金额:商品小计 - 优惠券抵扣 + 运费 BigDecimal totalAmount = calculateTotal(items); BigDecimal couponAmount = coupon.getAmount(); BigDecimal shippingFee = shippingService.calculate(dto.getAddressId(), items); BigDecimal payAmount = totalAmount.subtract(couponAmount).add(shippingFee); // 4. 生成订单并扣库存 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setPayAmount(payAmount); order.setStatus(0); // 0待支付 orderMapper.insert(order); for (OrderItemDTO item : items) { skuMapper.decreaseStock(item.getSkuId(), item.getQuantity()); } return order.getId(); }这段代码有三个设计上的关键点。第一,selectByIdForUpdate是行级悲观锁,它会在事务提交前锁住这条优惠券记录和 SKU 记录,防止两个用户同时下单把最后一件商品抢走。性能上确实不如乐观锁,但对于日订单量几千的小商城足够用。第二,优惠券的核销状态在这里不做更新,要等支付成功回调后才把优惠券置为已使用,因为用户可能下单后不付款,优惠券不能在下单时就消耗掉。第三,库存扣减用的是UPDATE sku SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},这条 SQL 自带原子性,即使去掉 forUpdate 也能防超卖,这是我在接手项目后自己加的一层保护。
4. 后台管理系统:商品发布、优惠券与物流配置的完整闭环
4.1 bootstrap-ace后台模板:页面布局与权限菜单的挂载方式
管理后台用的是 bootstrap-ace 模板,这是基于 Bootstrap 3 的一套后台界面框架,布局是经典的左侧菜单 + 右侧内容区。ace 模板自带的菜单组件、表格样式、表单控件都比较完整,做商城管理后台不需要额外引入组件库。页面文件是 JSP,通过 shiro 做登录认证和权限控制。
打开后台首页后,左侧菜单按业务模块分组:商品管理、订单管理、用户管理、营销管理、系统设置。每个菜单项对应一个 Controller 的 RequestMapping,比如/admin/product/list渲染商品列表页面,/admin/order/list渲染订单列表。权限控制的核心是 shiro 的注解,你在 Controller 方法上能看到类似下面的写法。
@RequiresPermissions("product:edit") @RequestMapping(value = "/edit", method = RequestMethod.POST) public String edit(Product product) { productService.updateById(product); return "redirect:/admin/product/list"; }@RequiresPermissions注解会在方法执行前做权限校验,当前登录用户没有 product:edit 这个权限就会直接跳转到 403 页面。这里有个常见的坑:shiro 的权限初始化是在用户登录时把角色和权限一次性塞进 Session 里的,如果后台给某个管理员改了角色,他要重新登录才能生效。项目源码里没有做权限热刷新,你如果改了角色配置,记得让对应用户退出重登。
后台页面的 URL 设计也有规律:/admin/product/list是商品列表,/admin/product/edit是编辑商品,/admin/order/list是订单列表,/admin/coupon/list是优惠券列表。接口层用的全是 GET 或 POST 请求配合 JSP 表单提交,没有像小程序端那样走 JSON RESTful 风格,新上手的人要注意这个差别,不要在后台模块里强行套小程序端那种@ResponseBody风格。
4.2 商品发布与多规格库存:从表单提交到SKU写入
商品发布页是后台里比较复杂的一个表单。基本信息有商品名称、副标题、分类、主图、详情富文本,价格和库存则要处理多规格。项目里用的方案是:前端表单中有一个“添加规格”按钮,每点一次就动态增加一行 sku 输入框,用户填完规格名和价格库存后,前台 JS 把这些数据组装成 JSON 数组,提交后后端解析并批量写入 sku 表。
// 商品发布时,后端接收SKU数组并批量插入 public void saveProductWithSkus(Product product, List<SkuDTO> skuList) { // 先插入商品主表,拿到自增ID productMapper.insert(product); // 再循环插入SKU,注意每一条都要回填productId for (SkuDTO sku : skuList) { Sku skuEntity = new Sku(); skuEntity.setProductId(product.getId()); skuEntity.setSpecJson(JSON.toJSONString(sku.getSpecs())); skuEntity.setPrice(sku.getPrice()); skuEntity.setStock(sku.getStock()); skuMapper.insert(skuEntity); } }这段逻辑的麻烦点在于:插入商品主表后,MyBatis 默认不会把自增 ID 回填到传入的 product 对象里,除非你在 insert 语句里设置了useGeneratedKeys="true" keyProperty="id"。如果少了这个配置,product.getId() 永远返回 null,导致 SKU 表的 product_id 全部是 0,后台商品详情页就查不到对应的规格。源码里已经配好了,你自己二次开发时不要动这个配置。
商品发布还有一个富文本的处理细节。详情页用的是 UEditor 编辑器,它上传图片时会把图片 base64 编码直接塞进 HTML 里,如果图片比较大,整条记录可能超过 16MB,MySQL 的max_allowed_packet默认 4MB 会直接报错。我的做法是修改 UEditor 的配置文件,把图片上传改成走独立接口,上传后返回图片 URL,编辑器里只保存 URL 字符串,这样详情字段始终是文本,不会有超大包的问题。
4.3 优惠券与运费模板:后台规则怎么落到订单计算里
优惠券模块在后台对应两张表:coupon表存券本身的定义,比如面额、使用门槛、有效期;user_coupon表存用户领到的券,记录归属用户和领券时间。后台配置一张满100减20的新券,操作是往 coupon 表插入一条记录,用户在小程序端领券后,user_coupon 表里出现一条对应记录。
运费模块稍微复杂一点,它用的是按件计费的模式,不是按重量。运费模板表的主要字段是template_name、first_item_count、first_item_fee、additional_item_count、additional_item_fee。翻译过来就是:首件商品收 X 元,每增加一件再加 Y 元。用户结算时,系统统计购物车里所有商品的总件数,套用公式:首件费 + 加件费 * (总件数 - 1)。
// 运费计算:首件计费 + 续件计费,不按重量 public BigDecimal calcShipping(List<OrderItemDTO> items) { int totalCount = 0; for (OrderItemDTO item : items) { totalCount += item.getQuantity(); } if (totalCount <= 0) { return BigDecimal.ZERO; } // template: firstItemCount=3 首3件12元,additionalItemFee=5 每加1件5元 BigDecimal fee = shippingTemplate.getFirstItemFee(); int extraCount = totalCount - shippingTemplate.getFirstItemCount(); if (extraCount > 0) { fee = fee.add(shippingTemplate.getAdditionalItemFee() .multiply(BigDecimal.valueOf(extraCount))); } return fee; }这段算法有个边界条件要处理好:如果总件数小于等于首件件数,fee 就是首件费,不会出现负数。比如模板配置是首 3 件 12 元、续件 5 元,用户买了 8 件商品,运费就是 12 + 5 * 5 = 37 元。你如果接手了这套源码,可以留意一下additionalItemCount字段是否被用到,有些模板策略是“满 3 件免邮,之后每 3 件加收 5 元”,那就是按段落计费,和按件计费逻辑完全不同,要改这里的方法。
后台还有一个容易漏掉的配置:运费模板和地市是绑定的。也就是说你需要为不同地区配置不同运费策略,比如偏远地区首件 15 元、续件 8 元,普通地区首件 10 元、续件 5 元。默认的源码里只做了全国统一模板,你如果要支持地区差异,需要额外加一张地区和模板的关联表,核心是定位用户收货地址的省市区编码。
5. 避坑指南:支付回调、事务失效与微信接口调用的常见翻车现场
5.1 微信支付回调验签失败,订单永远停在待支付状态
现象:用户在小程序里支付成功后,后台订单状态一直不变,还是“待支付”,手动查微信商户后台能看到钱已经扣了。
原因:微信支付的回调接口notify_url在本地开发时用的是内网穿透地址,微信回调时会带签名参数,后端验签需要用到 APIv3 密钥。配置密钥时如果多了空格、换行符或者把 APIv2 和 APIv3 的密钥搞混,验签就会失败。另一个原因是回调接口返回给微信的响应格式不对,微信要求返回 XML 格式的<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>,如果你返回了 JSON,微信会认为回调失败并持续重试。
解决:第一步,先看后台日志,确认回调请求有没有真正到达你的接口。如果完全没日志,那说明内网穿透的地址不对或者域名没备案。第二步,打开微信商户平台的 API 安全设置,重新设置 APIv3 密钥,确保复制时没有换行。第三步,按微信官方格式返回,不要画蛇添足。
提示:调试支付回调时,最有效的办法是看微信支付商户平台里的“回调日志”,每一条回调记录都带请求时间、返回状态码、返回内容,比在本地服务端抓日志快得多。
5.2 事务注解失效:MyBatis 自调用导致库存回滚不生效
现象:下单接口报了“库存不足”的异常,但发现订单表里多了一条待支付订单,库存却没扣减成功,数据明明已经回滚了,为什么还会多出订单?
原因:@Transactional注解只有在通过 Spring 容器代理调用时才会生效。如果在一个类的内部,方法 A 调用方法 B,B 上面标了@Transactional,而这个调用没有经过代理对象,事务不会开启。具体到这个项目里,createOrder方法是 public 的,如果它被同类中的另一个方法直接调用,事务就会静默失效。
解决:把事务方法拆到独立的 Service Bean 里,跨 Bean 调用才能保证 Spring 代理介入。或者在createOrder方法内部自己注入OrderService,用this.orderService.createOrder()的方式调用,绕开内部 self-invocation 的问题。我检查过源码,这个项目里的下单方法是从 Controller 直接调用的,路径没问题,但你自己加定时任务时要格外小心。
5.3 session_key 过期导致解密手机号失败
现象:小程序端提交 button 组件触发的手机号授权,后端调用解密接口时报invalid session_key或解密出来的数据是乱码。
原因:session_key 的有效期取决于用户是否主动删除小程序,官方没有明确的过期时间,但有一个硬性规则:用户调用wx.login()后,旧的 session_key 立即失效。常见场景是用户进入小程序A页面完成一次登录,然后长时间停留在某个页面,再次点击手机号授权时,session_key 已经在新的一次wx.login()中被替换了。
解决:在手机号授权页面,在获取用户授权前先主动调用wx.login()重新拿 code,确保后端拿到的是最新的 code。后端换取 session_key 后立即解密,不要存储、不要延迟处理。源码里有个工具类WxSecurityUtil.decryptData,在分布式环境下要确保这个工具方法是无状态的,不要把 session_key 存在成员变量上。
5.4 MySQL 空闲连接被切断:商城凌晨睡死、白天一早报 500
现象:每天早上商城第一次访问时,接口返回 500,日志里报Connection is not available, request timed out,等一两分钟后自动恢复。
原因:MySQL 的wait_timeout默认 8 小时,连接池里的连接如果空闲超过这个时间,MySQL 服务端会主动断开。而 druid 连接池里的连接并不知道自己被断了,依然认为连接可用,取出来用时才发现已经失效。项目里validationQuery配了SELECT 1,但有些版本需要用testWhileIdle参数开启空闲检测。
解决:druid 配置里加上testWhileIdle=true和timeBetweenEvictionRunsMillis=60000,让连接池每分钟检查一次空闲连接,把失效连接清掉。同时 MySQL 的 wait_timeout 可以调大,或者干脆去掉validationQuery之外的其它干扰项,确保每次取连接都走存活检测。我接手第一个商城项目时就是被这个坑坑了一周,后来所有带 druid 的项目都强制带上testWhileIdle。
5.5 商品库存超卖:两个用户同时买最后一件商品
现象:后台商品库存显示 1 件,但前台出现了 2 个成功订单,库存变成负数。
原因:下单逻辑里先 SELECT 查询库存,再 UPDATE 扣减库存,两步之间有时间窗口。两个并发请求同时读到库存为 1,都认为可卖,最终都执行了扣减。如果不加任何锁,MySQL 的普通 UPDATE 在这种场景就是超卖的根源。
解决:源码里的下单方法已经对 SKU 用了selectByIdForUpdate悲观锁,这一行如果你的 MyBatis 映射没生效,会导致锁完全失效。检查 Mapper XML 里对应的 SQL 是否真的带了FOR UPDATE。你可以在 MySQL 客户端执行SET autocommit=0; BEGIN; SELECT * FROM sku WHERE id=1 FOR UPDATE;手动测一遍锁效果。另外建议在 SQL 层加一道硬约束:UPDATE sku SET stock = stock - 1 WHERE id = #{id} AND stock > 0,这样即使应用层逻辑有漏洞,数据库层也兜底。
提示:悲观锁只适合并发量不高的场景,秒杀这种上千并发的玩法需要换 Redis 预扣库存的方案,这不在这套源码的范围内。
6. 上线前的最后一道工序:用Charles抓包验证完整下单链路
6.1 用 Charles 查看小程序请求:定位是前端问题还是后端问题
小程序端出问题最烦的就是不知道请求到底发出去没有、后端返回了什么。Charles 抓包在这个场景里很管用。开发模式下,微信开发者工具自带网络面板,但联调时你用真机预览,微信开发者工具里的网络面板是看不到的,这时候就只能靠 Charles。
首次配置 Charles 时,要开启 SSL 代理并安装根证书。手机上设置代理指向你的电脑 IP,端口默认 8888。配置好后,打开小程序,Charles 里会列出所有 https 请求,包括wx.login的 code 换 session、商品列表接口、下单接口、支付统一下单接口。每个请求点进去都能看到完整的请求头、请求体、响应体。
# 一个典型的商品列表请求,在Charles里能看到的结构: # URL: https://yourdomain.com/api/product/list?page=1&size=10 # Request Header: # token: eyJhbGciOiJIUzI1NiIs... # Content-Type: application/json # Response Body: # {"code":0,"msg":"ok","data":{"list":[{"id":1,"name":"测试商品"}],"total":100}}用 Charles 排查问题,我有一套固定的路径:先看请求是否发出,再看响应 code 是否为 0,最后看后端日志里有没有打印 SQL。如果 Charles 里能看到请求但响应是 404,那是后端路由映射问题,去查 Controller 的@RequestMapping。如果响应是 500,去看 Tomcat 的 catalina.out。如果 Charles 里毛都没有,那就是前端代码里wx.request根本没执行,检查页面生命周期和按钮事件绑定。
6.2 下单-支付-回调-发货的完整链路验证清单
整套商城功能最怕的不是单个接口报错,而是整条业务链路跑不通。上线前我强烈建议按下面这张清单在测试环境完整走一遍,每步都确认状态字段正确再放行。
| 步骤 | 操作 | 预期结果 | 检查点 |
|---|---|---|---|
| 1 | 用户登录 | 获取到 token,用户表新增记录 | token 有效,openid 唯一 |
| 2 | 加入购物车 | 本地缓存出现商品 | 规格 ID 与 SKU 对应 |
| 3 | 提交订单 | 生成待支付订单,库存扣减 | 订单金额=商品-优惠券+运费 |
| 4 | 发起支付 | 调起微信支付收银台 | 支付金额与订单金额一致 |
| 5 | 支付回调 | 订单状态变为已支付 | 商户平台回调日志返回 SUCCESS |
| 6 | 商家发货 | 后台录入物流单号 | 前端订单列表显示物流信息 |
| 7 | 用户确认收货 | 订单状态变为已完成 | 评价入口开放 |
| 8 | 申请退款 | 退款原路返回 | 商户平台有退款记录 |
这张清单里最容易出问题的是第 5 步。支付回调需要确保你的服务器能收到微信发来的请求,并且接口不能有防火墙拦截。很多服务器默认只放行 80 和 443 端口,如果你的回调地址写的是 8080 端口,微信请求过不来,订单状态就永远不会更新。
整个链路里还有一处接口幂等的问题容易被忽略。支付回调可能因为网络原因被微信重复推送,你的回调方法里一定要做去重判断,检查订单当前状态是不是已经变成已支付,如果已经支付就直接返回成功,不要重复更新订单状态。这个判断在源码的回调方法里已经写了,你自己写回调逻辑时不要省这一步。
提示:上线前把微信商户平台的“支付结果通知”频率调成最低档,可以减少重复回调对服务器的压力,但去重逻辑是底线,不能指望微信不重试。
这套源码整体跑下来,给我最大的感受是:它不是为了炫技术写出来的演示项目,而是真的在业务场景里打磨过的。商品多规格、优惠券抵扣、运费模板、支付回调这些模块都有完整的实现,不是那种只有 CRUD 的壳子。如果你是想在微信生态里快速落地一个商城,先用这份源码把基础业务跑通,重点把精力花在自己的特色功能和运营策略上。
想起当时第一次用这套源码部署上线,在支付回调上折腾了整整两天,最后发现是腾讯云的防火墙把微信回调 IP 拦了。从那以后我每次排查问题都先问自己三个问题:请求到了没、响应是什么、日志说了啥。你拿到源码后也照着这个思路走一遍,大部分问题都能自己解决。希望帮到你。
本文还有配套的精品资源,点击获取