资讯动态

微信小程序+SSM后端:文玩销售系统从登录到支付的关键实现

发布时间:2026/9/13 14:33:42 来源:尧图企业网站定制
简介基于微信平台的文玩销售小程序是一个面向高校毕业设计及小程序电商初学者的完整项目。后端选用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 BootSSM 的自动装配更少出现问题时能顺着 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.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/wenwan?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot 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封装一个WechatLoginServiceRestController 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?appidxxxsecretxxxjs_codexxxgrant_typeauthorization_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, ListLong cartIds, Long addressId, String remark) { ListCartItem 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 MapString, String unifiedOrder(Order order, User user) { SortedMapString, 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); MapString, String resp XmlUtil.xmlToMap(respXml); String prepayId resp.get(prepay_id); // 二次签名给小程序 SortedMapString, 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_idxxx漏掉前缀会报invalid payment。3.3 支付回调与订单状态更新支付成功后微信服务器会向notify_url发起异步回调。回调处理必须做到幂等重复报文不能导致订单状态被覆盖或发生二次业务处理。正确顺序是验证签名 → 校验金额和订单号 → 查订单当前状态 → 如果是已支付就返回成功不再处理 → 否则更新状态。回调接口里返回给微信服务器的响应是纯文本成功返回SUCCESS失败返回FAIL。常有人在这里把 JSON 返回给微信服务器导致平台一直重复推送。RequestMapping(/pay/notify) ResponseBody public String payNotify(HttpServletRequest request) { String xml HttpUtil.readBody(request); MapString, 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然后在方法上加ScheduledScheduled(cron 0 */5 * * * *) public void closeExpiredOrders() { ListOrder 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.jsconst 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触底加载用onReachBottomPage({ 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 classsku-panel view wx:for{{skuList}} wx:keyid classsku-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 和 HTTPS5.1 打包与部署SSM 项目用mvn clean package打包成 war 包放到 Tomcat 的webapps目录重启 Tomcat 后自动解压。部署前检查三处JDK 版本与代码编译版本一致数据库连接可连通Tomcat 的 server.xml 里 URIEncoding 设置为 UTF-8否则小程序传中文备注会乱码。开发环境和生产环境的jdbc.properties不同建议用 Maven profile 做环境隔离profiles profile idprod/id properties envprod/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 iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameurl value${jdbc.url} / property nameusername value${jdbc.username} / property namepassword value${jdbc.password} / property nameinitialSize value5 / property namemaxActive value30 / property namevalidationQuery valueSELECT 1 / property nametestWhileIdle valuetrue / /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(); // 查询缓存 MapString, Object body new HashMap(); body.put(touser, openid); body.put(template_id, 模板ID); body.put(page, pages/order/detail?orderNoxxx); 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 idsearch resultTypemap SELECT * FROM t_goods where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR material LIKE CONCAT(%, #{keyword}, %)) /if /where /select数据量不大时可以接受如果商品上万考虑接 Elasticsearch 或数据库全文索引。一个简单的折中方案是给material和category建短词索引搜索时先按精确分类过滤再在结果集里做 LIKE速度会有明显提升。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价