基于SSM与微信小程序的二手交易平台设计与实现要点
2026/9/19 6:02:20 网站建设 项目流程

简介:一份基于微信小程序的二手物品交易平台SSM毕业论文,适用于计算机专业毕业生或正在设计类似课设的开发者,可帮助解决从选题到系统实现的完整写作与设计参考。资源是单个doc文档,大小约1.5MB,已有77人学习。文档结构完整,从绪论切入项目背景、意义与研究内容,围绕SSM(Spring、SpringMVC、MyBatis)框架、MySQL数据库和微信小程序端展开技术选型分析,涵盖用户、物品、交易等核心数据表设计,并涉及安全性与性能优化等关键环节。后续章节还包含系统设计原则、模块划分、前后端接口设计与具体实现步骤,附有摘要、目录和关键词。读者可借助文档快速搭建同类交易平台的论文框架,理解前后端交互及数据库实现要点,适合用于毕业论文撰写参考和毕业设计项目梳理。

1. 从毕业论文标题到可交付的系统,这个课题的难点在哪

“基于微信小程序的二手物品交易平台 ssm”这类题目在毕业论文里出现频率很高,但多数实现停留在“商品 CRUD + 登录注册”的层面。真正让它区别于普通管理系统的地方,在于交易闭环:商品从发布、浏览、联系、下单到确认收货,每一步都涉及状态变更和权限校验,而这些恰恰是 SSM 框架最容易写出隐患的部分。SSM 本身是经典教学组合,资料多、部署简单、答辩时容易讲清楚,适合作为课题的技术底座;小程序的触达成本低,用户不需要下载 App,扫码即用,也比较贴近二手交易的实际使用场景。这篇文章按一个可复现的开发方案来展开:先梳理业务模型,再分别落地后端接口和小程序端实现,最后收在部署验证和容易踩的坑上,适合正在做同类毕业设计、或想快速搭一个二手交易 Demo 的开发者。

2. 业务模型与选型:先想清楚二手交易和小程序各自要什么

2.1 为什么是“微信小程序 + SSM”而不是 Spring Boot + Vue

严格来说,当前新项目用 Spring Boot 更省事,但在毕业论文场景下 SSM 依然有其合理性:一是网上可参考的代码多,出问题时容易找到对照;二是它能清楚地展示 Spring 的 IoC 和 AOP、SpringMVC 的请求流转、MyBatis 的 SQL 映射,答辩时有东西可讲;三是学校机房或自己电脑上的旧 JDK/Tomcat 环境往往能直接跑起来,不需要折腾新版本依赖。如果已有的参考代码是 Spring Boot 也没关系,Sm拆分的思路完全一致,只是注解和配置文件写法不同。小程序端则优先选择原生开发,原因在于微信官方文档和社区资料最全,涉及登录、支付、地理位置等能力时原生代码可以直接复用;如果之前熟悉 Vue,用 uni-app 也可以,但“UI 层和生命周期差异”会增加不必要的排查成本。

2.2 核心业务闭环与状态机设计

二手交易平台不能只做一个展示页面。最小可用闭环包含六条链路:

  1. 用户通过微信登录获取 openid,后端生成业务 token;
  2. 用户发布闲置物品,填写标题、描述、价格、成色、图片、所在城市;
  3. 其他用户按分类或关键词搜索商品,查看详情并发起“我想要”;
  4. 买卖双方通过平台内留言或预留的微信号沟通(不做即时聊天时);
  5. 买家对心仪商品发起下单,卖家标记“已卖出”或“已下架”;
  6. 双方线下交易完成后,买家确认收货,订单状态变为“已完成”,卖家积累信用记录。

其中订单状态必须用状态机来控制。常见做法是用一个整型或字符串字段表示状态,并在 Service 层定义可流转方向。比如订单状态枚举为:待付款/待发货/待收货/已完成/已取消。需要注意“取消”不能从“已完成”回退,“发货”只能由卖家操作,“收货”只能由买家操作。如果不用状态机,只在前端控制按钮显隐,很容易出现“用户直接改请求参数跳到任意状态”的漏洞。

2.2.1 数据表设计:至少需要五张表

一张商品表、一张订单表、一张用户表,外加收藏和留言,就能覆盖 90% 的演示需求。字段设计上注意以下几点:价格用分存储,避免浮点数精度问题;图片用逗号分隔的 URL 字符串,最多存 9 张,方便小程序端用循环渲染;状态字段用 tinyint;所有表都带 create_time 和 update_time。商品表的核心字段包括 id、uid、title、description、price、original_price、category_id、status、images、location、view_count、create_time;订单表包括 id、order_no、product_id、buyer_id、seller_id、price、status、create_time、pay_time、finish_time。用户表不与微信 openid 之外的信息强绑定,首次登录时自动注册即可。

