简介一份基于Spring Boot与微信小程序的旧衣回收系统设计与实现毕业论文docx文档面向计算机相关专业毕业生、小程序开发者及环保信息化项目学习者完整展示从需求分析到系统实现的毕业设计全过程。压缩包共1个文件为docx格式大小3.64MB内容包含摘要、目录、第1章概述、关键技术介绍Java、微信开发者工具、MySQL、Spring Boot、B/S架构、系统分析、需求分析、可行性分析、系统流程分析及后续设计章节。已有170人学习浏览。论文详细梳理了系统首页、个人中心、用户管理、回收人员管理、旧衣信息管理、回收预约与派单、回收订单、积分商品与兑换、管理员管理等模块并结合前后端分离架构给出实现方案可帮助读者快速理解旧衣回收业务流程与小程序开发要点。读者可参考其章节组织、模块划分与文献综述写法用于自身毕业设计或相关项目开发。1. 旧衣回收系统不只是一道毕设题springboot 与微信小程序组合的落地逻辑拿到“springboot基于微信小程序的旧衣回收系统”这个题目时很多同学的第一反应是照着商城 demo 改用户登录、下单、支付、订单列表。等真正把需求写成用例才发现旧衣回收的流程比商城多出一个“线下服务”环节预约上门时间、回收员取件、称重定价、积分入账。用户端是微信小程序服务端是 SpringBoot 后台订单状态还随时会被小程序、回收员和管理员推进。这套系统的编码量不大难点全在状态机设计、微信登录鉴权和数据一致性上。这篇笔记按一条能毕业也能落地的路线讲清楚数据模型、SpringBoot 接口、小程序请求封装和真机部署时的坑适合 SpringBoot 学完基础但没完整做过项目的人。2. 先把旧衣回收流程拆成表结构订单状态机与六张核心表的建模我一般不会先写代码而是先把业务边界用状态机和表结构锁死。旧衣回收和普通商城的最大区别是用户下单后没有支付环节取而代之的是“回收员上门-称重-结算积分”的线下动作订单状态流转的先后顺序就是系统的一致性核心。2.1 旧衣回收的状态机比电商订单多出的真实现场如果只给订单表放一个status字段而不定义流转规则后面做接单、结算、取消时必然改一处崩一处。我给这个系统定义了 6 个状态状态值状态名说明0待接单用户已预约等待回收员认领1待上门回收员已接单待按预约时间上门2回收中衣物已取走待称重3已完成称重结束积分已入账4已取消用户主动取消或系统超时取消5售后中用户对重量或积分提出异议状态流转只允许0→1→2→30→40→5 需特殊审批。这里有个很容易被忽略的点用户主动取消只在“待接单”阶段允许已经接单后再取消要走“售后中”因为线下回收员的行程已经排了。这个规则会直接影响后面的接口设计和论文里的用例划分。2.2 六张核心表把回收订单、积分流水和地址分开建表结构我建议拆成六张用户表、地址表、回收订单表、订单明细表、回收品类表、积分流水表。用户表用role字段区分普通用户、回收员和管理员省去冗余的回收员表订单明细单独拆出来是因为一次预约可能同时回收多类衣物而且每类衣物的单公斤积分不一样。-- 用户表同时承担普通用户、回收员、管理员三种角色 CREATE TABLE sys_user ( id bigint PRIMARY KEY AUTO_INCREMENT, openid varchar(64) NOT NULL COMMENT 微信小程序 openid, nickname varchar(64) NOT NULL DEFAULT , phone varchar(20) DEFAULT NULL, role tinyint NOT NULL DEFAULT 0 COMMENT 0-用户 1-回收员 2-管理员, points int NOT NULL DEFAULT 0 COMMENT 当前累计积分, created_at datetime NOT NULL, updated_at datetime NOT NULL, UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 地址表 CREATE TABLE address ( id bigint PRIMARY KEY AUTO_INCREMENT, user_id bigint NOT NULL, contact_name varchar(32) NOT NULL, contact_phone varchar(20) NOT NULL, province varchar(32) NOT NULL, city varchar(32) NOT NULL, district varchar(32) NOT NULL, detail varchar(128) NOT NULL, is_default tinyint NOT NULL DEFAULT 0 COMMENT 1-默认地址, created_at datetime NOT NULL, KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT回收地址表; -- 回收订单主表 CREATE TABLE recycle_order ( id bigint PRIMARY KEY AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint NOT NULL, address_id bigint NOT NULL, recycler_id bigint DEFAULT NULL COMMENT 回收员 id接单后写入, appoint_start_time datetime NOT NULL COMMENT 预约开始时间, appoint_end_time datetime NOT NULL COMMENT 预约结束时间, status tinyint NOT NULL DEFAULT 0 COMMENT 见状态机, total_weight decimal(10,2) NOT NULL DEFAULT 0 COMMENT 称重后的总重量 kg, total_points int NOT NULL DEFAULT 0 COMMENT 本次回收获得积分, remark varchar(255) DEFAULT NULL, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at datetime NOT NULL, updated_at datetime NOT NULL, KEY idx_user_id (user_id), KEY idx_status (status), KEY idx_recycler_id (recycler_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT回收订单表; -- 订单明细表一次回收可包含多类衣物 CREATE TABLE recycle_item ( id bigint PRIMARY KEY AUTO_INCREMENT, order_id bigint NOT NULL, category_id bigint NOT NULL, estimated_weight decimal(10,2) NOT NULL DEFAULT 0 COMMENT 用户填写预估值, image_url varchar(255) DEFAULT NULL COMMENT 用户上传的旧衣照片, note varchar(255) DEFAULT NULL, KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表; -- 回收品类表 CREATE TABLE recycle_category ( id bigint PRIMARY KEY AUTO_INCREMENT, name varchar(32) NOT NULL COMMENT 上衣/裤子/床单/其他, unit_points decimal(10,2) NOT NULL COMMENT 每公斤可兑换积分, sort tinyint NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT回收品类表; -- 积分流水表所有积分变动都落在这里不能直接改用户表的 points CREATE TABLE points_log ( id bigint PRIMARY KEY AUTO_INCREMENT, user_id bigint NOT NULL, order_id bigint DEFAULT NULL, change_type tinyint NOT NULL COMMENT 1-回收奖励 2-积分兑换 3-售后扣回, change_points int NOT NULL COMMENT 正数增加负数扣除, balance_after int NOT NULL COMMENT 变动后的用户总积分, description varchar(128) DEFAULT NULL, created_at datetime NOT NULL, KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表;这里有几个参数和设计值得展开。sys_user表没有给role加唯一约束回收员和管理员是运营侧手工创建的openid加唯一索引是因为微信登录会反复触发没有唯一索引可能插出重复账号。订单表里的version字段是给乐观锁用的后面接单和结算都会依赖它。recycle_order不直接保存衣物照片而是放在recycle_item否则一个订单多件衣服时照片字段会变成冗余。积分流水表记录balance_after这是对账的依据我在这上面吃过亏只记变化值、不记余额活动运营查数据时完全像个黑匣子只能翻日志。表之间不要建物理外键在代码层校验归属更省心。物理外键在毕设系统里除了让 ER 图更漂亮实际运行中经常因为删除顺序影响批量操作所以我的做法是只在建表语句里保留索引外键关系画在论文的 ER 图里。2.3 选型为什么这个毕设场景我用 MyBatis-Plus 而不是 JPA后端持久层我选 MyBatis-Plus不是因为 JPA 不好而是旧衣回收系统的订单查询条件太动态。用户按状态查订单、管理员按日期和回收员查订单、积分流水按类型查这些组合条件用 JPA 方法名会很啰嗦条件构造器直接甩一个 LambdaQueryWrapper 最直观。维度MyBatis-PlusJPA/Hibernate多条件查询LambdaQueryWrapper 拼接SQL 可控方法名或 Query复杂条件维护成本高分页PaginationInnerInterceptor物理分页Pageable还要处理对象映射状态更新UpdateWrapper 指定字段不影响其他列save 会把整个实体覆盖容易带出脏数据答辩解释可以直接贴 SQL 讲业务抽象太多老师一问 N1 就紧张文档量国内资料多遇到问题容易搜到官方文档偏向英文细节多另外要注意 SpringBoot 版本别追新。现在 stable 的 2.7.x 配合 JDK8 最稳3.x 需要 JDK17 起底很多毕设机器上的 MySQL 驱动和工具链都不一定跟得上。MyBatis-Plus 的MybatisPlusInterceptor注册代码也短Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段配置给 MyBatis-Plus 开启内置分页。PaginationInnerInterceptor在分页查询时会自动改写 SQL把LIMIT参数接到语句末尾。需要注意它有多个数据库方言这里明确指定DbType.MYSQL如果你部署到 PostgreSQL 而忘了改方言分页 SQL 会报错。很多教程只贴这段不解释方言参数我见过不少照抄后分页翻车的。3. 把微信登录和 API 打通SpringBoot 后端从零搭建到接口交付数据库建好后下一步不是马上写业务接口而是先把 SpringBoot 项目骨架搭稳再把微信登录这条链打通。登录连不通后面所有接口都只能靠 Postman 浅测。3.1 用 IDEA 创建 SpringBoot 项目pom 依赖与 springboot 配置我一般会在 IDEA 里用 Spring Initializr 创建项目Group 填com.exampleArtifact 填old-clothes-system语言选 Java打包方式 jar。依赖先别勾全后面按需加到 pom 里更清晰。这里是我常用的依赖组合parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.19.2/version /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里用 SpringBoot 2.7.18 是为了压住 JDK8 的底线如果你为了“新版”去用 3.xLombok 版本、MyBatis-Plus 适配、小程序的基础库兼容都要重新调一遍属于给自己加戏。Hutool 是后端调用微信接口用的HttpUtil.get比手写RestTemplate省心很多。java-jwt 用来签发用户令牌毕设系统不需要引入 Spring Security一个拦截器校验 Authorization 头就够了。application.yml的核心配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/old_clothes?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 10MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted wechat: appid: wx1234567890abcdef secret: abc123def456ghi789jkl012mnop345qr grant-type: authorization_code jwt: secret: your-own-random-secret-string expire-days: 7配置里三个参数要重点说明。characterEncodingutf8是因为 MySQL 的 utf8mb4 在 JDBC 连接串里写成utf8即可写成utf8mb4反而可能被驱动忽略。max-file-size和max-request-size都写成 10MB是为了避免上传旧衣照片时被 Spring Boot 默认 1MB 限制拦截。logic-delete-field是 MyBatis-Plus 的全局逻辑删除配置如果表里有deleted字段更新时自动带deleted0条件我这里表没用逻辑删除字段只留一个配置项方便扩展。注意wechat.secret一定只放在后端配置文件里绝不能出现在小程序代码。微信登录的 appsecret 一旦泄露任何人都可以冒充你的小程序换取用户身份。3.2 微信小程序登录接口wx.login 换 openid 再签发 JWT微信小程序登录的标准链路是小程序端wx.login拿临时 code传给 SpringBoot后端拿 code 加上 appid、secret 去微信接口换 openid 和 session_key后端用 openid 查用户不存在就自动注册最后签一个 JWT 返回给小程序。这个 JWT 就是后续所有请求的身份凭证。先看小程序端代码// pages/login/login.js const app getApp(); function wxLogin() { return new Promise((resolve, reject) { wx.login({ success: (res) { if (!res.code) { return reject(new Error(wx.login 拿不到 code)); } wx.request({ url: app.globalData.baseUrl /api/user/login, method: POST, data: { code: res.code, nickname: 旧衣用户 }, success: (loginRes) { if (loginRes.statusCode 200 loginRes.data.code 200) { wx.setStorageSync(token, loginRes.data.data.token); wx.setStorageSync(userInfo, loginRes.data.data.userInfo); resolve(loginRes.data.data); } else { reject(new Error(loginRes.data.message)); } }, fail: reject }); }, fail: reject }); }); } module.exports { wxLogin };注意这里的res.code是一次性的每次调用wx.login都会生成新 code旧 code 即使没过期也会作废。所以不要在小程序的onLaunch和页面onLoad里同时调两次wxLogin否则后拿到的 code 大概率会让后端报 “invalid code”。后端登录接口核心逻辑Service public class UserServiceImpl implements UserService { Autowired private SysUserMapper userMapper; Value(${wechat.appid}) private String appid; Value(${wechat.secret}) private String secret; Value(${jwt.secret}) private String jwtSecret; Value(${jwt.expire-days}) private int expireDays; Override Transactional(rollbackFor Exception.class) public LoginVO login(String code, String nickname) { // 1. 用 code 换 openid请求微信官方接口 String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code code grant_typeauthorization_code; JSONObject session HttpUtil.get(url, JSONObject.class); String openid session.getStr(openid); if (StrUtil.isBlank(openid)) { throw new BusinessException(微信登录失败 session.getStr(errmsg)); } // 2. 按 openid 查用户不存在则自动注册 SysUser user userMapper.selectOne(Wrappers.SysUserlambdaQuery() .eq(SysUser::getOpenid, openid)); if (user null) { user new SysUser(); user.setOpenid(openid); user.setNickname(StrUtil.isBlank(nickname) ? 微信用户 : nickname); user.setRole(0); user.setPoints(0); userMapper.insert(user); } // 3. 签发 JWT过期时间与 jwt.expire-days 对齐 long expireMillis expireDays * 24 * 60 * 60 * 1000L; String token JWT.create() .withClaim(userId, user.getId()) .withClaim(role, user.getRole()) .withExpiresAt(new Date(System.currentTimeMillis() expireMillis)) .sign(Algorithm.HMAC256(jwtSecret)); LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserInfo(user); return vo; } }这里最容易被忽略的是session.getStr(errmsg)的判断。微信接口在 code 失效时不会返回 openid只会返回errcode和errmsg如果后端不判断直接拿 openid 查库用户会遇到“登录成功但账号为空”的怪问题。JWT 里我只放了 userId 和 role不放 openid避免 token 体积变大。拦截器解析 token 后把 userId 放进ThreadLocal业务接口从上下文拿当前用户这样接口签名里不用每个方法都传 userId。3.3 小程序请求封装统一带 token、统一报错、设置缓存时间登录拿到 token 后小程序端不能每个页面试着自己拼wx.request否则后端接口一旦调整 baseUrl 或错误码整个项目都要改。我在小程序utils/request.js里做了一层统一封装// utils/request.js const app getApp(); function request(path, method GET, data {}) { return new Promise((resolve, reject) { const token wx.getStorageSync(token); wx.request({ url: app.globalData.baseUrl path, method: method, data: data, timeout: 10000, header: { Content-Type: application/json, Authorization: token ? Bearer token : }, success: (res) { if (res.statusCode 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(new Error(登录已过期)); return; } if (res.statusCode 200 res.data.code 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message || 请求失败, icon: none }); reject(new Error(res.data.message)); } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } module.exports { request };这里有几个参数要遵守。timeout: 10000设成 10 秒是因为回收员在室内上门时手机信号差太短容易误报超时Authorization头用Bearer前缀后端拦截器按这个约定解析 token。401 状态码由后端过滤器统一抛出前端只要遇到 401 就清掉本地 token 并跳到登录页这样 app 内所有接口的过期逻辑都是一致的。同时wx.setStorageSync是永久存储不能直接当“缓存时间”用。如果只希望用户信息保存 1 天、token 保存 7 天我习惯自己包一层// utils/cache.js function setCache(key, value, expireSeconds) { const record { value, expireAt: Date.now() expireSeconds * 1000 }; wx.setStorageSync(key, record); } function getCache(key) { const record wx.getStorageSync(key); if (!record) return null; if (Date.now() record.expireAt) { wx.removeStorageSync(key); return null; } return record.value; } module.exports { setCache, getCache };setCache 的第 3 个参数是过期秒数不传或传 null 就代表永久保存。这里有一个经验小程序端缓存过期时间要和后端 JWT 的expire-days对齐不能前端设置了 7 天缓存后端 JWT 只有 1 天那样用户看起来登录着真正下单时反而被 401 弹出。如果你用 uniapp 做这个项目API 名字会从wx.*换成uni.*但缓存过期思路完全一样。4. 预约回收与订单流转状态更新、消息通知、积分结算的实现微信登录打通之后就可以安心写业务了。旧衣回收的业务核心是“订单状态流转”和“积分结算”两个闭环任何状态改动都不能绕过状态机。4.1 创建预约单把小程序表单映射成回收订单用户在小程序里填写地址、选旧衣品类、上传照片、预约上门时间后端要做的不只是 insert而是把多个表单数据放进同一个事务。Service public class RecycleOrderServiceImpl implements RecycleOrderService { Autowired private RecycleOrderMapper recycleOrderMapper; Autowired private RecycleItemMapper recycleItemMapper; Autowired private AddressMapper addressMapper; Override Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateDTO dto) { // 1. 地址必须属于当前用户 Address address addressMapper.selectById(dto.getAddressId()); if (address null || !address.getUserId().equals(dto.getUserId())) { throw new BusinessException(地址不存在或不属于当前用户); } // 2. 写订单主表 RecycleOrder order new RecycleOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setAddressId(dto.getAddressId()); order.setAppointStartTime(dto.getAppointStartTime()); order.setAppointEndTime(dto.getAppointEndTime()); order.setStatus(0); order.setVersion(0); recycleOrderMapper.insert(order); // 3. 写订单明细支持一次预约多种旧衣 for (OrderItemDTO item : dto.getItems()) { RecycleItem recycleItem new RecycleItem(); recycleItem.setOrderId(order.getId()); recycleItem.setCategoryId(item.getCategoryId()); recycleItem.setEstimatedWeight(item.getEstimatedWeight()); recycleItem.setImageUrl(item.getImageUrl()); recycleItemMapper.insert(recycleItem); } } private String generateOrderNo() { return HC DateUtil.format(new Date(), yyyyMMddHHmmss) RandomUtil.randomNumbers(6); } }Transactional(rollbackFor Exception.class)保证主表和明细表同时写入明细表失败时主表也会回滚。这里有一个细节异常类型如果写成RuntimeException而业务里抛的是自定义BusinessException最好让这个异常继承RuntimeException否则事务不会回滚。地址归属的判断是必须的否则用户传一个别人的地址 id 就能在别人家下单。订单号我用“HC 时间戳 6 位随机数”看起来像HC20250412153012345678在论文里解释订单号规则也算一个亮点。4.2 回收员接单用状态条件更新代替“先查再改”接单接口是并发问题最容易爆发的点。多个回收员同时点“接单”如果后端先select判断status0再update status1中间这一步存在时间差两个人可能都看到 0都更新成功订单就被重复接走。正确做法是让数据库来仲裁把状态当条件写进 update 语句影响行数为 0 就说明订单已经被别人接走。public void claimOrder(ClaimOrderDTO dto) { boolean claimed recycleOrderService.update(new LambdaUpdateWrapperRecycleOrder() .eq(RecycleOrder::getId, dto.getOrderId()) .eq(RecycleOrder::getStatus, 0) .set(RecycleOrder::getStatus, 1) .set(RecycleOrder::getRecyclerId, dto.getRecyclerId()) .set(RecycleOrder::getUpdatedAt, new Date())); if (!claimed) { throw new BusinessException(订单已被其他回收员接走); } }注意这里的状态值从 0 变成 1对应状态机里的“待接单 → 待上门”。MyBatis-Plus 的update方法返回布尔值底层就是看数据库受影响行数是否为 1。使用LambdaUpdateWrapper时eq条件会拼到 where 后面set的内容拼到 set 后面最终生成的 SQL 等价于UPDATE recycle_order SET status1, recycler_idxxx WHERE id? AND status0。只要两个回收员同时请求数据库的行锁会让第二个请求等到第一个事务提交后再执行此时 status 已经变成 1第二个请求的影响行数为 0就抢不到了。如果订单表已经用了Version乐观锁可以让版本号条件替代 status 条件两种机制效果类似。我这里两者都保留状态条件表达业务规则version 兜底防止其他维度的并发修改互相覆盖。4.3 完成回收与积分结算一个事务解决称重、状态和积分入账回收员上门收走旧衣后回到后台输入称重重量系统按品类单价计算积分并把订单状态推到“已完成”。这一步最容易出问题的是回收员重复点击“完成回收”导致积分入账两次。Transactional(rollbackFor Exception.class) public void finishRecycle(FinishRecycleDTO dto) { // 1. 状态条件更新只有“回收中(2)”才能变成“已完成(3)” boolean finished recycleOrderService.update(new LambdaUpdateWrapperRecycleOrder() .eq(RecycleOrder::getId, dto.getOrderId()) .eq(RecycleOrder::getStatus, 2) .set(RecycleOrder::getStatus, 3) .set(RecycleOrder::getTotalWeight, dto.getTotalWeight()) .set(RecycleOrder::getUpdatedAt, new Date())); if (!finished) { throw new BusinessException(订单当前状态不能完成回收); } // 2. 按订单明细计算总积分 int totalPoints calculatePoints(dto.getOrderId()); // 3. 更新订单积分写用户积分流水 recycleOrderService.update(new LambdaUpdateWrapperRecycleOrder() .eq(RecycleOrder::getId, dto.getOrderId()) .set(RecycleOrder::getTotalPoints, totalPoints)); userService.addPoints(dto.getUserId(), totalPoints, dto.getOrderId()); }这里的calculatePoints会去 recycle_item 表查出每个明细的 category_id再去 recycle_category 表拿 unit_points最后乘以明细重量求和。注意步骤 1 已经通过状态条件把订单卡死所以即使同一个回收员快速点了两次提交第二次执行时 status 已经不是 2事务直接报错回滚积分流水不会重复插入。userService.addPoints内部会更新sys_user.points并插入一条points_log流水表和用户表必须在同一个事务里否则可能出现积分加了但流水没了。定时取消订单的代码也不复杂Component public class OrderTimeoutTask { Autowired private RecycleOrderService recycleOrderService; Scheduled(cron 0 */5 * * * ?) public void cancelTimeoutOrder() { Date now new Date(); ListRecycleOrder timeoutOrders recycleOrderService.list( new LambdaQueryWrapperRecycleOrder() .eq(RecycleOrder::getStatus, 0) .lt(RecycleOrder::getAppointStartTime, now)); for (RecycleOrder order : timeoutOrders) { boolean cancelled recycleOrderService.update(new LambdaUpdateWrapperRecycleOrder() .eq(RecycleOrder::getId, order.getId()) .eq(RecycleOrder::getStatus, 0) .set(RecycleOrder::getStatus, 4) .set(RecycleOrder::getUpdatedAt, now)); if (cancelled) { // 记录调度日志便于论文里展示定时任务监控 log.info(订单 {} 超时未接单系统自动取消, order.getOrderNo()); } } } }cron 0 */5 * * * ?表示每 5 分钟执行一次具体拆开是秒位 0分位*/5表示从 0 分开始每隔 5 分钟时、日、月为*任意星期位?表示不指定。如果你希望只在每天 8:00-22:00 之间运行可以写成0 0/5 8-22 * * ?。定时任务方法里同样用状态条件更新因为用户可能刚点取消同一秒定时任务也扫到了这条订单状态条件保证只有真正处于“待接单”的订单才会被取消。5. 微信小程序旧衣回收系统避坑真机调试、并发与部署的 5 个翻车点做这类系统时尝到过不少教训下面这些坑我基本都在不同项目里踩过。每条按“现象→原因→解决”给出便于你排查。5.1 登录与请求的坑code 二次使用、数据库字符集、合法域名现象小程序每次冷启动都调wx.login偶尔报invalid code用户必须杀掉小程序重进才正常。原因wx.login返回的 code 一次性有效后端换完 openid 后 code 立即失效。小程序端在onLaunch和首页的onLoad里同时发起登录两次并发请求会拿到两个 code后一个请求覆盖前一个后端处理完第一个后第二个的 code 已经过了有效期。还有一些项目在 token 已经存在时仍无条件wx.login把本来能用的会话也刷新掉了。解决登录请求做单例复用token 存在且未过期就不重新登录必须登录时用一个共享 Promise 包住wx.login和wx.request并发调用只发一次。后端对code失效返回明确的invalid code前端收到后清 token 再走一次登录。另外登录成功后把 userInfo 和 token 用 3.3 节的 setCache 写进本地设置合理的缓存时间避免每次启动都重新登录。现象真机上填写“北京-朝阳区-XX小区”提交后数据库里变成“”。原因MySQL 建库时用了默认的utf8实际上 MySQL 的utf8最多存 3 字节遇到生僻字和 emoji 直接截断或者 JDBC 连接串characterEncoding没配对。解决建库用DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci表结构统一utf8mb4JDBC 连接串保持characterEncodingutf8。注意小程序端wx.request的 header 要写成Content-Type: application/json; charsetUTF-8否则特殊字符到后端会变形。如果已经乱码用ALTER TABLE address CONVERT TO CHARACTER SET utf8mb4;修复现有表。现象开发工具勾选“不校验合法域名”时接口正常手机预览显示request:fail url not in domain list。原因微信小程序真机要求所有wx.request的域名必须在小程序公众平台配置过而且必须是 HTTPS证书链完整。本地调试用 IP 或 http 协议一到真机就被拦。解决在小程序后台的“开发管理-服务器域名-request 合法域名”里加入后端 HTTPS 域名并配置好 SSL 证书。如果只是毕设演示没有现成域名可以用云服务器的公网 IP 加备案域名但正确路径就是配置合法域名不要想着在真机上绕过校验。5.2 订单并发与部署的坑重复接单、上传大小、线上时区现象演示时两个回收员账号同时抢同一单数据库订单状态被改成“待上门”但回收员 id 一会儿是 A 一会儿是 B订单列表显示错乱。原因后端先selectById查状态判断 status0 后再 update两个事务并发时都读到了 0然后各自更新成功后更新的覆盖先更新的。解决用状态条件更新取代“先查后改”。UPDATE recycle_order SET status1, recycler_id? WHERE id? AND status0影响行数为 1 才代表抢单成功。这个思路和乐观锁本质一样也是论文里可以写的并发控制方案。实际排查时先看日志里有没有两条“接单成功”记录再用数据库的binlog或 general log 确认 update 的先后顺序。现象上传旧衣照片时本地正常部署到云服务器后超过 1MB 的图片报413 Request Entity Too Large或The temporary upload location is not valid。原因Spring Boot 默认spring.servlet.multipart.max-file-size只有 1MBNginx 默认client_max_body_size也只有 1MB另外 SpringBoot 内嵌 Tomcat 上传临时目录默认用/tmp云服务器/tmp被系统清理后上传直接失败。这类问题表现最常见排查最费时间偶尔会让人以为是自己代码的玄学问题。解决application.yml 里把max-file-size和max-request-size调到 10MBNginx 的 http 或 server 块里加client_max_body_size 10m;再给 SpringBoot 设置一个业务目录作为 Tomcat 临时目录server.tomcat.basedir/home/app/tmp。如果你用 Docker 部署 springboot 项目记得容器内时区设置为Asia/Shanghai否则订单表里自动生成的时间全是 UTC用户看到的预约时间会有 8 小时偏差。做法是在 docker-compose 环境变量里加TZAsia/Shanghai或启动参数挂载/etc/localtime。6. 论文落地准备把演示数据和验证脚本做成答辩论据系统能跑只是第一步毕业论文答辩时老师更关心你有没有考虑过状态流转、并发和测试。我每做完一个系统都会留半天时间准备“演示脚本”在表里灌一组典型数据比如一个已取消订单、一个待接单订单、一个已完成订单然后用 Postman 或 Swagger 把每个接口按顺序跑一遍把返回 JSON 截图放进论文附录。演示顺序建议按照业务闭环走演示顺序操作预期结果1微信扫码登录后端返回 tokensys_user 表新增或复用记录2创建预约单recycle_order 状态为 0明细表出现衣物记录3回收员接单状态变为 1recycler_id 写入4完成回收称重状态变为 3points_log 新增一条流水5查看用户积分sys_user.points 累加balance_after 和流水一致每一步都截图尤其要把接单接口的请求参数和响应里的status变化截在一起这样老师能直观看到状态机是怎么转的。答辩时不要只讲 CRUD要主动说出你做的两个关键设计状态条件更新防止重复接单积分流水记录余额防止对账黑匣子。如果时间充裕再写一个针对状态流转的单元测试用 Spring Boot Test 拉起 JUnit 用例断言“订单从待接单不能直接跳到已完成”。这个测试代码量不大但比口头解释“我有并发控制”更有说服力。有一次我演示前没检查数据库结果在“接单”环节因为订单状态不是 0直接翻车现场气氛很冷。从那以后我养成一个习惯任何现场演示前先把数据库里订单状态人工复位成初始状态再跑一遍从前到后的完整链路并且把这次演示的 SQL 和请求日志存成文本跟着论文一起打包。这样就算现场网络出问题老师也能看到完整的日志链路。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取
