做二手交易平台最早是因为在校园社群里看到太多人发闲置转让信息消息一刷就没了东西卖没卖掉全靠缘分。后来我抽空把“Spring Boot 微信小程序二手交易平台”这套前后端分离的方案完整搭了一遍从用户登录到商品发布、从下单到后台审核整个流程走下来踩了不少坑。这篇文章就当是给同样想做类似项目的人一份参考不管是毕业设计、个人练手还是真想上线一个小范围使用的二手交易系统都可以照着这个思路来落地。我采用的方案很直接后端用 Spring Boot 提供接口小程序端用微信原生框架做页面数据库用 MySQL缓存用 Redis鉴权用 JWT。整体结构不复杂但足够把一个真实二手交易场景中的核心链路跑通用户微信授权登录、发布商品、浏览搜索、发起购买、线下交易、确认卖出的状态流转以及管理员对违规内容的审核处理。下面我会先从整体设计讲起再拆数据模型接着给关键功能的实现过程和代码思路最后把我在部署和运行阶段遇到的实际问题整理出来。内容偏实战适合已经能写简单 Spring Boot 接口、会一点小程序开发的读者如果你是纯新手建议先把基础接口写法和小程序页面生命周期补一补再看。1. 项目整体设计思路与技术选型1.1 为什么选择“Spring Boot 微信小程序”这套组合二手交易场景最大的特点是“用户就在手机上、交易是 C2C 的、低频但强信任”。选择微信小程序核心原因是用户不用下载独立 App扫描二维码或者搜索就能打开获客成本低。尤其是校园、社区这类封闭场景小程序天然适合在群聊里传播一个“大件转让”卡片分享到群里面同学点开就能看详情这个体验是原生 App 比不了的。后端选择 Spring Boot主要是考虑到生态成熟、上手成本低。Spring Boot 内置了 Web 容器不用像传统 SSM 项目那样折腾一堆 XML 配置配合 MyBatis-Plus 操作数据库单表 CRUD 基本不用写 SQL再搭配 Redis 做缓存整个项目对团队规模和技术水平的要求都不高。对一个人开发或者两个人协作的模拟项目来说这套技术栈足够稳定。如果说得更直白一点小程序端负责“好看好用”Spring Boot 负责“稳定靠谱”。前端调后端的 RESTful 接口数据用 JSON 格式传输代码结构清晰后面想拆微服务、加管理后台都可以在这套基础上继续扩展。1.2 功能模块与用户角色梳理做项目前先把“谁是用户、他能干什么”想清楚后面开发才不会东一锤子西一棒子。我的二手交易平台里一共分为三种角色普通用户买家/卖家发布闲置、编辑下架、浏览搜索、收藏商品、发起留言或下单、确认收货。管理员在 Web 管理端审核商品列表、处理举报、管理用户状态、查看平台数据。系统定时任务自动处理超时未确认订单、清理过期缓存、刷新商品热度排序。拆功能的时候我排序了优先级。第一版只做核心交易链路不碰即时聊天、支付、物流这些重量级功能。原因很简单二手交易本质是线下当面交易为主线上支付和寄送物流反而容易引入纠纷消息沟通用平台内置留言表实现一轮简单交互就够了真要实时聊天可以后续接 WebSocket。优先级清单大致是这样的基础微信登录、商品发布、商品列表、商品详情、状态管理进阶收藏、留言咨询、举报违规、后台审核扩展订阅消息通知、Redis 缓存热门商品、管理端数据统计这样划分的好处是即使只完成基础部分系统也已经具备“可用的二手交易平台”的完整闭环进阶和扩展内容则像给房子做装修不会影响主体结构。2. 数据模型设计与核心原理拆解2.1 用户身份链路从微信登录到 JWT小程序端没有传统的“用户名密码”而是调用wx.login()获取临时登录凭证code后端拿这个code去微信接口换取用户的openid。这个openid是用户在某个小程序下的唯一标识同一个用户的openid和unionid在开放平台下保持关联但对单个平台来说openid已经足够作为用户主键。我设计的登录流程是这样的小程序端调用wx.login()拿到code。小程序把code发送到后端接口/api/auth/login。后端用code调用微信接口换取openid和session_key。后端根据openid查表如果用户不存在就自动注册。后端生成 JWT Token返回给小程序端。小程序端把 Token 存到本地缓存后续所有请求都带在 header 的Authorization字段里。用 JWT 而不是服务端 Session是因为小程序请求是无状态的JWT 天然适合这种场景。用户登录后拿到一串加密字符串后端拦截器只校验签名和有效期不需要在 Redis 里维护一堆 session 记录减轻服务端压力。当然 JWT 也有处理不好的坑token 过期后用户需要重新登录。我采用的方案是让 token 有效期保持 7 天小程序端在收到 401 时静默调用wx.login()重新换 token用户几乎感知不到掉线。2.2 商品与订单状态机二手交易和普通电商最大的区别是一件商品只有一个卖家一个买家这个“一人一物”的独占关系必须靠状态机来保障。商品状态和订单状态不能各管各的必须联动。我定义的商品状态如下0草稿/待审核1在售2已锁定有人下单等待买家确认或支付3已售出4下架/删除5违规下架核心逻辑是只有状态为“在售”的商品才能被用户下单下单成功后商品状态立刻从“在售”变成“已锁定”防止被第二个人下单等买家确认收货或者管理员确认交易完成后商品变成“已售出”。这个“锁定”操作非常重要。新手最容易犯的错误是先减库存再去更新订单状态但二手商品根本没有库存概念一件商品一旦被锁定其他请求必须拦截。我用数据库的乐观锁来做并发控制在下单时执行类似UPDATE product SET status 2 WHERE id ? AND status 1的语句如果受影响行数为 0说明商品已经被人下单或下架操作直接失败。订单状态我也单独设计了一套10待付款/待确认20已锁定等待双方线下交易30已完成40已取消如果没有线上支付订单可以简化为“待确认—已完成”两个核心状态。但保留中间态对后续增加支付功能有好处不至于把数据模型推倒重来。2.3 数据库表结构参考数据库我用的是 MySQL 8.0表名和字段多采用下划线命名。核心表有这些user用户表存openid、昵称、头像、手机号、校区/位置、信用分、状态。category商品分类表存分类名称、排序、图标路径。product商品表存标题、描述、图片列表、价格、原价、成色、交易方式、校区位置、状态、浏览量、发布人。product_favorite收藏表用户和商品多对多关联。product_order订单表存商品 id、买家 id、卖家 id、订单状态、交易方式、创建时间、完成时间。message留言咨询表存买家、卖家、商品、内容、是否已读。report举报表存举报人、被举报商品、举报类型、举报说明、处理状态。admin_user后台管理员表使用 BCrypt 加密密码登录后签发独立 JWT。关键字段的实际经验是价格必须用decimal(10,2)不能用double否则浮点数运算会出现 0.10.20.30000000000000004 这类问题图片路径不要直接拼 URL要存相对路径或者对象存储的 key这样上线后迁移文件不至于改数据库凡是涉及多状态的地方我习惯加一个status字段并配上状态说明注释方便维护。3. 核心功能实现与实操记录3.1 微信登录接口实现先用 Maven 创建 Spring Boot 项目引入依赖spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok、jjwt、hutool-all。Hutool 是纯工具类里面封装了很多 Http 请求和 JSON 处理功能科研效率不错。登录接口的代码逻辑比较固定核心步骤是接收code拼装请求 URL 去微信接口换openid查用户表没有就注册然后生成 JWT。关键代码如下PostMapping(/auth/login) public RString login(RequestBody LoginRequest request) { String url StrUtil.format( https://api.weixin.qq.com/sns/jscode2session?appid{}secret{}js_code{}grant_typeauthorization_code, appId, appSecret, request.getCode() ); String response HttpUtil.get(url); JSONObject json JSONUtil.parseObj(response); String openid json.getStr(openid); if (StrUtil.isBlank(openid)) { return R.fail(微信登录失败); } User user userService.getOne(new LambdaQueryWrapperUser() .eq(User::getOpenid, openid)); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(微信用户 openid.substring(openid.length() - 6)); user.setStatus(1); userService.save(user); } String token JwtUtil.createToken(user.getId(), user.getNickname()); return R.ok(token); }注意微信接口返回的openid是小程序侧的唯一标识测试阶段经常有人把公众号的 appsecret 拿过来导致请求失败。务必确认小程序后台的AppID和AppSecret没填错。JWT 创建和校验我用的是 jjwt 库密钥放在配置文件中发布时用环境变量替换不要硬编码到源码里。3.2 商品发布与图片上传商品发布页面需要用户填写标题、分类、价格、描述、成色、交易方式并上传 1-9 张图片。图片上传这块很容易踩坑我在第一版用的是本地磁盘存储上线后才发现这种方式在多实例部署时文件不同步后来换成了对象存储方案。对于模拟项目最简单又稳妥的做法是把图片转成 Base64 直接存数据库我一开始确实这么试过但两张高清图就能让接口请求体非常大数据库压力也大。配合小程序端的wx.chooseMedia组件可以先压缩图片再通过后端临时文件上传到对象存储。后端接收图片的思路PostMapping(/file/upload) public RString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return R.fail(请选择文件); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.) 1); if (!Arrays.asList(jpg, jpeg, png, gif, webp).contains(suffix)) { return R.fail(不支持的图片格式); } String key product/ DateUtil.format(new Date(), yyyyMMdd) / UUID.randomUUID() . suffix; // 交给 ossService 或 localFileService 保存文件 String url fileService.upload(key, file); return R.ok(url); }上传成功后返回 URL商品发布接口把 URL 拼接成图片列表存入数据库。这里有一个细节图片列表我用的是 JSON 字符串保存字段类型设置成varchar(1000)。虽然不符合关系型数据库复审,但胜在简单读取时直接 JSON 解析。发布商品时有一点尤其重要必须把当前登录用户作为卖家写入商品表不能相信前端传来的userId。否则别人抓包伪造请求就能把商品挂到别人名下。正确的做法是从 JWT 拦截器注入用户 ID接口里直接UserContext.getUserId()。3.3 下单锁定与状态流转实现二手平台的下单区别于电商购物车不需要复杂结算流程。用户看到商品后点击“我想要”后端生成一笔订单同时把商品锁住。业务流程如下校验商品是否存在状态是否为“在售”。校验买家不能买自己的商品。创建订单状态为“待付款/待确认”。用乐观锁更新商品状态UPDATE product SET status 2 WHERE id ? AND status 1。如果更新失败回滚订单并提示“手慢了商品已被下单”。这里必须把创建订单和锁定商品放在同一个事务里。我是在ProductOrderService上加了Transactional(rollbackFor Exception.class)方法内先 insert 订单再执行商品状态更新。如果中间抛出异常订单不会残留成脏数据。下面是一个简化版代码Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long productId) { Product product productService.getById(productId); if (product null || product.getStatus() ! 1) { throw new BizException(商品已下架或已被下单); } if (userId.equals(product.getSellerId())) { throw new BizException(不能购买自己发布的商品); } ProductOrder order new ProductOrder(); order.setProductId(productId); order.setBuyerId(userId); order.setSellerId(product.getSellerId()); order.setPrice(product.getPrice()); order.setStatus(10); orderService.save(order); boolean updated productService.updateStatusLocked(productId, userId); if (!updated) { throw new BizException(商品已被其他人抢先下单); } return order.getId(); }确认完成的逻辑则更简单买家确认收到货后订单状态改成“已完成”商品状态改成“已售出”卖家增加信用分。为了防止恶意取消超过 48 小时未确认的订单可以由管理员手动处理或者用定时任务自动关闭。4. 高频踩坑与排查实战4.1 并发下单导致商品被重复购买这是我在自测时反复遇到的问题同一个商品两台手机同时点“我想要”结果两条订单都创建成功了。原因很简单我的第一版代码先查询商品状态再创建订单再更新商品状态。两个请求同时查数据库时看到的商品状态都是“在售”于是都进入了创建流程。解决思路是靠数据库的乐观锁而不是靠代码里的 if 判断。我把更新商品状态的 SQL 加上了status 1条件只要更新影响行数为 0就说明状态早已被别人改了。这段事务逻辑放在数据库层面能有效防止并发穿透。搭建过程中我还做了第二个保险给订单表设置唯一索引uk_product_id。因为一手商品在同一时间段只能生成一笔有效订单所以商品 ID 在未完成订单中必须唯一。使用product_id status的组合索引也可以但要注意非唯一状态带来的索引设计问题。我最终是把status为 10 和 20 的订单视为“未完成”这部分在写入时由代码保证唯一。4.2 图片上传后无法访问的问题本地存储的图片启动时用的是相对路径保存结果页面上一片空白。排查后发现我直接把C:/upload/product/a.jpg存进数据库小程序端根本访问不到这个路径。正确做法是数据库里存/uploads/20250312/xxx.jpg这种虚拟路径然后通过后端静态资源映射把/uploads/**指向磁盘目录。Spring Boot 里的映射配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourcePathResolver(file: uploadPath /); } }但这个方法只适合本地开发。一旦应用部署到服务器或者未来做了容器化部署本地磁盘文件就会出问题。更实用的方案是直接用对象存储服务把域名换成自己的 CDN 域名前端展示时拼接完整 URL。如果只是交作业或 demo本地存储配好静态资源映射也够用。4.3 小程序请求域名与 HTTPS 配置开发阶段小程序开发者工具可以勾选“不校验合法域名”所以请求http://localhost:8080没问题。但到了真机测试或者发布体验版就必须在小程序后台配置服务器域名。我这里踩过一个典型的坑小程序要求所有请求域名必须是 HTTPS同时不能带端口号默认 443。一开始我图方便给后端直接开了个http://服务器IP:8080结果真机上请求全部被拦截控制台报错显示“url not in domain list”。最后的解决方案是在服务器上用 Nginx 做反向代理将https://api.example.com/转发到内网http://localhost:8080/并且在小程序后台的“服务器域名”里配置 request 合法域名。HTTPS 证书我用的免费证书申请和配置都比较简单关键是不要把证书到期时间忘了不然某一天小程序突然就白屏了。4.4 订阅消息推送的必要条件本来想实现“商品被下单后给卖家发通知”结果始终收不到消息。排查之后发现原因有两个第一个小程序订阅消息是“一次性订阅”用户必须主动点击“允许”并授权一次开发者才能给他发一条模板消息。不能像公众号那样随时随地群发。所以我在下单页面上增设了一个“允许通知我”的按钮用户点击后才调wx.requestSubscribeMessage拿到的ticket传到后端存起来。第二个订阅消息的模板 ID 必须从小程序后台申请不能自己随便填。而且touser是用户的openid参数不能带去表情符号。我把测试信息带着几个表情符号接口一直报参数错误去掉后才正常。如果不想把流程做复杂可以退一步卖家只需要打开小程序看“我的卖出”也能看到订单状态。订阅消息作为辅助提醒来设计会轻松很多。5. 性能优化与上线部署经验5.1 缓存策略与数据库索引优化二手交易平台的并发量其实不会特别高但商品列表页是被频繁访问的。我用 Redis 给首页商品列表做了缓存key 类似product:list:hot有效期 10 分钟。发布新商品时主动删除这个缓存下一次请求就会回源数据库重新加载。不过这里有一个容易踩坑的地方缓存整个商品详情页会导致敏感信息泄露的风险。因为接口需要返回卖家的联系方式、位置等私有字段如果直接缓存整个 JSON活跃商品会享受缓存加速但商品一旦被下架缓存里还是旧数据用户依然能看到。我的解法是详情接口只缓存商品基础信息标题、图片、价格、成色卖家联系信息永远实时查库这样既减少数据库压力又不会出现下架商品仍可访问的问题。数据库索引方面最有效的几个索引是product表status单列索引seller_id单列索引product_order表buyer_id、seller_id单列索引product_id未完成订单唯一索引product_favorite表user_id product_id联合唯一索引有了这些索引之后列表筛选、用户“我发布的”、用户“我买到的”这些查询都能走索引数据量在两三万条时响应速度基本都在 50ms 以内。5.2 服务器部署与日常维护我选了最常规的单体应用部署方式一台云服务器装 JDK 17、MySQL 8.0、Redis、Nginx。Spring Boot 项目打成 jar 包后用systemd守护进程跑内存分配控制在 512MB 到 1GB 之间。分享一个实用的部署流程本地执行mvn clean package -DskipTests生成 jar 包。用scp把 jar 包传到服务器的/app目录。停掉旧服务systemctl stop secondhand。备份数据库mysqldump -u root -p secondhand secondhand.sql。启动新服务systemctl start secondhand然后检查日志journalctl -u secondhand -f。日常维护最怕的就是数据库连接池被打满。我注意到小程序端每次冷启动会瞬间发送好几个请求加上管理后台轮询接口连接池默认 10 个连接很容易不够用。后来我把 HikariPool 的最大连接数调到 30并且给日志接口加了一层 Redis 缓存服务就稳定多了。还有一个容易被忽略的问题是时区配置。数据库连接串必须带上serverTimezoneAsia/Shanghai服务器系统时间也要校准否则时间字段会差 8 小时订单超时判断就会出错。5.3 给初学者的后续扩展建议如果你照着这套思路做完了一个能跑通的核心版本下一步我认为可以优先扩展这几个方向第一个管理后台。虽然前端用小程序的接口可以直接增删改查但没有 Web 管理端商品审核和举报处理根本无从下手。我建议单独做一个 Vue 管理端管理员登录后能看到待审核列表、下架违规商品、封禁恶意用户。这部分不算难主要就是接口权限控制要严格普通用户和管理员的 Token 需要用不同的签发方式避免用户伪造管理员身份。第二个消息通知。前一版我用的是简单的站内留言表只能做“买家留言卖家看到后回复”的弱交互。如果需求真实上线建议引入 WebSocket 或小程序客服消息买卖双方可以实时沟通。但要注意实时聊天带来的是内容安全审核压力一旦有人发布违规内容平台需要能及时处理。第三个信用评价体系。每一笔完成的订单买卖双方可以互相评价评分直接累积到用户信用分上。这个模块可以很好地约束线下交易中的爽约行为后期也能通过信用分做商品推荐排序提升整体平台质量。我在实际开发中感受最深的一点是这类系统真的不是功能写得越多越厉害而是“核心链路越稳越厉害”。登录、发布、下单、确认这四个动作不出错项目就成功了八九十。后面那些花哨的功能都是在保证核心不塌的前提下逐步添加的。最后再分享一个小技巧如果时间紧张可以先不做管理后台的整套页面而是在现有数据库测试数据里造几个不同角色的账号用 Postman 把后端的接口全部测通。小程序端再按照接口字段把页面填起来这样即使管理端是半成品演示效果也不会太差。真要正式上线前记得把测试数据清理干净把你的 AppSecret 换成环境变量再用 Nginx 把 HTTPS 配上这样整个项目才算是真正可以交给用户使用。祝你也能顺利把属于自己的二手交易平台跑起来。