3. SSM 后端的接口层实现:从登录鉴权到订单流转

3.1 用 SSM 搭建最省心的项目结构

如果从零创建,可以采用标准的 Maven 分模块结构:ssm-common 放统一返回结果和工具类,ssm-pojo 放实体类,ssm-dao 放 MyBatis 的 mapper 接口和 XML,ssm-service 放业务逻辑,ssm-web 放 SpringMVC 的 Controller。配置上需要 web.xml 加载 Spring 和 SpringMVC 两个上下文,一个管业务 Bean,一个管 Controller;事务配置在 service 层,用<tx:annotation-driven>开启注解事务,因为订单下单涉及商品状态和订单表两条写操作,必须保证原子性。

mybatis-config.xml 里开启驼峰映射和日志输出,前者让数据库字段与 Java 属性自动对应,后者方便调试 SQL。

<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> </configuration>

这是最基础的配置,主要解决三个问题:数据库列名下划线转 Java 驼峰、SQL 日志输出到控制台、加载 mapper 文件。如果不开启驼峰映射,查询结果中的 create_time 字段将无法赋值给 createTime 属性,需要手动写 resultMap,增加不必要的代码量。

3.2 微信登录与 token 鉴权

微信小程序的登录不依赖传统账号密码。前端调用wx.login()拿到临时 code,后端用这个 code 请求微信接口获取 openid 和 session_key,然后用 openid 作为用户唯一标识,生成一个自己的业务 token 返回给前端。这里不能直接把微信的 session_key 当作业务 token 使用,因为 session_key 有时效且和微信官方服务器有关,不应该传到前端。

Controller 层代码如下:

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { String openid = userService.code2Openid(request.getCode()); if (openid == null) { return Result.error("登录失败,code 已失效"); } User user = userService.getOrCreateUser(openid); String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("token:" + token, user.getId().toString(), 2, TimeUnit.DAYS); return Result.success(new LoginResponse(token, user)); } }

逻辑说明:code2Openid内部通过 HttpClient 调用微信官方接口,把 code 换成 openid;getOrCreateUser先按 openid 查一次库,查不到就新建用户;token 存入 Redis 并设置两天过期,后续请求通过拦截器校验。需要注意的是 code 是一次性的,前端拿不到 openid,只能拿到后端返回的 token,这样才能保证 openid 不泄露。

3.2.1 登录 token 的拦截器配置

拦截器是必须的,否则所有接口都裸奔。在 SpringMVC 配置中注册登录拦截器并排除登录接口,然后实现preHandle方法,从请求头中取出Authorization字段,去 Redis 查 token,查不到就返回 401。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String token = request.getHeader("Authorization"); Object userId = redisTemplate.opsForValue().get("token:" + token); if (userId == null) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } request.setAttribute("currentUserId", Long.valueOf(userId.toString())); return true; } }

要点:request.setAttribute把当前用户 ID 存到请求域,Controller 里用(Long) request.getAttribute("currentUserId")就能拿到当前登录用户,不需要反复解析 token。这里必须考虑“全局异常处理器”兜底,因为一旦用户带过期 token 访问接口,拦截器返回的是纯 JSON 串,而不是统一格式,前端解析时可能会挂掉。常见做法是拦截器返回 401 状态码,小程序端在封装wx.request时统一跳转回登录页。

3.2.2 发布商品接口与图片上传路径

发布商品是写入操作,需要校验字段非空、价格大于 0、最多传 9 张图。图片上传可以走两种方案:直接存服务器本地目录,或接入云存储。毕业设计建议直接用服务器本地路径,例如/upload/20250406/xxx.jpg,数据库里只存相对路径,页面拼接完整的域名访问。上传接口用 MultipartFile 接收文件,按日期分目录存储,文件名用 UUID 加后缀防止重名。注意给上传目录配置静态资源映射,否则 SpringMVC 访问不到上传的文件。商品发布接口核心逻辑段如下:

public Long addProduct(Product product, MultipartFile[] images) { if (product.getPrice() == null || product.getPrice() <= 0) { throw new BizException("价格必须大于0"); } String basePath = "/upload/" + LocalDate.now().toString().replace("-", ""); List<String> urls = new ArrayList<>(); for (MultipartFile image : images) { String fileName = UUID.randomUUID().toString().substring(0, 8) + getExt(image.getOriginalFilename()); image.transferTo(new File(basePath + "/" + fileName)); urls.add(basePath + "/" + fileName); } product.setImages(String.join(",", urls)); product.setStatus(1); productMapper.insert(product); return product.getId(); }

