
简介面向计算机相关专业毕设学生与项目实战学习者的微信小程序校园二手交易平台源码包含完整小程序前端、Java后端及SQL数据库文件。代码围绕校园二手商品发布、浏览与交易流程设计结构清晰、便于二次开发可直接作为毕业设计、课程设计或期末大作业使用。压缩包共258个文件大小约3.68MB涵盖Java源码、WXML/WXSS界面、Vue页面、JSON配置、XML资源、SQL脚本以及png/jpg图片素材等。各类型文件分别对应后端接口、页面展示、项目配置与数据初始化SQL脚本可快速导入数据库方便搭建运行环境。目前已有135人学习下载。该设计经导师指导并获得99分评价代码完整、确保可运行还包含常见配置与目录文件能帮助读者理解校园二手交易项目架构为毕业设计答辩或项目练习提供直接参考。1. 什么是微信小程序校园二手交易平台毕设里最容易被低估的题目每年毕业季选题表里都会出现“基于微信小程序的校园二手交易平台”这类题目乍一看遍地都是、没什么新意但真做起来才发现它恰好踩中了微信小程序开发里最典型的一条技术链路登录鉴权、列表分页、图片上传、订单状态流转还要配一个像样的数据库。校园场景的好处是不需要虚构业务——毕业生甩卖教材、宿舍囤的杂物、考研资料这些信息现在散落在班级群、朋友圈和公告栏里看的人少、消息一天就被刷没。做这套系统就是把“发布—浏览—下单—确认收货”搬到小程序里让每件商品有明确的在售与售出状态让每笔订单有迹可循。适合真心想把数据库设计和接口开发弄明白的同学不适合只想要个能交差demo的人。2. 数据库先行用户、商品、订单三张核心表怎么设计才扛得住答辩追问毕业设计的评审几乎必然围绕ER图和三张核心表展开数据库设计立不住后面接口写得再花哨也白搭。这一章按“实体划分—字段说明—状态机—连接参数”的顺序把建表语句直接给你字段为什么这么定、索引为什么这么建都写在注释和说明里方便答辩时直接引用。2.1 实体划分与字段取舍三张表就能撑起最小闭环整套系统最核心的实体是用户、商品、订单外加一个站内留言和收藏但毕设要控制工作量我一般只把前三个做成完整表留言和收藏放到“选做加分项”。用户和商品是一对多商品和订单是一对多——同一件商品可以被不同人下单只有在下单接口里把商品状态改成“已售出”之后才终止。用户表建表语句如下CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 用户ID主键, openid VARCHAR(64) NOT NULL COMMENT 微信openid登录唯一标识, nickname VARCHAR(32) NOT NULL DEFAULT COMMENT 微信昵称展示用, avatar_url VARCHAR(255) NOT NULL DEFAULT COMMENT 头像地址, student_no VARCHAR(20) NOT NULL DEFAULT COMMENT 学号可选填, college VARCHAR(32) NOT NULL DEFAULT COMMENT 学院筛选二手品类时有用, phone VARCHAR(20) NOT NULL DEFAULT COMMENT 联系电话交易联系用, credit_score TINYINT NOT NULL DEFAULT 100 COMMENT 信用分初始100扣分后限制交易, status TINYINT NOT NULL DEFAULT 1 COMMENT 账号状态1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这段建表逻辑里有几个答辩点要提前想清楚。openid虽然是微信侧唯一标识但不建议拿它当主键——主键用自增id可以缩短索引体积页面里关联用户时也更简洁openid保持唯一索引就够了。credit_score是很多同学会漏掉但很加分的字段它让“失信用户限制交易”有了落点。phone不设唯一索引因为学生填错或者换号很正常毕设阶段不用在这种地方卡数据。商品表的设计比用户表更讲究因为它直接决定首页列表、搜索、个人中心“我发布的”这三个查询好不好写CREATE TABLE goods ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 商品ID, seller_id INT UNSIGNED NOT NULL COMMENT 卖家ID关联user.id, title VARCHAR(60) NOT NULL COMMENT 标题列表直接展示, description TEXT COMMENT 详细描述详情页展示, cover_url VARCHAR(255) NOT NULL COMMENT 封面图URL列表用, images VARCHAR(1024) NOT NULL DEFAULT COMMENT 多图URL数组JSON字符串, price DECIMAL(10,2) NOT NULL COMMENT 价格单位元, category TINYINT NOT NULL DEFAULT 0 COMMENT 分类0教材 1数码 2生活 3其他, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0审核中 1在售 2已售出 3下架, view_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 浏览量排序可用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller_status (seller_id, status), KEY idx_category_status (category, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;images用 JSON 字符串而不是单独建一张商品图片表是刻意控制表数量前端JSON.parse就能拿到图片列表。但要提前想好答辩话术如果商品详情要做多图轮播JSON 字段够用如果后台要做图片维度统计就必须拆表。价格用DECIMAL(10,2)而不是FLOAT是因为浮点型算总价会产生 0.10.20.30000000000000004 这类误差做订单金额快照时会非常难看。两个复合索引分别服务“我发布的商品”和“分类浏览商品”这两个最常出现的查询单字段索引在这两个场景下都会有一边查不到。订单表是整套系统的状态中枢字段设计直接影响事务能不能写清楚CREATE TABLE orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号展示给用户不用自增id, goods_id INT UNSIGNED NOT NULL COMMENT 商品ID, buyer_id INT UNSIGNED NOT NULL COMMENT 买家ID, seller_id INT UNSIGNED NOT NULL COMMENT 卖家ID冗余存储避免每次join用户表, price DECIMAL(10,2) NOT NULL COMMENT 成交价格快照下单那一刻的商品价格, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待付款 1待确认 2已完成 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL COMMENT 付款时间, finish_time DATETIME DEFAULT NULL COMMENT 确认收货时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer_status (buyer_id, status), KEY idx_seller_status (seller_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;订单表里故意冗余了seller_id是因为买家在“我买到的”列表里要同时显示卖家昵称卖家在“我卖出的”列表里要显示买家如果订单表只存goods_id这两个页面都要 join 两张表。毕设阶段多做一步冗余换来的却是接口代码简单一截。price字段一定存的是下单那一刻的快照不是读商品表当前价格——卖家可能随时改价订单和账单必须对得上这也是答辩必问点。2.2 状态机设计用 TINYINT 枚举把业务流程锁死二手交易最容易翻车的地方是状态管理。很多同学喜欢用字符串存状态比如statussold写起来爽但接口改一处漏一处数据库里什么脏数据都能出现。正确做法是给商品和订单分别定义状态机用 TINYINT 加注释把可选值钉死。商品状态只有四个0 审核中、1 在售、2 已售出、3 下架。创建商品时默认 0管理员审核通过后变 1用户下单成功后变 2卖家主动下架变 3。订单状态是五个0 待付款、1 待确认、2 已完成、3 已取消。这里多一个“待确认”是因为校园二手交易普遍线下面对面交付买家不付钱、卖家不发货线上系统只需要记录“买家已下单等待双方线下碰头”等买家点了确认收货再标记完成整个闭环就严谨了。在代码里不要直接写魔法数字统一用枚举或者常量类比如 Java 里的GoodsStatusEnum.ON_SALE.getCode()。数据库注释、后端枚举、前端switch三处必须保持一致我见过最痛的翻车就是后端把 2 定义为已售出前端把 2 定义为下架列表页商品状态错乱了两个星期才查出来。2.3 连接串与字符集utf8mb4 和时区是绕不开的两个参数数据库建好了连接串配不对照样出幺蛾子。最常见的两个坑一是中文乱码二是时间差了 8 小时。JDBC 连接串推荐这样写jdbc:mysql://localhost:3306/campus_secondhand?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueuseUnicodetruecharacterEncodingUTF-8保证中文正常写库但要注意 MySQL 的utf8其实不是真正的全量 UTF-8它只到三字节存不了 emoji。学生卖东西的商品描述里经常带表情符号建库建表时一定要统一用utf8mb4也就是我在建表语句里写的字符集。serverTimezoneAsia/Shanghai是解决“数据库时间和本地时间差 8 小时”的标准做法不写的话老版本驱动会拿服务器默认时区订单创建时间全偏。useSSLfalse是本地开发关掉 SSL 握手减少报错上线走内网或者正规云数据库时再按需打开。3. 后端接口与数据库落库从登录鉴权到订单流转的完整链路数据库立住之后第三步是把接口按“登录—发布—下单”这条主线走出来。后端框架选用上Spring Boot 是毕设最常见的选择理由很实际资料多、评审认可度高、报错信息好搜。这一章代码按 Spring Boot MyBatis 的常规搭配写接口路径设计成/api/...方便小程序端直接对接。3.1 登录接口用 code 换 openid 再换 token别把敏感信息发给前端微信登录和其他登录最大的区别是前端拿不到用户密码只能通过wx.login拿到一个临时code由后端拿着这个code去微信接口换openid和session_key。核心接口如下RestController RequestMapping(/api/auth) public class AuthController { Resource private UserService userService; Resource private TokenService tokenService; Resource private WxService wxService; PostMapping(/login) public Result login(RequestBody LoginRequest req) { // 1. 用 code 向微信接口换 openidcode 5 分钟内有效且只能使用一次 String openid wxService.code2Session(req.getCode()); if (openid null || openid.isEmpty()) { return Result.fail(登录凭证已失效请重试); } // 2. 查用户表第一次登录自动注册 User user userService.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(微信用户 openid.substring(openid.length() - 4)); userService.register(user); } // 3. 签发自定义 token后续请求都带这个 token不再依赖 code String token tokenService.createToken(user.getId()); return Result.ok().put(token, token).put(userId, user.getId()); } }这段代码的关键在于openid永远只停留在后端不能直接返回给前端当令牌。有人图省事把openid当 token 返回等于把自己的账号标识交出去任何人拿到都能伪造登录。用token的好处是可以控制有效期比如设置 7 天过期过期后前端自动跳登录页重新wx.login用户无感知续期。参数方面要留意两点code换openid的接口需要配置小程序的appid和secret这两个值放在后端配置文件里绝不能写进小程序前端代码token推荐用 UUID 或 JWTJWT 的密钥要单独放配置中心硬编码在 Java 类里一旦代码外传就等于整套登录系统白给。3.2 发布商品多图上传和字段校验的接口写法发布商品的接口要同时处理文本字段和文件上传小程序端用wx.uploadFile走multipart/form-data后端接口接收MultipartFile数组。代码骨架如下PostMapping(/api/goods) public Result publish(RequestParam(value files, required false) MultipartFile[] files, RequestParam(title) String title, RequestParam(value description, required false) String description, RequestParam(price) BigDecimal price, RequestParam(category) Integer category, RequestHeader(token) String token) { // 1. 校验登录态 Integer userId tokenService.parse(token); if (userId null) { return Result.fail(401, 登录已过期请重新登录); } // 2. 字段合法性校验双保险小程序端校验只影响体验后端校验才决定数据质量 if (title null || title.length() 60 || price null || price.compareTo(BigDecimal.ZERO) 0) { return Result.fail(标题或价格不合法); } // 3. 保存图片到本地磁盘文件名用 UUID 防止重名返回可访问的 URL 列表 ListString imageUrls new ArrayList(); if (files ! null) { for (MultipartFile file : files) { imageUrls.add(fileService.saveImage(file, userId)); } } // 4. 落库 Goods goods goodsService.publish(userId, title, description, price, category, imageUrls); return Result.ok().put(goodsId, goods.getId()); }文件保存这块我一般这样落地后端在项目根目录建一个upload/文件夹按日期分子目录文件名用UUID.randomUUID()拼接原始扩展名避免两个学生上传同名photo.jpg互相覆盖。保存后返回给前端的 URL 必须是完整可访问路径比如http://192.168.1.100:8080/upload/2026/05/abc.jpg而不是相对路径否则小程序列表页还得自己拼域名拼错一个端口就白屏。校验逻辑这里特别强调一点小程序前端能做的校验非常有限任何人只要会抓包就能绕过前端直接调接口。所以price必须用BigDecimal接收数据库里也是DECIMAL(10,2)两层都是定点数才能在源头掐掉浮点误差。title长度限制在 60 也是和数据库的VARCHAR(60)对齐的接口层和存储层不一致是毕设代码被扣分的重灾区。3.3 下单与事务商品状态和订单状态必须一起变下单是整个系统里最容易出脏数据的地方因为一次操作要同时改两张表插入一条订单记录把商品状态从“在售”改成“已售出”。很多同学分两步写先 insert 订单再 update 商品结果 update 失败订单有了商品却还在卖第二个买家又下了一单两笔订单抢同一件货。解决办法就是用数据库事务包住这两个操作并且加行锁防止并发Transactional(rollbackFor Exception.class) public Order createOrder(Long goodsId, Long buyerId) { // 1. 查商品并加行级锁同一时间只有一个事务能拿到这条记录 Goods goods goodsMapper.selectByIdForUpdate(goodsId); if (goods null || !GoodsStatusEnum.ON_SALE.getCode().equals(goods.getStatus())) { throw new BizException(商品不存在或已下架); } if (goods.getSellerId().equals(buyerId)) { throw new BizException(不能购买自己发布的商品); } // 2. 创建订单价格从商品表快照过来 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setGoodsId(goods.getId()); order.setBuyerId(buyerId); order.setSellerId(goods.getSellerId()); order.setPrice(goods.getPrice()); order.setStatus(OrderStatusEnum.WAIT_PAY.getCode()); orderMapper.insert(order); // 3. 商品状态改为已售出和订单插入在同一个事务里 goodsMapper.updateStatusById(goods.getId(), GoodsStatusEnum.SOLD.getCode()); return order; }selectByIdForUpdate是标准悲观锁写法在 MySQL InnoDB 下会对这一行加锁第二个用户同时下单时会阻塞等待等第一个事务提交后拿到最新状态发现商品已经售出直接抛出业务异常。这样“两人同时抢最后一件商品”就只剩一个赢家数据不会乱。事务下的三步操作可能中途异常比如订单号重复、插入失败这时Transactional会整体回滚商品状态恢复在售数据库不会留下“商品售出但订单不存在”的孤儿数据。这里还有个隐藏坑generateOrderNo()如果直接用时间戳加随机数并发高时很容易撞车我习惯用“日期 用户ID后四位 四位随机数”拼一个 20 位订单号并且给order_no建唯一索引撞了就重试一次。4. 微信小程序前端首页渲染、发布上传和请求封装的完整代码后端接口就位后小程序端才能真正跑起来。这一章给出可以直接抄的页面配置、请求封装和列表分页代码全部按原生小程序语法写不依赖第三方 UI 框架跑通后再换 uni-app 或者 TDesign 都容易。4.1 tabBar 与页面结构三个 tab 比五个更符合二手交易场景小程序第一步是配好app.json。很多人喜欢把首页、发布、消息、我的、订单做成五个 tab实际在手机上五个字会挤得很小而且校园二手交易的高频操作是“逛”和“发”订单入口放在“我的”页面里一点不影响使用。我的推荐配置{ pages: [ pages/index/index, pages/goods/detail, pages/publish/publish, pages/orders/orders, pages/profile/profile ], window: { navigationBarTitleText: 校园二手交易, navigationBarBackgroundColor: #07c160, navigationBarTextStyle: white }, tabBar: { color: #999999, selectedColor: #07c160, list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/publish/publish, text: 发布 }, { pagePath: pages/profile/profile, text: 我的 } ] }, lazyCodeLoading: requiredComponents }页面路径里的pages/orders/orders没有放进 tabBar它作为“我的”页面的子页面通过wx.navigateTo跳转这样既保留订单列表页又不挤占底部导航空间。lazyCodeLoading是提升启动速度的开关按需注入组件项目小的时候体会不明显但答辩时说出来是加分项。4.2 首页商品列表分页加载和防重复请求的写法首页是用户第一眼看到的东西分页逻辑写不好会出现两个经典问题下拉加载时数据重复、快速滑动时同一页请求发两次。下面是经过反复验证的写法Page({ data: { goodsList: [], page: 1, pageSize: 10, finished: false, loading: false }, onLoad() { this.fetchGoods(true); }, onReachBottom() { // 到底部时如果上一请求还没返回直接忽略防止 page 错乱 if (this.data.finished || this.data.loading) { return; } this.fetchGoods(false); }, fetchGoods(reset) { const page reset ? 1 : this.data.page 1; this.setData({ loading: true }); wx.request({ url: ${app.globalData.baseURL}/api/goods, data: { page, pageSize: this.data.pageSize }, success: (res) { const rows res.data.data.rows || []; this.setData({ goodsList: reset ? rows : this.data.goodsList.concat(rows), page: page, finished: rows.length this.data.pageSize, loading: false }); }, fail: () { this.setData({ loading: false }); wx.showToast({ title: 网络异常请重试, icon: none }); } }); } });reset参数控制是刷新还是加载更多下拉刷新时传true强制回到第一页。loading锁是这套代码的灵魂onReachBottom的触发频率远高于接口返回速度不加锁的话用户快速滑动一次能发出三个请求page 参数互相打架列表翻几页就开始重复。finished的判断用的是“返回条数小于 pageSize”而不是“等于 0”因为正好凑够一页时最后一页可能返回 10 条满数据再用等于 0 判断就会多翻一次多发出一次无效请求。页面 JSON 里还要开下拉刷新{ enablePullDownRefresh: true, backgroundTextStyle: dark }然后在 Page 里补一个onPullDownRefreshonPullDownRefresh() { this.fetchGoods(true); wx.stopPullDownRefresh(); }stopPullDownRefresh要放在请求回调里而不是外面否则下拉动画还没走完就被强制关闭体验很生硬。4.3 登录与请求封装把 token 注入写成一劳永逸的工具小程序里每一个接口都要带登录态如果每个页面的wx.request都手动加 header代码量翻倍还容易漏。我习惯在最开始就封装一个request.js把 token 读取、状态码拦截、错误提示全部统一处理const request (url, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${app.globalData.baseURL}${url}, method, data, header: { Content-Type: application/json, token: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 401) { // token 过期清除本地缓存并跳转登录 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(res); return; } if (res.data.code ! 0) { wx.showToast({ title: res.data.msg, icon: none }); reject(res); return; } resolve(res.data.data); }, fail: (err) { wx.showToast({ title: 网络请求失败, icon: none }); reject(err); } }); }); }; module.exports { request };这里把 HTTP 状态码和业务状态码分开处理HTTP 401 统一跳登录业务code ! 0统一弹 toast。这样小程序端的每个页面只需要关心接口返回的业务数据不用每个页面重复写错误处理。baseURL放在app.globalData里开发时用局域网 IP比如http://192.168.1.100:8080真机调试验证通过后再统一替换成 https 域名比全局搜索替换省心得多。4.4 发布页从选图到上传的完整流程发布页的图片上传是另一个高频出问题的地方因为wx.uploadFile和wx.request是两套 APIheader 格式也不一样。封装一个上传函数const uploadImage (filePath) { return new Promise((resolve, reject) { wx.uploadFile({ url: ${app.globalData.baseURL}/api/goods, filePath, name: files, header: { token: wx.getStorageSync(token) || }, success: (res) { const data JSON.parse(res.data); if (data.code 0) { resolve(data.data.url); } else { wx.showToast({ title: data.msg, icon: none }); reject(res); } }, fail: reject }); }); };选图用wx.chooseMedia支持一次选多张并压缩wx.chooseMedia({ count: 6, mediaType: [image], sizeType: [compressed], sourceType: [album, camera], success: async (res) { const uploadTasks res.tempFiles.map((item) uploadImage(item.tempFilePath)); const urls await Promise.all(uploadTasks); this.setData({ imageUrls: urls }); } });sizeType: [compressed]一定要带否则原图上传会非常慢校园网环境下教师机和小程序真机的传输速度都会让人崩溃。Promise.all 并发上传可以接受毕设阶段的图片量不会触发服务器压力问题。5. 联调避坑微信登录态、图片上传和订单并发的高频翻车点这一章是血泪经验集合每一条都是我在实际项目中踩过、也帮别人排查过的真问题。按“现象→原因→解决”写在下面联调时照方抓药能省下大量排查时间。5.1 现象开发者工具里一切正常真机预览就白屏或者请求失败原因微信开发者工具的“详情-本地设置”里默认勾选了“不校验合法域名”这个选项只对工具生效真机上小程序会强制校验 request 的域名必须是 https 且在小程序后台配置过合法域名。开发时如果用 http 加局域网 IP真机必然失败。解决开发阶段在微信开发者工具右上角“详情-本地设置-不校验合法域名”保持勾选真机预览时手机和电脑连同一个 Wi-Fi请求地址里的localhost替换成电脑的局域网 IP命令行ipconfig查看。要真正在真机上测试可以开启“真机调试”模式这个模式下域名校验依然存在所以更通用的做法是把后端接口暂时部署到一台支持 https 的测试服务器上或者用微信云开发的云函数转发请求。5.2 现象wx.login拿到的 code 偶尔报“invalid code”登录失败原因微信的code是一次性的而且有效期只有 5 分钟前端如果同时发起多个请求每个请求里都调一次wx.login第一次调用把 code 用掉了后面几次再拿去换 openid 自然失败。解决把登录动作做成全局单例登录 Promise 只创建一次所有接口共享同一个 token 获取链路。参考下面这段let loginPromise null; const ensureLogin () { if (!loginPromise) { loginPromise new Promise((resolve) { wx.login({ success: (res) { // 调用后端 /api/auth/login把 code 换成 token request(/api/auth/login, POST, { code: res.code }) .then((data) { wx.setStorageSync(token, data.token); resolve(data.token); }); } }); }); } return loginPromise; };在小程序app.js的onLaunch里调用一次ensureLogin()之后的页面请求直接读取缓存 token。如果 token 过期401 拦截里清缓存后再次调用ensureLogin()整个登录链路就闭环了。5.3 现象图片上传接口返回成功列表页图片却显示不出来原因最常见的两种一是后端返回的是相对路径如/upload/xxx.jpg小程序端直接渲染成了www.example.com/upload/xxx.jpg二是后端返回绝对路径但端口不对比如后端实际跑在 8080前端拼 URL 用的却是 80 端口。第二种问题在本地开发时天天出现。解决后端保存图片后返回完整 URL拼接规则统一放在配置类里比如baseUrl从配置文件读取不要在代码里写死。前端列表页渲染时加一层兜底const formatImageUrl (url) { if (!url) return ; if (url.startsWith(http)) return url; return ${app.globalData.baseURL}${url}; };凡是经过这个函数处理的图片地址无论后端返回相对路径还是绝对路径都能正确显示。排查时先看接口返回的原始 JSON再在浏览器里直接访问这个 URL能访问说明路径没问题问题一定出在前端拼接逻辑上。5.4 现象同一件商品被两个用户同时下单成功数据库出现两笔待付款订单原因下单接口用了“先查后改”的流程查的时候商品状态是在售两个请求并发先后读到相同状态各自插入订单成功然后各自把商品状态改成已售出动画上看确实只有一件商品数据库里却躺着两笔订单。解决两个办法二选一推荐都用。第一是在 SQL 层面做原子更新下单前先执行一条带条件的 UPDATEUPDATE goods SET status 2 WHERE id #{goodsId} AND status 1;如果返回受影响行数为 0说明商品已经不是“在售”状态直接抛异常“手慢了商品已被别人买走”。第二条是在事务里加SELECT ... FOR UPDATE行锁我在 3.3 节写的代码就是完整示例。这两者叠加并发问题在理论和实践层面都堵死了答辩时这段代码可以直接作为“如何解决并发”的论据。5.5 现象写进数据库的中文和 emoji 变乱码问号或者方块原因字符集不统一。最常见的是数据库表建成了utf8而utf8mb3存不了 emoji表情直接变成问号。其次是 JDBC 连接串没指定characterEncodingUTF-8驱动用默认字符集写库。解决建库时执行一遍ALTER DATABASE campus_secondhand CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后对每张表执行ALTER TABLE goods CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;同时确认连接串里带了useUnicodetruecharacterEncodingUTF-8。有个容易忽略的细节MyBatis 配置文件里的url如果写在 XML 中字符要用amp;转义否则启动直接报错。6. 答辩前怎么验证用一张检查表和两个账号把系统跑成“能演示的样子”毕业设计答辩最怕的是现场演示翻车但翻车往往不是功能没写而是没在真机上完整走一遍主流程。我建议在答辩前三天按下面这张表把系统从头到尾验收一次每项都打勾之后再考虑做加分功能。验证项操作步骤预期结果登录注册用买家账号首次登录用户表自动插入新记录token 写入缓存发布商品用卖家账号发布一件带图商品商品出现在首页列表状态为在售商品详情点击首页商品卡片详情页展示多图、价格、卖家信息下单用买家账号点击立即购买订单表新增记录商品状态变为已售出重复下单再用另一个账号购买同一件商品提示“商品已下架或已售出”订单列表分别在两个账号查看我的订单卖家看到卖出订单买家看到买入订单确认收货买家点击确认收货订单状态变为已完成商品不再出现在首页下架商品卖家在“我发布的”里下架商品商品状态变为下架首页不可见验证时准备两个微信号一个小程序在开发工具里跑一个在真机上跑才能同时测到并发下单这个关键场景。测试数据要像真实数据价格写 25.5、39.9 这种有零有整的别全是 1 元 2 元评审看到“一本高数教材 25.5 元”会觉得你试过真实场景。6.1 答辩时主动讲三件事比“功能全做完了”更有说服力第一讲数据库设计时把订单表冗余seller_id的理由说出来——减少 join、提升查询速度。第二讲并发时把UPDATE ... WHERE status1和事务回滚的例子抛出来说明你考虑过两单抢一件商品的情况。第三讲 token 登录机制时说明为什么不能直接拿 openid 当身份标识这里能体现出对安全的意识。我当年毕业设计最后悔的就是功能写完没做并发测试导师现场问“两个人同时下单怎么办”我只能尴尬地翻代码找 bug。现在每次带新人做这类项目我都逼他们把并发验证放最前面一个 UPDATE 语句能解决的问题别等到答辩才暴露。希望这份从数据库到接口再到小程序端的完整路线能帮到你照着建表、照着写接口、照着排错这套项目做到能演示、能答辩、能理直气壮说“这是我独立完成”的程度是完全来得及的。本文还有配套的精品资源点击获取