简介:校园二手物品交易系统是一套基于微信小程序与Java开发的完整毕业设计源码包,面向计算机相关专业学生,可作为毕业设计、课程设计或项目实训的参考范本。项目采用小程序开发工具、MySQL数据库与B/S架构,涵盖服务端后台和移动端两个部分:后台支持首页跳转、用户信息管理、二手商品管理、违规投诉管理与订单管理;前端提供首页分类浏览、商品发布、个人中心等完整交易闭环,可帮助读者理解前后端数据交互与后台管理逻辑。资源共941个文件,其中150个gif演示动图、149个class编译文件、135个js脚本、76个java源码、71个json配置、62个wxss样式及60个wxml页面,另有完整SQL数据库文件和jsp页面,压缩包整体约30.07MB,目录结构清晰,便于按模块学习。包内附带演示视频,可直接运行查看效果。目前已有2597人学习下载,适合需要快速搭建可用系统、参考功能设计或准备答辩演示的开发者。
1. 校园二手交易小程序:为什么它总被选作毕设,这套源码包里到底有什么
基于微信小程序的校园二手物品交易系统,几乎每年毕业设计都会有人做这个题目。原因很直白:前端有小程序、后端有接口、底层有数据库,一条"发布商品 → 浏览下单 → 订单状态变更"的链路能完整跑通,演示视频画得出来,答辩也有东西可讲。这套 zip 交付物按标题看包含三块:小程序前端源码、后端源码与数据库脚本、一份演示视频,恰好覆盖了毕设从开发到验收的所有环节。适合计算机、软件工程方向需要独立完成全栈项目的同学,也适合想用最短时间搭一个可演示原型的从业者。题目看着简单,但微信登录、图片上传、订单状态机这三个坎,几乎每个做这个方向的人都会踩一遍。
2. 技术选型与整体架构:原生小程序 + Spring Boot + MySQL 的骨架怎么搭
2.1 前端选型:原生小程序比 uniapp 更省心
小程序端最稳妥的选择是原生微信小程序,而不是 uniapp。很多同学看到 uniapp 能一套代码多端打包就动心,但到了答辩现场,uniapp 打包到微信小程序后经常出现导航栏样式错位、组件兼容性差异、开发者工具报错定位不准的问题。毕设场景追求的是"能稳定演示、能讲清楚原理",原生小程序的开发调试链路最短:写一个 WXML,立刻在开发者工具里看到效果,出了问题能直接定位到具体页面。uniapp 微信小程序打包的配置项(manifest.json、小程序插件配置)反而会分散你的精力。
原生小程序的边界也很清晰:页面就是一个目录下的四个文件(js、json、wxml、wxss),数据绑定用 setData,页面跳转用 navigateTo,请求用 wx.request。这些概念在答辩时三句话能讲完,评阅老师不需要额外学习成本。
2.2 后端选型:Spring Boot + MyBatis Plus 是答辩安全牌
后端我一般推荐 Spring Boot 2.x + MyBatis Plus + MySQL。选 Spring Boot 不是因为性能,而是因为它是答辩老师最熟悉的组合,论文里的技术选型章节好写,出了问题也容易在网络上找到同类解决方案。Node.js 的 Express 写起来更轻,但毕设论文里"框架选型理由"这部分会比较单薄,而且 Java 的强类型对订单状态这类强业务逻辑更友好。
MyBatis Plus 的好处是单表增删改查几乎不用写 SQL,内置的 LambdaQueryWrapper 可以直接这样查在售商品:
List<Goods> goodsList = goodsMapper.selectList( new LambdaQueryWrapper<Goods>() .eq(Goods::getStatus, 0) .orderByDesc(Goods::getCreateTime) );这段代码的逻辑是:查询 status 等于 0(在售)的商品,按创建时间倒序排列。如果你的项目里商品表叫 t_goods、状态字段叫 status,这套写法直接能跑。参数说明:eq 是 MyBatis Plus 的等值条件,orderByDesc 是倒序排序,这两个方法配合起来就是首页商品列表的核心查询。
2.3 建库与字符集:utf8mb4 从第一步就定死
数据库层面最值得交代的是字符集。校园二手交易系统里商品描述、留言评论都会出现中文,甚至有人会用 emoji 表情描述"九成新📱",如果建库时用了 utf8 而不是 utf8mb4,emoji 会直接存不进去,报错 Incorrect string value。MySQL 的 utf8 是 utf8mb3 的别名,只支持基本多语言平面,utf8mb4 才能覆盖全部 Unicode。用 Navicat 或命令行建库时,字符集务必选 utf8mb4,排序规则选 utf8mb4_general_ci:
CREATE DATABASE IF NOT EXISTS campus_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE campus_trade;字符集要同时管三层:数据库、连接串、表字段。Spring Boot 的 application.yml 里,JDBC 连接串必须带上 characterEncoding=utf8,否则即使数据库是 utf8mb4,连接层也可能因为默认字符集不一致导致乱码:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword这里 useUnicode=true 是启用 Unicode 映射,characterEncoding=utf8 指定字符编码,serverTimezone=Asia/Shanghai 是为了避免 MySQL 8.x 的时区报错。这三项是 MySQL 8 连接配置的标配,缺一个都会在运行时出现奇怪的中文或时间问题。
2.4 整体目录结构与请求链路
一个约定俗成的工程结构应该是前后端分离:小程序端只负责展示和交互,后端只提供接口,数据库只存数据。小程序内部按页面拆目录,后端按 controller、service、mapper 分层。
campus-trade-miniapp/ # 小程序前端 ├── pages/ │ ├── index/ # 首页:商品列表 │ ├── publish/ # 发布页:表单+图片上传 │ ├── detail/ # 详情页:商品信息+留言 │ ├── order/ # 订单页:我买到的/我卖出的 │ └── mine/ # 我的:登录信息、我的发布 ├── utils/ │ └── request.js # wx.request 封装 └── app.js campus-trade-server/ # 后端服务 ├── src/main/java/ │ ├── controller/ # 接口层 │ ├── service/ # 业务层 │ ├── mapper/ # 数据访问层 │ └── entity/ # 实体类 └── src/main/resources/ └── application.yml # 数据源与端口配置请求链路是:小程序页面 → wx.request → 后端 Controller → Service → Mapper → MySQL,返回结果再原路回传。这条链路在答辩时画一张时序图就够用了,不需要引入消息队列或缓存中间件,毕设阶段加了那些东西反而讲不清楚。
3. 数据库设计:用户、商品、订单三张核心表,字段与状态机一次定对
3.1 用户表 t_user:openid 是唯一身份
校园二手交易系统的用户表不需要设计得太复杂,核心字段围绕微信身份展开。openid 是用户在某个小程序下的唯一标识,同一个微信号在不同小程序下 openid 不同,所以 openid 完全可以作为业务主键来用。session_key 是微信会话密钥,用来解密用户信息,不该返回给前端,只存后端。手机号字段在 2023 年后微信调整了获取规则,个人主体小程序拿不到完整手机号,所以这张表里要有一个 phone 字段给用户手动填写,还要有一个可空标记。
CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', openid VARCHAR(64) NOT NULL UNIQUE COMMENT '微信openid', session_key VARCHAR(64) DEFAULT NULL COMMENT '会话密钥,仅后端使用', nickname VARCHAR(32) DEFAULT '' COMMENT '昵称', avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像地址', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话,用户可手动填写', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';注意两个细节:openid 加了 UNIQUE 约束,防止同一个微信号注册出多个账号;session_key 刻意没有加 NOT NULL,因为不是每次登录都会刷新它。ON UPDATE CURRENT_TIMESTAMP 能让更新记录时自动刷新 update_time,减少业务代码里的手动赋值。
3.2 商品表 t_goods:状态字段就是商品的生命周期
商品表是整个系统的核心,字段设计决定了后面所有页面的复杂度。除了标题、描述、价格这些常规字段,我特别建议专门设计一个 status 字段来表达商品状态:0 在售、1 已售出、2 已下架。不要用 delete_flag 去删商品,二手交易场景里"下架"和"删除"是两件不同的事,下架的商品在卖家的"我的发布"里还能看到,删除才是真没了。
图片字段也是一个容易踩坑的地方。很多人第一版把图片存成逗号分隔的字符串,比如 "url1,url2,url3",确实能跑,但图片回显时要 split 字符串。更稳的做法是单独建一张商品图片表,不过我见过的大多数毕设方案都喜欢把图片路径拼成一个字段,理由是简单。折中方案是用 JSON 字符串,小程序端 JSON.parse 后直接得到数组,比逗号分隔更可读。
CREATE TABLE t_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID', user_id BIGINT NOT NULL COMMENT '发布者ID,关联t_user', title VARCHAR(50) NOT NULL COMMENT '商品标题', description VARCHAR(500) DEFAULT '' COMMENT '商品描述', price DECIMAL(10,2) NOT NULL COMMENT '售价', original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价,可空', degree TINYINT DEFAULT 5 COMMENT '成色:5全新/4九成新/3八成新/2七成新及以下', images VARCHAR(1000) DEFAULT '' COMMENT '图片URL,JSON数组字符串', category VARCHAR(20) DEFAULT '其他' COMMENT '分类:教材/数码/生活/运动/其他', status TINYINT DEFAULT 0 COMMENT '状态:0在售/1已售/2下架', browse_count INT DEFAULT 0 COMMENT '浏览次数,可做排序', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';这里 KEY idx_user 和 KEY idx_status 是两个普通索引:idx_user 加速"查询某个人发布的全部商品",idx_status 加速"查询在售商品列表"。如果商品量大,这两个索引的收益很明显;毕设数据量下不加也行,但加了答辩时能多说一句"我考虑了索引优化"。
3.3 订单表 t_order:状态流转靠数字不靠文字
订单表是第二个容易设计错的地方。最常见的问题是只存一个 status 字段,但不知道这个字段到底能取哪些值、谁能改它。我习惯把订单状态定成四个数字:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号,时间戳+随机数', goods_id BIGINT NOT NULL COMMENT '商品ID', goods_title VARCHAR(50) DEFAULT '' COMMENT '商品标题快照,防止商品被删后订单无信息', goods_image VARCHAR(255) DEFAULT '' COMMENT '商品图片快照', buyer_id BIGINT NOT NULL COMMENT '买家ID', seller_id BIGINT NOT NULL COMMENT '卖家ID', price DECIMAL(10,2) NOT NULL COMMENT '成交价格', status TINYINT DEFAULT 0 COMMENT '状态:0待付款/1待领取/2已完成/3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', pay_time DATETIME DEFAULT NULL COMMENT '付款时间', finish_time DATETIME DEFAULT NULL COMMENT '完成时间', KEY idx_buyer (buyer_id), KEY idx_seller (seller_id), KEY idx_goods (goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';订单编号 order_no 我建议用时间戳加四位随机数生成,比如 new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) 拼上 Math.random() 四位,不需要引入雪花算法。goods_title 和 goods_image 属于冗余字段,是为了"快照":如果发布者删了商品,订单表里仍然能看到当时买的什么东西。这个设计在答辩时是加分项。
订单状态流转可以用下面这张表说明:
| 当前状态 | 可变更到的状态 | 触发动作 | 操作角色 |
|---|---|---|---|
| 0 待付款 | 1 待领取 / 3 已取消 | 买家付款 / 买家取消 | 买家 |
| 1 待领取 | 2 已完成 / 3 已取消 | 买家确认领取 / 卖家取消 | 买家或卖家 |
| 2 已完成 | 无 | 终态 | 无 |
| 3 已取消 | 无 | 终态 | 无 |
这张表的价值在于,它把业务规则从散落的代码里抽出来变成了可讨论的文档,写论文时可以直接截图进"系统设计"章节。
3.4 收藏与留言表:要不要做,做成什么样
除了三张核心表,收藏表和留言评论表是常见的加分项。收藏表只需要三个字段:user_id、goods_id、create_time,联合唯一索引防止重复收藏。留言表需要 goods_id、user_id、content、create_time,用来做商品详情页的评论列表。这两张表看毕设时间充裕度来加,如果工期紧,只做三张核心表也完全站得住。
4. 小程序端三个关键页面:登录、商品列表、发布
4.1 登录流程:wx.login 换 openid,手机号怎么拿
小程序端的登录和传统网页登录完全不同。网页登录是输入用户名密码,小程序是调用 wx.login 拿到一个临时 code,再把 code 发给后端,后端拿着 code 加上你的 appid 和 secret 去微信服务器换 openid 和 session_key。用户体验上,用户打开小程序就已经"登录"了,不需要输账号密码。具体代码:
// pages/mine/mine.js login() { wx.login({ success: (res) => { if (res.code) { wx.request({ url: 'http://localhost:8080/api/auth/login', method: 'POST', data: { code: res.code }, success: (resp) => { const { token, userInfo } = resp.data.data; wx.setStorageSync('token', token); wx.setStorageSync('userInfo', userInfo); } }); } } }); }这段代码的关键在于:wx.login 拿到的 code 是一次性的,有效期只有五分钟,而且只能用一次。后端拿 code 换完 openid 后,这个 code 就作废了,千万不要缓存它。token 是后端自己生成的一个字符串,小程序端拿到后存到本地存储里,后续所有请求都带上它作为身份凭证。
关于"获取手机号",现在微信的规定是必须使用 open-type="getPhoneNumber" 的 button 组件,而且要求小程序已完成微信认证(企业主体)。个人主体的小程序调用这个接口只会拿到一个加密串,解不出来完整手机号。毕设场景的通行做法是:登录只依赖 openid 识别身份,手机号在发布商品时让用户手动填,填完存到 t_user 表或商品表的联系方式字段里。
4.2 首页列表:导航栏高度适配与下拉刷新
首页列表页是所有小程序项目里必踩的一个坑:顶部导航栏高度。iPhone 的刘海屏、安卓的挖孔屏,系统状态栏高度都不一样,如果写死一个像素值,真机上一跑就发现标题栏和系统时间重叠了。正确的做法是动态计算导航栏高度,用 wx.getMenuButtonBoundingClientRect 拿到右上角胶囊按钮的位置,然后推算导航栏高度:
// utils/navbar.js function getNavBarHeight() { const menu = wx.getMenuButtonBoundingClientRect(); const system = wx.getSystemInfoSync(); return (menu.top - system.statusBarHeight) * 2 + menu.height; }这段代码的思路是:胶囊按钮的 top 减去状态栏高度,得到导航栏上下留白的空间,乘 2 是因为上下留白对称,再加上胶囊本身的高度,就是整个导航栏总高度。拿到这个高度后,页面里的自定义导航栏容器就动态设置 style="height: {{navBarHeight}}px",状态栏高度用 statusBarHeight 单独处理。
首页列表的数据请求建议用 onPullDownRefresh 配合 setData 实现下拉刷新,代码里要注意 wx.stopPullDownRefresh 必须在数据渲染完之后调用,否则刷新动画不会停:
// pages/index/index.js onPullDownRefresh() { this.loadGoods(); }, loadGoods() { wx.request({ url: 'http://localhost:8080/api/goods/list', data: { pageNum: 1, pageSize: 10 }, success: (res) => { this.setData({ goodsList: res.data.data.records }); wx.stopPullDownRefresh(); } }); }这里 data 里的 pageNum 和 pageSize 是后端分页接口的标准参数名,pageNum 从 1 开始,pageSize 是每页条数,一般设 10 或 20。注意开发者工具里可以直接请求 http://localhost,但真机上 localhost 指向手机自身,必须换成电脑的局域网 IP,这个坑后面避坑章节还会展开讲。
4.3 发布页:图片上传与表单提交
发布页是微信小程序表单交互最多的页面,包含商品标题、描述、价格、成色单选、分类选择和图片上传。成色选择用微信小程序单选框 radio-group,分类用 picker 就行。图片上传用 wx.chooseMedia 选图,再逐张用 wx.uploadFile 传到后端,拿回 URL 后和其他字段一起提交:
// pages/publish/publish.js async uploadImages() { const res = await wx.chooseMedia({ count: 3, // 最多3张 mediaType: ['image'], sizeType: ['compressed'] // 压缩图,减少体积 }); for (let i = 0; i < res.tempFiles.length; i++) { const file = res.tempFiles[i].tempFilePath; wx.uploadFile({ url: 'http://localhost:8080/api/upload', filePath: file, name: 'file', success: (resp) => { const url = JSON.parse(resp.data).data; this.setData({ images: [...this.data.images, url] }); } }); } }wx.chooseMedia 是官方推荐的新接口,替代了旧版 wx.chooseImage。sizeType 传 compressed 会拿到压缩图,对图片上传链路很重要:二手交易的商品图用原图会占到 5MB 以上,上传慢还容易被后端拒绝。count 限制 3 张是为了控制请求耗时和数据量,毕设演示时 3 张足够说明问题。
这段代码有个潜在的并发问题:for 循环里同时发多个 uploadFile,后端可能并发写入文件导致文件名冲突。所以后端的文件名必须用 UUID 生成,不能直接拿原始文件名。这个问题在第五章的后端实现里会重点处理。
5. 后端接口实现:统一返回体、token 校验与图片静态映射
5.1 统一返回体:code、message、data 三件套
后端接口的第一步是设计统一返回体。没有统一返回体的项目,有的接口返回 JSON 对象,有的返回数组,有的失败时只返回字符串,小程序端做通用请求封装时就会非常痛苦。统一返回体我一般设计成三个字段:
public class Result<T> { private Integer code; // 0成功,1业务失败,2未登录 private String message; // 提示信息 private T data; // 业务数据 public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 1; r.message = message; return r; } }注意 code 的语义约定:0 是成功,1 是业务失败,2 是未登录。这样小程序端的 request 封装只需要判断 code 是否为 0,非 0 就弹出 message 的文本,不用每个页面单独处理错误分支。data 用泛型 T,返回列表、对象、分页结果都能兼容。
5.2 商品发布接口:图片先传、字段后提交,两步解耦
商品发布的前端流程是图片先上传、表单后提交,所以后端要拆成两个接口:一个处理图片上传,一个处理商品入库。图片上传接口接收 MultipartFile,把文件写到本地磁盘的 upload 目录,文件名用 UUID:
@RestController public class UploadController { @PostMapping("/api/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException { String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; String savePath = "upload/" + newName; File dest = new File(savePath); file.transferTo(dest); return Result.ok("/upload/" + newName); } }这段代码的关键是 newName 的生成:用 UUID 去掉横线后拼上原文件的扩展名,彻底避免两个用户同时上传同文件名导致的覆盖问题。ext 从原始文件名里截取,substring(lastIndexOf(".")) 能拿到包括点号在内的扩展名,比如 ".jpg"。transferTo 是 Spring 提供的落盘方法,比手动创建 FileOutputStream 省事。
但是图片上传完还有一个隐藏问题:Spring Boot 默认的静态资源映射只覆盖 classpath 下的 static 目录,你写到项目根目录 upload 文件夹的文件,默认是访问不到的。必须配置资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:upload/"); } }addResourceHandler("/upload/**") 表示 URL 以 /upload/ 开头的请求都去磁盘上找文件,addResourceLocations("file:upload/") 指定磁盘根路径是项目运行目录下的 upload 文件夹。这两行配置不加,前端回显图片时 URL 写对了也是 404。
5.3 登录接口与 token 鉴权:拦截器只认 token 不认 openid
登录接口收到小程序传来的 code 后,调用微信的 jscode2session 接口换 openid。这个调用需要你的小程序 appid 和 secret,换回 openid 后查 t_user 表:存在就更新 session_key,不存在就插入新用户。然后生成一个 token 返回给前端:
public static final String APPID = "你的appid"; public static final String SECRET = "你的secret"; @PostMapping("/api/auth/login") public Result login(@RequestBody LoginRequest req) { String openid = getOpenidByCode(req.getCode()); if (openid == null) { return Result.error("微信登录失败"); } User user = userMapper.selectOne( new LambdaQueryWrapper<User>() .eq(User::getOpenid, openid)); if (user == null) { user = new User(); user.setOpenid(openid); userMapper.insert(user); } String token = UUID.randomUUID().toString(); user.setToken(token); userMapper.updateById(user); return Result.ok(new LoginResponse(token, user)); }token 生成后直接存到 t_user 表里,是一种牺牲一点安全性换取极简实现的方案。真要做严格鉴权,应该用 Redis 存 token 并设置过期时间,但毕设本地部署没有 Redis 也能讲得通。后面的拦截器只需要验证每个请求头里的 token 能否在 t_user 表里查到对应的用户:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } User user = userMapper.selectOne( new LambdaQueryWrapper<User>().eq(User::getToken, token)); if (user == null) { response.setStatus(401); return false; } request.setAttribute("userId", user.getId()); return true; } }拦截器里的 request.setAttribute 很关键:后续的 Controller 里直接从 request 上取 userId,而不是让前端传 userId 过来。这样做的原因是防止越权——如果接口信任前端传来的 userId,用户改一下请求参数就能操作别人的商品。
6. 避坑清单:真机联调、手机号、图片、乱码与审核
6.1 现象:开发者工具一切正常,真机预览请求超时
原因:开发者工具里 localhost 指向你的电脑,真机上 localhost 指向手机自己,当然连不上。而且小程序默认要求请求地址必须是 HTTPS 且在后台配置了合法域名。
解决:开发调试阶段,在开发者工具右上角"详情 → 本地设置"勾选"不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书"。后端地址从 localhost 改成电脑的局域网 IP,比如 http://192.168.1.100:8080。手机和电脑连同一个 WiFi,保证网络互通。如果换了 WiFi 或 IP 变了,后端和小程序里的地址要一起改。
6.2 现象:点击获取手机号按钮,返回 errCode 10001
原因:getPhoneNumber 接口现在要求小程序已通过微信认证,个人主体小程序没有认证权限,调用这个接口只会拿到 errCode 10001 之类的报错。这是 2023 年微信调整规则后的结果,不是你的代码问题。
解决:毕设场景放弃手机号自动获取。登录只用 wx.login 拿 openid,用户在发布商品时手动填手机号,存到 t_user 的 phone 字段或商品的联系方式字段。演示时提前在手机号输入框里打好一串数字,不要现场临场输入。
6.3 现象:图片上传成功返回 URL,列表页回显全部裂图
原因:多半不是上传失败,而是回显地址不对。如果上传返回的 URL 是 /upload/xxx.jpg 这种相对路径,小程序端直接把它当成图片地址请求,会拼到小程序的域名下去找文件,自然找不到。另外如果 Spring Boot 的静态资源映射没配置,即使地址正确也会 404。
解决:回显前判断 URL 开头,如果是相对路径就拼接后端的完整地址,比如 http://192.168.1.100:8080 + "/upload/xxx.jpg"。后端 addResourceHandlers 配置必须写上。还有一个容易被忽略的点:Spring Boot 默认 multipart 上传大小限制是 1MB,如果选图没走 compressed 压缩,一张手机照片就可能超限,接口直接报 500。在 application.yml 里调大:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MBmax-file-size 限制单个文件大小,max-request-size 限制单次请求总大小。一次上传三张图片时,max-request-size 必须大于三倍单文件限制,否则前端 for 循环里只上传了两张就会报错。
6.4 现象:数据库里中文全部变成问号
原因:三层字符集不一致。数据库建库时用了 utf8,或者连接串没带 characterEncoding=utf8,或者表字段本身是 latin1。
解决:建库统一 utf8mb4,连接串带 characterEncoding=utf8,Navicat 里检查表字段的字符集是否为 utf8mb4。最彻底的办法是把建库 SQL 删掉重新执行一遍,不要试图在已有的 latin1 表上 ALTER 修改,字段多的时候 ALTER 容易遗漏。改完后重启后端,再插入一条中文数据验证。
6.5 现象:小程序提审被拒,类目显示为"社交"或"电商"不符
原因:二手交易类小程序在微信的类目体系里属于电商平台类目,通常要求企业主体并提供相关资质。个人主体小程序提交这种内容,审核几乎必拒。
解决:毕设演示根本不需要提审上线。用开发者工具的"预览"功能生成二维码,用测试号或体验版在答辩现场演示。论文里写"系统已部署,可通过体验版体验",答辩老师不会要求你上架微信商店。如果确实需要真机演示,提前配置好合法域名并上传代码作为体验版,体验版成员可以直接访问。
7. 演示验收:一口气跑通发布到成交,答辩追问这么接
答辩前再做一遍完整的链路自测,比让老师听你讲任何功能描述都有效。我习惯用一个验收表来逐项过:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 用测试微信号打开小程序 | 自动登录,进入首页 |
| 2 | 发布一条"高数教材"商品 | 上传3张图,提交后首页能看到 |
| 3 | 用另一个微信号进入详情页 | 看到完整信息,点击"我想要" |
| 4 | 下单并模拟付款 | 卖家订单列表出现待领取订单 |
| 5 | 卖家确认完成 | 买家订单状态变为已完成 |
| 6 | 刷新首页 | 该商品状态从"在售"变为"已售" |
这个链条走完,你的系统就是一个完整闭环,而不是几个互不相干的页面。演示视频也照这个顺序录,手机录屏加开发者工具双窗口,重点录登录跳转、图片上传、订单状态三个瞬间。
答辩最常见的追问是"如何防止用户越权修改别人的商品"。你直接回答:后端接口不信任前端传来的 userId,所有需要身份的请求都从请求头的 token 解析出当前用户,更新商品时校验商品表的 user_id 是否等于当前登录用户,不等于就返回"无权操作"。这个回答能覆盖一大半追问。
还有一句提醒:我当年演示时把图片地址写死成了 localhost,答辩现场连上投影后图片全裂,只能尴尬地切回电脑浏览器展示。后来我把所有图片请求都改成了相对路径加统一的 baseUrl 拼接,并且把 baseUrl 抽到了全局配置文件里。这个习惯到现在都在用。希望帮到你,祝答辩顺利。
本文还有配套的精品资源,点击获取