参数说明:getExt用来取文件后缀,只允许 jpg、png、jpeg 和 webp;setStatus(1)表示上架在售状态,后续下架改为 0。图片格式校验不能省略,否则用户会上传 exe 或 html 文件,造成存储型 XSS 风险,也会拖累打开速度。

3.3 订单下单与并发防重

下单操作要同时校验三件事:商品是否存在、商品是否已下架、买家不能买自己的商品。如果并发请求同时触发,可能出现“同一商品被两个人同时下单成功”的脏数据,因此在 Service 层要用乐观锁或数据库唯一约束兜底。较简单可靠的方案是在商品表加一个order_lock字段,下单时执行UPDATE product SET status = 2, order_lock = 1 WHERE id = ? AND status = 1;如果受影响行数为 0,说明商品已被别人抢过,直接抛异常。这是“乐观锁”在单表上的典型应用,没有引入 Redis 分布式锁的复杂度,SSM 毕业设计足够用。

下单接口的时序是:校验登录 → 校验商品 → 加锁 → 创建订单 → 返回订单号。相关代码:

@Transactional public Order createOrder(Long productId, Long buyerId) { Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BizException("商品不存在或已下架"); } if (product.getUid().equals(buyerId)) { throw new BizException("不能购买自己发布的商品"); } int rows = productMapper.lockForOrder(productId); if (rows == 0) { throw new BizException("手慢了,商品已被下单"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setProductId(productId); order.setBuyerId(buyerId); order.setSellerId(product.getUid()); order.setPrice(product.getPrice()); order.setStatus(0); orderMapper.insert(order); return order; }

@Transactional保证商品状态变更和订单插入要么同时成功、要么同时失败。productMapper.lockForOrder的 SQL 就是前面说的条件更新语句,若商品已被其他订单锁定,则返回 0。这里有一个细节:订单状态 0 代表“待付款/待交易”,二手交易不强制接入微信支付,所以订单表里可以去掉支付回调,双方线下碰面后由买家点击“确认收货”完成最终状态;如果后续要接入校园卡或押金支付,再扩展支付字段,这一层接口设计不会受到限制。

3.3.1 订单状态流转与权限控制

状态变更接口需要区分操作者身份。/order/ship只有卖家能调用,/order/confirm只有买家能调用,前提是当前状态匹配。在 Service 层必须做两件事:通过订单查到商品归属(而不是信前端传来的 seller_id),比较当前请求的用户 ID 是否匹配;然后判断当前状态能否流转到目标状态。订单表只有明确的“状态机”才能保证业务闭环,否则前端任意调接口就能把订单从“待付款”改成“已完成”。

3.4 商品列表的分页查询与条件筛选

二手平台的首页普遍是“最新发布”流,附带分类、价格区间、城市三个筛选维度。用 MyBatis 动态 SQL 可以一个方法应对多种查询条件,不需要为每个筛选条件都写一个接口。分页用 PageHelper,因为它是国内使用率最高也最省事的 MyBatis 分页插件。关键查询如下:

<select id="selectProductPage" resultType="com.demo.entity.Product"> SELECT * FROM product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> AND status = 1 </where> ORDER BY create_time DESC, id DESC </select>

逻辑说明:<where>标签会自动去掉第一个多余的 AND,这是 MyBatis 动态 SQL 最常用的小技巧;status = 1固定在条件里,确保下架商品不出现在搜索结果中;排序先按create_time倒序再按id倒序,保证同一秒发布的商品也有稳定的先后顺序。分页参数由 PageHelper 注入PageHelper.startPage(pageNum, pageSize),返回结果用PageInfo包装,既能得到当前页列表,也能一次性拿到总条数和总页数。

这里要额外处理图片列表:数据库存的是逗号分隔的 URL,Controller 返回给小程序前应该转换成 List 类型,避免前端再去split(",")。此类组装逻辑统一放在 VO 层做,Service 返回实体、Controller 返回 VO,这样数据库字段不会泄露到前端,同时便于在字段上面补充描述信息。

4. 微信小程序端实现:从登录到交易的关键页面

4.1 合理划分页面结构与自定义导航栏

小程序端建议划分八个页面:首页(商品流)、分类页、搜索页、商品详情页、发布页、订单列表页、订单详情页、我的(个人中心)。再算上登录页和编辑资料页,总共十个左右,页面不多,但彼此之间跳转关系较密,要提前定义好跳转路径,防止后期页面嵌套混乱。首页推荐流使用onReachBottom来触底加载下一页,用onPullDownRefresh做下拉刷新。

顶部导航栏高度是一个不容易处理的问题。默认导航栏左右两边会留出胶囊按钮的空间,不同机型比例不同,如果直接写padding-top: 64px,在 iPhone X 等刘海屏上就会遮住内容。常见做法是获取系统状态栏高度后动态设置占位视图高度:

const systemInfo = wx.getWindowInfo(); const menuButton = wx.getMenuButtonBoundingClientRect(); const navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height; this.setData({ statusBarHeight: systemInfo.statusBarHeight, navBarHeight: navBarHeight });

参数说明:wx.getWindowInfo()能拿到状态栏高度和窗口尺寸,wx.getMenuButtonBoundingClientRect()能拿到右上角胶囊按钮的位置和尺寸,两行计算就能得到自定义导航栏的实际高度。不建议写死数值,因为安卓、iOS 和不同机型的刘海屏参数差异很大。

4.2 小程序登录与本地登录态

小程序端登录流程要与 3.2 节的后端接口配合。核心代码:

login() { wx.login({ success: (res) => { if (res.code) { wx.request({ url: `${this.globalData.baseUrl}/api/user/login`, method: 'POST', data: { code: res.code }, success: (resp) => { const { token, user } = resp.data.data; wx.setStorageSync('token', token); wx.setStorageSync('userInfo', user); } }); } } }); }

代码里wx.login获取临时 code,后端用它换取 openid 并生成业务 token,前端将 token 和用户信息写入本地存储。这个 token 在后续所有接口请求中通过Authorization请求头传递。注意不要在前端通过 code 直接换取 openid,微信不允许这种做法,而且也没有小程序端换取 openid 的接口。

封装wx.request时,遇到状态码 401 要自动清除本地 token 并跳转登录页;遇到网络错误需要提示用户检查网络。真机预览时如果发现“请求无法到达后端”,最常见的两个原因:一是小程序开发工具勾选了“不校验合法域名”,而真机没有勾选,需要在小程序后台配置 request 合法域名;二是后端部署在http://localhost:8080,真机无法访问电脑的 localhost,需要改成局域网 IP 或云服务器 IP,同时确保防火墙和服务器安全组放行对应端口。

4.3 首页商品流与上拉分页

首页的性能关键点在于分页和图片懒加载。分页参数 pageNum 从 1 开始,每次触底加 1,如果返回的数据条数小于 pageSize,则不再发起请求。使用wx:for渲染列表,图片在标签上使用lazy-load属性,让屏幕外的图片延迟加载,可以显著提升滚动流畅度。

loadProducts() { const params = { pageNum: this.data.pageNum, pageSize: 10, keyword: this.data.keyword || '', categoryId: this.data.categoryId || '' }; request({ url: '/product/page', data: params, success: (res) => { const list = res.data.data.list; this.setData({ productList: this.data.pageNum === 1 ? list : this.data.productList.concat(list), pageNum: this.data.pageNum + 1, hasMore: list.length === 10 }); } }); }

参数说明:pageNum === 1时是下拉刷新场景,直接覆盖列表;否则为触底加载,使用 concat 追加。hasMore用于控制触底时是否继续发起请求,防止无效请求浪费流量。这类分页逻辑在所有列表页通用,建议抽成公共 mixin 或公共方法,避免每个页面重复写。

4.4 发布页:图片上传与表单校验

发布页的表单包括:标题、描述、成色(全新/轻微使用/明显使用痕迹)、价格、原价、分类、手机号、所在地。成色用radio-group实现,分类用picker,所在城市可以用picker配合城市数据数组。图片选择使用wx.chooseMedia(这是新版 API,旧版chooseImage已不推荐),最多选择 9 张。上传时先调用后端的图片上传接口拿到 URL,再连同表单数据一起提交,而不是把地球上图片 base64 塞进表单里,否则请求包体积会非常大,导致真机上传卡顿。

以下是图片选择与上传的核心逻辑:

chooseImages() { wx.chooseMedia({ count: 9 - this.data.images.length, mediaType: ['image'], sizeType: ['compressed'], success: (res) => { const tempFiles = res.tempFiles; tempFiles.forEach((item) => { wx.uploadFile({ url: `${this.globalData.baseUrl}/api/upload`, filePath: item.tempFilePath, name: 'file', success: (uploadRes) => { const data = JSON.parse(uploadRes.data); this.setData({ images: this.data.images.concat(data.data) }); } }); }); } }); }

需要注意wx.uploadFile的响应是字符串类型,需要JSON.parse处理;上传图片时不能和普通表单请求共用同一个wx.request,因为uploadFile的 Content-Type 是 multipart/form-data。用户在选择图片时可以顺带做格式校验,直接限制sizeType为 compressed,避免上传几十 MB 的原图。

4.5 订单操作按钮的显隐逻辑

订单列表页的每个订单卡片下方需要显示不同操作按钮,依据是订单状态和当前登录用户身份。例如订单状态为“待发货”时,卖家和买家看到的内容不同:卖家看到“发货”按钮,买家看到“等待卖家发货”的纯文本提示。直接在小程序端写一堆wx:if会让模板变得冗长,常见做法是后端返回订单数据时同时返回canShipcanConfirmcanCancel三个布尔字段,小程序只根据这三个字段渲染按钮。后端一次性算好权限和状态,一方面减少了小程序端的判断逻辑,另一方面也避免前端知道订单状态机的全部流转规则,安全性更好。

5. 部署验证的三个关键操作与一个收尾技巧

5.1 后端部署后的联调验证顺序

后端代码写完后,不要急着连小程序,先用 Postman 或 Apifox 按顺序验证五组接口:登录换取 token、发布商品、分页搜索、下单、确认收货。验证时必须携带刚拿到的 token,否则会返回 401。这个顺序实际上就是完整业务主流程,能跑通说明核心链路没有问题,然后再进入小程序端联调。如果发布商品后图片无法访问,先检查 SpringMVC 是否配置了静态资源映射,再看上传目录的读写权限;如果是 CentOS 服务器,/upload目录通常是 root 权限,需要把目录属主改成 Tomcat 的运行用户,并执行chmod -R 755 /upload

5.2 小程序上线前的合规检查项

在小程序后台提交审核前,需要逐项确认:request 合法域名已配置且为 HTTPS;隐私协议中声明收集了用户微信昵称、头像、位置信息;有用户协议和售后规则页面;涉及交易功能的类目选择“二手闲置交易”,需要提交交易纠纷处理机制说明。开发版和体验版可以勾选“不校验合法域名”,但正式版不行,域名必须有 ICP 备案和 HTTPS 证书。另外要注意,平台对交易类小程序的审核较严格,发布功能需要在体验版里提前真人测试,尤其是“发布失败时是否给出明显提示”“商品被下单后再次访问详情页是否展示‘已抢光’”这类边界状态,很可能成为驳回理由。

5.3 图片旋转、iOS 静音与页面数据的几个易忽略点

用户从手机相册选择图片上传后,部分安卓机型会出现图片旋转 90 度的问题。原因是 EXIF 中记录了旋转方向,而后端直接存了原图。解决方法是小程序端选择图片时用wx.compressImage或 canvas 重绘一次,把方向信息抹掉;后端在图片上传接口中,也可以引入 Thumbnailator 库统一压缩为宽 800 像素的 JPEG,既解决旋转问题又控制大小。iOS 静音模式下页面里嵌入的音频不会自动播放,但二手交易平台中这一影响不大,唯一要注意的是消息提示音不能用wx.createInnerAudioContext播放,应使用wx.showToastwx.vibrateShort的震动反馈,避免用户戴着耳机被突如其来的声音打扰。

5.4 数据一致性收尾:给关键表加索引

直接给出运维阶段容易忽略的一个优化点:product表的statuscreate_time需要建联合索引,order表的buyer_idseller_idstatus需要分别建索引。没有索引时,随着数据量增长,首页查询会退化为全表扫描,这是很多 SSM 项目上线后性能下降的第一来源。建索引的 SQL:

ALTER TABLE product ADD INDEX idx_status_time (status, create_time); ALTER TABLE `order` ADD INDEX idx_buyer_status (buyer_id, status), ADD INDEX idx_seller_status (seller_id, status);

这两个索引能覆盖绝大多数场景:首页按上架时间倒序、我的发布按状态过滤、订单列表按买家或卖家维度查询。索引不是越多越好,像description这种大字段不要建索引,模糊查询走 LIKE 本身也命中不了索引;不要为price建单列索引,因为筛选单价区间的场景通常还会带分类和状态条件,联合索引的收益大于单列索引。上线前花十分钟检查EXPLAIN输出,确认key字段已经走到idx_status_time,再用sysbenchjmeter压 1000 并发订单请求,观察是否存在超卖现象。订单表设计时预留version字段,后续如果需要把乐观锁做得更完善,可以在状态更新时附带WHERE version = ?,让并发控制更稳。

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

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

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

立即咨询