简介:基于微信平台的文玩销售小程序,是一个面向高校毕业设计及小程序电商初学者的完整项目。后端选用Java语言,采用Spring Boot与SSM框架整合开发,前端为微信小程序页面,配合MySQL 5.7以上数据库与Tomcat 7以上服务器,可运行于JDK 1.8环境。压缩包内共包含1224个文件,既有Java源码和Vue页面,也有微信小程序的界面描述、样式与逻辑文件,还含数据库脚本、功能文档及启动部署脚本,源码、脚本、文档配套齐全,并已经过严格调试,能够直接运行。资源包大小约15MB,已有2708人学习,适合仔细研读商品展示、购物车、订单管理、用户评论等功能的完整实现,从中理解前后端数据交互、接口设计与数据表关联关系,为独立开发或二次修改提供可直接落地的参考。整体目录按功能模块划分,检索方便,可作为毕业设计或项目实训的蓝本,同时附带项目介绍文档,方便快速了解整体设计。
1. 基于微信平台的文玩销售小程序,为什么值得用 SSM 做后端
文玩品类与普通标品电商差异明显:商品非标、单价高、交易依赖信任,买家下单前往往要反复看图、问产地和工艺,线下看完线上买。小程序恰好能承接这个场景,用户扫一扫或从聊天记录进入,不需要下载 App,购买链路短。而 SSM 框架在这个领域依然是大量毕业设计、外包项目和中小团队商城系统的常见选型,Spring 管业务对象、SpringMVC 收接口、MyBatis 管 SQL,三层职责清晰,调试和交付都比前后端不分离的 JSP 时代好维护得多。这篇文章从一线开发者的视角,把一套微信文玩销售小程序从微信登录、商品浏览、购物车、微信支付到部署上线的关键路径拆开讲,重点写清楚哪些配置必须做、哪些参数容易写错,以及遇到支付失败和超卖时从哪里下手排查。
2. 微信小程序 + SSM 的架构拆解:目录结构、权限模型、数据库设计
2.1 为什么选 SSM:轻量、可控、适合非标品业务
SSM 不是新框架,但胜在稳定。文玩销售业务的复杂度集中在商品属性、订单状态和支付对账上,Spring 容器管理 Service 层事务,SpringMVC 用注解暴露 RESTful JSON 接口,MyBatis 手写 SQL 方便调整复杂的商品查询条件。相比 Spring Boot,SSM 的自动装配更少,出现问题时能顺着 XML 配置一步步查;对团队来说,接手一个 SSM 项目比接手一份结构混乱的 JSP 项目更直观。
需要注意,SSM 项目里容易把业务逻辑堆在 Controller 层,导致接口类膨胀。建议保持标准分包结构:controller只做参数接收和返回,service放事务和业务规则,mapper只碰数据库。文玩销售涉及图片、视频、价格浮动,非标品字段多,MyBatis 的resultMap比 JPA 更适合处理字段映射。
2.2 后端工程目录与核心配置
常见的 SSM 工程在打包后是 war 包,部署到 Tomcat。目录结构一般是这样:
src/main/java com.wenwan.controller // 小程序接口入口 com.wenwan.service // 订单、库存、支付业务 com.wenwan.dao // MyBatis Mapper 接口 com.wenwan.pojo // 实体类 com.wenwan.utils // 微信签名、HTTP 工具 src/main/resources jdbc.properties mybatis-config.xml spring-mvc.xml spring-service.xml src/main/webapp/WEB-INF/web.xmljdbc.properties里写数据库连接信息,密码不要硬编码到代码里,部署环境用环境变量替换。一个小坑是 MySQL 连接串必须带时区和编码参数,否则小程序端传过来的时间字段会偏移八小时:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/wenwan?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=${DB_PASSWORD}spring-mvc.xml要记得开启注解驱动和静态资源放行。商品图片如果放在后端本地目录,需要通过<mvc:resources>映射,否则小程序image组件加载图片时会 404。
2.3 数据库设计:商品、SKU、订单、用户
文玩不比标品,“商品表”一个表不够用。一条手串可能有规格、克重、尺寸、材质等级,所以需要goods表加goods_sku表。订单表要拆成主表和明细表,支付回调后只改主表状态,明细表不进事务高频更新。
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(64), avatar_url VARCHAR(255), phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_goods ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, category VARCHAR(32), material VARCHAR(32), -- 材质:小叶紫檀、黄花梨、玉石 craft VARCHAR(32), -- 工艺:手工雕、素珠 price DECIMAL(10,2), -- 展示价 main_image VARCHAR(255), detail_images TEXT, -- 逗号分隔的多图 status TINYINT DEFAULT 1, -- 1上架 0下架 version INT DEFAULT 0 -- 乐观锁版本号 ); CREATE TABLE t_goods_sku ( id INT PRIMARY KEY AUTO_INCREMENT, goods_id INT NOT NULL, spec VARCHAR(64), -- 如 8mm/12颗 stock INT NOT NULL, price DECIMAL(10,2) ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, address VARCHAR(255), total_amount DECIMAL(10,2), status TINYINT DEFAULT 0, -- 0待支付 1已支付 2已发货 3已完成 4已取消 created_at DATETIME, paid_at DATETIME ); CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, sku_id INT NOT NULL, goods_name VARCHAR(128), spec VARCHAR(64), price DECIMAL(10,2), qty INT );字段命名统一用下划线,MyBatis 开启驼峰映射后,Java 属性orderNo能直接对应order_no,省去大量resultMap配置。
2.4 微信登录:从 code 到 openid 到自定义 Token
小程序端不能直接拿到用户身份,只能通过wx.login()拿一个临时code,由后端用这个 code 加上 appid 和 secret 换取 openid。openid 是用户在该小程序下的唯一标识,同一用户在不同小程序里 openid 不同。
后端 Controller 接收前端传来的 code,封装一个WechatLoginService:
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private WechatLoginService loginService; @PostMapping("/login") public Result login(@RequestBody LoginRequest req) { String openid = loginService.code2openid(req.getCode()); User user = userMapper.findByOpenid(openid); if (user == null) { user = new User(openid); userMapper.insert(user); } // 生成自定义 token,存 Redis 或表 String token = TokenGenerator.generate(); tokenMapper.save(token, user.getId()); return Result.success(new LoginVO(token, user.getId())); } }code2openid内部调用微信接口https://api.weixin.qq.com/sns/jscode2session?appid=xxx&secret=xxx&js_code=xxx&grant_type=authorization_code,拿到 openid 和 session_key。这里有两个注意点:secret 绝不能返回给前端;session_key 是时效性的,不要用它做业务登录态。建议每次登录生成一个自定义 token,小程序端存入wx.setStorageSync('token', ...),后续请求放请求头。
3. SSM 后端实现购物车、订单和微信支付:核心接口与参数说明
3.1 购物车放服务端还是本地
文玩购买频次不高,很多项目图省事把购物车存在小程序本地storage,下单时整包传给后端。这种做法对单个用户可行,但换设备登录购物车就丢,库存也无法实时校验。正规一点的做法是服务端购物车,购物车表结构为id, user_id, sku_id, qty, checked,后端提供增删改查接口。基于小程序商城的高频场景,我一般建议服务端购物车,虽然多写几个接口,但后续加“库存不足自动勾除”功能会方便很多。
下订单的接口要接收购物车 ID 列表、收货地址 ID、买家备注。服务层用@Transactional保证订单创建、明细插入、库存扣减在一个事务里:
@Transactional(rollbackFor = Exception.class) public OrderResult createOrder(Long userId, List<Long> cartIds, Long addressId, String remark) { List<CartItem> items = cartMapper.selectByIds(cartIds); BigDecimal total = BigDecimal.ZERO; for (CartItem item : items) { Sku sku = skuMapper.findById(item.getSkuId()); if (sku.getStock() < item.getQty()) { throw new BizException("库存不足:" + sku.getSpec()); } total = total.add(sku.getPrice().multiply(BigDecimal.valueOf(item.getQty()))); } String orderNo = generateOrderNo(); orderMapper.insert(new Order(orderNo, userId, addressId, total)); // 明细、扣库存逻辑 for (CartItem item : items) { orderItemMapper.insert(...); skuMapper.deductStock(item.getSkuId(), item.getQty()); cartMapper.deleteById(item.getId()); } return new OrderResult(orderNo, total); }事务方法里避免远程调用和锁竞争,否则会拉长事务时间。扣库存的 SQL 要加条件stock >= #{qty},防止并发把库存扣成负数,这一点在第 6 章单独展开。
3.2 微信支付 JSAPI 接入关键参数
微信小程序内支付走 JSAPI 支付。统一下单接口返回prepay_id,这个prepay_id不能直接给前端,后端要拿着它生成支付参数,再次签名后才返回给小程序。
统一下单需要appid、mch_id、out_trade_no、total_fee、body、notify_url、openid。金额单位是分,后端把元转分时注意BigDecimal精度问题,不要用double去乘 100,很多支付金额差一分的问题都出在这里。
public Map<String, String> unifiedOrder(Order order, User user) { SortedMap<String, String> params = new TreeMap<>(); params.put("appid", wechatConfig.getAppId()); params.put("mch_id", wechatConfig.getMchId()); params.put("nonce_str", RandomUtil.generate()); params.put("body", "文玩商品"); params.put("out_trade_no", order.getOrderNo()); params.put("total_fee", order.getTotalAmount().movePointRight(2).intValueExact() + ""); params.put("spbill_create_ip", "你的服务器IP"); params.put("notify_url", wechatConfig.getNotifyUrl()); params.put("trade_type", "JSAPI"); params.put("openid", user.getOpenid()); // 生成签名 String sign = WechatSignUtil.sign(params, wechatConfig.getApiKey()); params.put("sign", sign); // 转 XML 请求 https://api.mch.weixin.qq.com/pay/unifiedorder String xml = XmlUtil.mapToXml(params); String respXml = HttpClientUtil.postXml(xml); Map<String, String> resp = XmlUtil.xmlToMap(respXml); String prepayId = resp.get("prepay_id"); // 二次签名给小程序 SortedMap<String, String> payParams = new TreeMap<>(); payParams.put("appId", wechatConfig.getAppId()); payParams.put("timeStamp", System.currentTimeMillis() / 1000 + ""); payParams.put("nonceStr", RandomUtil.generate()); payParams.put("package", "prepay_id=" + prepayId); payParams.put("signType", "MD5"); payParams.put("paySign", WechatSignUtil.sign(payParams, wechatConfig.getApiKey())); return payParams; }有一个高频坑:timeStamp必须是字符串,而且不能带有小数点。前端wx.requestPayment接收timeStamp、nonceStr、package、signType、paySign五个参数,其中package必须写成prepay_id=xxx,漏掉前缀会报invalid payment。
3.3 支付回调与订单状态更新
支付成功后微信服务器会向notify_url发起异步回调。回调处理必须做到幂等,重复报文不能导致订单状态被覆盖或发生二次业务处理。正确顺序是:验证签名 → 校验金额和订单号 → 查订单当前状态 → 如果是已支付就返回成功不再处理 → 否则更新状态。
回调接口里返回给微信服务器的响应是纯文本,成功返回SUCCESS,失败返回FAIL。常有人在这里把 JSON 返回给微信服务器,导致平台一直重复推送。
@RequestMapping("/pay/notify") @ResponseBody public String payNotify(HttpServletRequest request) { String xml = HttpUtil.readBody(request); Map<String, String> params = XmlUtil.xmlToMap(xml); if (!WechatSignUtil.verify(params, wechatConfig.getApiKey())) { return "FAIL"; } String orderNo = params.get("out_trade_no"); String totalFee = params.get("total_fee"); Order order = orderMapper.findByOrderNo(orderNo); if (order == null || !order.getTotalAmount().movePointRight(2).intValueExact().toString().equals(totalFee)) { return "FAIL"; } if (order.getStatus() == 0) { orderMapper.updateStatus(orderNo, 1, new Date()); } return "SUCCESS"; }回调接口不能被登录拦截器拦截,多数支付回调联调失败的案例是回调触发了前端的/api/user/**权限校验。建议把/pay/**单独放在白名单里。
3.4 订单超时关闭与定时任务
待支付订单不能一直占着库存,需要定时关闭。SSM 项目里用 Spring Task 最简单,在spring-service.xml开启<task:annotation-driven>,然后在方法上加@Scheduled:
@Scheduled(cron = "0 */5 * * * *") public void closeExpiredOrders() { List<Order> orders = orderMapper.findPendingBefore(new Date(System.currentTimeMillis() - 30 * 60 * 1000)); for (Order order : orders) { orderMapper.updateStatus(order.getId(), 4); // 恢复库存 skuMapper.restoreStockByOrderId(order.getId()); } }注意不要在大事务里循环调用数据库,数据量大时每批取 100 条处理。定时任务与用户手动取消订单会冲突,更新时加状态条件where status = 0,避免把已支付订单吞掉。
4. 小程序前端从商品展示到调起微信支付
4.1 小程序目录结构与 request 封装
小程序端页面建议按业务拆:pages/index首页、pages/goods/list、pages/goods/detail、pages/order/confirm、pages/order/list。公共请求封装在utils/request.js:
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: 'https://api.example.com' + url, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }); } else { reject(res.data.msg); } }, fail: (err) => reject(err) }); }); };每次请求把 token 放在Authorization头里,后端通过拦截器解析 token 并设置当前用户上下文。一旦 token 过期或无效,返回 401,前端统一跳转登录页。不要在每个页面重复写wx.showToast,封装层统一处理错误信息,能少写很多重复代码。
4.2 商品列表页与无限加载
文玩分类不复杂,但用户常按材质和价位筛选。列表页用wx:for渲染商品卡片,下拉刷新用onPullDownRefresh,触底加载用onReachBottom:
Page({ data: { goodsList: [], page: 1, category: '', keyword: '', hasMore: true }, onReachBottom() { if (!this.data.hasMore) return; this.setData({ page: this.data.page + 1 }, () => this.fetchList()); }, fetchList() { request(`/api/goods?page=${this.data.page}&category=${this.data.category}&keyword=${this.data.keyword}`) .then(res => { this.setData({ goodsList: this.data.goodsList.concat(res.list), hasMore: res.list.length > 0 }); }); } });需要动态设置当前页面标题时,在onLoad里调用wx.setNavigationBarTitle({ title: category })。注意跳转商品详情时,在列表页wx.navigateTo传goodsId,详情页通过options.goodsId读取。
4.3 商品详情与 SKU 选择
文玩商品图要多角度展示,详情页用swiper做主图轮播。商品规格如“8mm 单圈 / 108 长串”要放在 SKU 组件里选择,选完再更新价格和库存。小程序端没有现成的 SKU 组合控件,常见做法是用radio-group或自定义弹层:
<view class="sku-panel"> <view wx:for="{{skuList}}" wx:key="id" class="sku-item {{selectedSkuId === item.id ? 'active' : ''}}" >const doPay = (payParams) => { wx.requestPayment({ ...payParams, success: () => { // 不代表最终结果,以回调/查询为准 wx.navigateTo({ url: '/pages/order/pay-success' }); }, fail: (err) => { // 用户取消或参数错误,不要直接提示支付失败 wx.showToast({ title: '订单待支付', icon: 'none' }); } }); };支付失败err.errMsg里requestPayment:fail cancel表示用户主动取消,这种情况不要清除购物车;如果fail里带invalid signature等字样,要回到后端查签名逻辑。支付成功后前端跳转成功页只是体验上的一环,真实结果要以后端收到的回调为准,也可以提供“查询订单状态”按钮轮询。
4.5 域名校验与微信平台要求
小程序正式版发起wx.request的域名必须是 HTTPS,且需要在微信公众平台「开发管理 - 服务器域名」里配置request 合法域名。域名需要 ICP 备案,证书要有效,不支持 IP 地址和自签名证书。
开发阶段可以勾选开发者工具右上角「不校验合法域名、TLS 版本以及 HTTPS 证书」,但不能作为线上方案。另外,经常有项目在已发布的小程序里修改接口路径或端口,导致请求直接 404,排查时先在开发者工具里打开调试模式看Network面板的实际请求 URL。
5. SSM 部署到云服务器的标准过程:Tomcat、Nginx 和 HTTPS
5.1 打包与部署
SSM 项目用mvn clean package打包成 war 包,放到 Tomcat 的webapps目录,重启 Tomcat 后自动解压。部署前检查三处:JDK 版本与代码编译版本一致;数据库连接可连通;Tomcat 的 server.xml 里 URIEncoding 设置为 UTF-8,否则小程序传中文备注会乱码。
开发环境和生产环境的jdbc.properties不同,建议用 Maven profile 做环境隔离:
<profiles> <profile> <id>prod</id> <properties> <env>prod</env> </properties> </profile> </profiles>资源文件放src/main/resources/profile/${env},打包时指定-Pprod。
5.2 Nginx 反向代理配置
Tomcat 默认 8080 端口,小程序只能访问 443,需要在前面加一层 Nginx。反向代理的同时要处理 HTTPS 证书和转发请求头。
server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/cert/api.example.com.pem; ssl_certificate_key /etc/nginx/cert/api.example.com.key; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /images/ { alias /data/wenwan/images/; expires 7d; } }图片单独用alias映射到本地磁盘,由 Nginx 直接响应,不用经过 Tomcat,减少后端压力。proxy_set_header这几个头如果不配,HttpServletRequest.getRemoteAddr()拿到的是 127.0.0.1,一些风控逻辑会误判。
5.3 数据库连接池配置
SSM 里常配 Druid 连接池,除了基本连接参数,还要设置最大连接数和连接有效性检测:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="url" value="${jdbc.url}" /> <property name="username" value="${jdbc.username}" /> <property name="password" value="${jdbc.password}" /> <property name="initialSize" value="5" /> <property name="maxActive" value="30" /> <property name="validationQuery" value="SELECT 1" /> <property name="testWhileIdle" value="true" /> </bean>连接池参数不是越大越好。文玩销售这类小程序日常并发不高,maxActive: 30足够。数据库报Too many connections多数是连接泄漏,检查是否每个sqlSession都正确关闭,或者在 Service 层用了 MyBatis-Spring 自动管理但事务切面没生效。
5.4 抓包与联调技巧
联调阶段用 Charles 或 Fiddler 抓小程序流量,可以看到请求和响应的完整数据。抓包前需要将手机代理指向电脑,并安装证书。跳过证书校验的代码只能用于测试,发布前必须移除。
需要注意:小程序开发版请求到正式环境域名时,请求头里的Referer是带版本的,后端如果校验来源会失败。排查联调问题时,先看后端日志有没有收到请求,再对比前端res.statusCode是 404 还是 500,能快速定位是网络问题还是业务异常。
6. 文玩销售小程序上线后的 3 个进阶技巧
6.1 用乐观锁防超卖
下单扣库存时,直接update t_goods_sku set stock = stock - #{qty} where id = #{id}在高并发下有超卖风险。加一个版本号字段,更新时校验版本:
UPDATE t_goods_sku SET stock = stock - #{qty}, version = version + 1 WHERE id = #{id} AND version = #{version} AND stock >= #{qty}影响行数为 0 时说明版本冲突或库存不足,Service 层捕获后提示用户“手慢了,库存已更新”。可以把重试逻辑放在服务端,最多重试 3 次,避免把冲突直接抛给小程序端。
6.2 微信订阅消息推送发货通知
买家在订单详情页点击“提醒发货”后,小程序端调用wx.requestSubscribeMessage授权一次。后端拿到ticket后存到用户订单关联表。发货时后台调用subscribeMessage.send接口:
public void sendSubscribeMessage(String openid, String ticket) { String accessToken = getAccessToken(); // 查询缓存 Map<String, Object> body = new HashMap<>(); body.put("touser", openid); body.put("template_id", "模板ID"); body.put("page", "pages/order/detail?orderNo=xxx"); body.put("data", new HashMap<>()); // 小程序订阅消息对字段数量和模板要求严格,多送空白占位符 body.put("miniprogram_state", "formal"); HttpClientUtil.postJson("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=" + accessToken, JSON.toJSONString(body)); }订阅消息要提前申请模板,access_token两小时过期一次,需要缓存。注意小程序正式版才允许发送 formal,体验版要传trial才能正常推送。
6.3 商品搜索的模糊查询性能
文玩搜索词常是材质或工艺,用 MyBatis 动态 SQL 拼LIKE条件,但前模糊匹配会让索引失效:
<select id="search" resultType="map"> SELECT * FROM t_goods <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR material LIKE CONCAT('%', #{keyword}, '%')) </if> </where> </select>数据量不大时可以接受;如果商品上万,考虑接 Elasticsearch 或数据库全文索引。一个简单的折中方案是给material和category建短词索引,搜索时先按精确分类过滤,再在结果集里做 LIKE,速度会有明显提升。
本文还有配套的精品资源,点击获取