
简介这是一套面向计算机专业本科生的微信小程序毕业设计实战项目聚焦悬赏信息发布场景兼顾课程设计与期末大作业需求适合Java后端与小程序前端初学者快速上手。资源包含完整可运行系统基于SSM或SpringBoot构建的Java后台含UserController、RewardController、AdminController等10余个核心业务控制器、微信小程序前端wxml/wxss/js文件齐全、MySQL 5.7兼容数据库脚本sql文件、以及Navicat可视化配置说明和Tomcat部署指南。压缩包共320个文件涵盖60个jar依赖库、42个图片资源、32个JS逻辑文件、23个Java源码与23个class编译文件、21个XML配置及19个JSON接口定义总大小44.85MB结构清晰、注释充分关键工具类如DateUtil、ExportExcel、UploadController等均已封装。目前已有45人学习下载提供从环境搭建、数据库初始化、前后端联调到功能验证的一站式实践路径是少有的前后端代码齐备、调试通过、开箱即用的高完成度毕设方案。1. 微信小程序悬赏信息发布系统为什么 Java 后端 小程序前端的组合在中小团队落地最稳这不是一个“炫技型”项目——没有用 Spring Cloud 做微服务没上 Kubernetes 编排也没硬塞 Redis 集群或 Elasticsearch 全文检索。它就是一个真实可上线、能跑通「用户发悬赏→审核员初审→接单者抢单→完成交付→评价结算」全链路的轻量级系统核心逻辑全部由 JavaSpring Boot 2.7.x实现数据库用 MySQL 8.0微信小程序端用原生 WXML/WXSS/JS 开发不依赖任何第三方低代码平台。很多刚带学生做毕设的导师、接外包的小工作室、甚至某高校实验室的课程设计组都选它当基线项目因为部署简单单台 2C4G 云服务器就能扛住日活 500、代码结构清晰Controller-Service-Mapper 三层分明、数据库字段命名直白如task_status TINYINT COMMENT 0待审核 1已发布 2已接单 3已完成 4已关闭最关键的是——所有接口都按微信小程序登录态code2Session 自定义登录态 token做了严格校验不是裸奔 HTTP 接口。如果你正卡在「小程序怎么连 Java 后端」「怎么让管理员和普通用户看到不同数据」「MySQL 表之间怎么设外键又不拖慢查询」这些具体问题上这篇就是为你写的实操笔记。2. 搭建 Java 后端从初始化 Spring Boot 到跑通第一个悬赏列表接口2.1 初始化工程与关键依赖选型为什么只加这 5 个 starter我们不用spring-boot-starter-webflux响应式开发对本项目是过度设计也不引入mybatis-plus的全自动 CRUD它生成的 SQL 在多表关联时容易失控。实际采用的是MyBatis 原生 PageHelper 分页插件 HikariCP 连接池的组合。pom.xml中必须保留的核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.6/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency /dependencies提示pagehelper-spring-boot-starter版本必须与 MyBatis-Spring-Boot-Starter 对齐。1.4.6 是目前兼容 Spring Boot 2.7.x 最稳定的版本若升级到 2.3.x 以上PageHelper 会因拦截器注册顺序错乱导致分页失效——这是血泪经验。2.2 数据库初始化三张核心表的设计逻辑与建表语句系统靠三张主表驱动user_info用户基础信息、task_info悬赏任务主表、task_order接单记录表。它们不是随意堆字段而是按业务约束反向推导出来的user_info中user_type TINYINT DEFAULT 0 COMMENT 0普通用户 1审核员 2管理员—— 类型字段直接决定小程序端菜单栏显示哪些入口task_info中publish_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP和expire_time DATETIME NOT NULL构成任务生命周期后端定时任务每 5 分钟扫描一次expire_time NOW() AND status 1的任务并自动关闭task_order中order_status TINYINT DEFAULT 0 COMMENT 0待接单 1进行中 2已完成 3已取消与task_info.status联动更新避免状态不一致。建表语句含索引如下注意KEY idx_task_status_expire (status, expire_time)是高频查询的联合索引CREATE TABLE user_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, open_id VARCHAR(64) NOT NULL COMMENT 微信 openid, nick_name VARCHAR(32) NOT NULL, avatar_url VARCHAR(255), user_type TINYINT DEFAULT 0 COMMENT 0普通用户 1审核员 2管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (open_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE task_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT, reward_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, publish_user_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待审核 1已发布 2已接单 3已完成 4已关闭, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_publish_user (publish_user_id), KEY idx_status_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE task_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, accept_user_id BIGINT NOT NULL, order_status TINYINT DEFAULT 0 COMMENT 0待接单 1进行中 2已完成 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_task_user (task_id, accept_user_id), KEY idx_task_status (task_id, order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 实现「获取待发布悬赏列表」接口Controller → Service → Mapper 全链路小程序首页需要拉取status 1已发布且未过期的任务按publish_time DESC排序支持分页。这个接口看似简单但藏着三个易错点分页参数校验、时间范围过滤、关联用户昵称头像。我们一步步写Controller 层接收参数并转发RestController RequestMapping(/api/task) public class TaskController { Autowired private TaskService taskService; GetMapping(/list) public ResultPageResultTaskVO listTasks( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { // 防止恶意传超大页码 if (pageNum 1 || pageSize 1 || pageSize 50) { return Result.fail(参数错误); } PageResultTaskVO result taskService.listPublishedTasks(pageNum, pageSize); return Result.success(result); } }Service 层业务逻辑封装Service public class TaskService { Autowired private TaskMapper taskMapper; Autowired private UserInfoMapper userInfoMapper; public PageResultTaskVO listPublishedTasks(Integer pageNum, Integer pageSize) { // PageHelper 会自动将后续的 select 查询包装成分页 SQL PageHelper.startPage(pageNum, pageSize); ListTaskVO taskVOS taskMapper.selectPublishedTasks(); // 手动补充发布者昵称和头像避免 N1 查询 MapLong, UserInfo userMap new HashMap(); ListLong userIds taskVOS.stream() .map(TaskVO::getPublishUserId) .distinct() .collect(Collectors.toList()); if (!userIds.isEmpty()) { ListUserInfo users userInfoMapper.selectByIds(userIds); userMap users.stream().collect(Collectors.toMap(UserInfo::getId, u - u)); } for (TaskVO vo : taskVOS) { UserInfo user userMap.get(vo.getPublishUserId()); if (user ! null) { vo.setPublisherNickName(user.getNickName()); vo.setPublisherAvatar(user.getAvatarUrl()); } } PageInfoTaskVO pageInfo new PageInfo(taskVOS); return PageResult.TaskVObuilder() .list(taskVOS) .total(pageInfo.getTotal()) .pageNum(pageInfo.getPageNum()) .pageSize(pageInfo.getPageSize()) .build(); } }Mapper XML精准控制 SQL拒绝全自动!-- TaskMapper.xml -- select idselectPublishedTasks resultTypecom.example.vo.TaskVO SELECT t.id, t.title, t.content, t.reward_amount, t.publish_user_id, t.publish_time, t.expire_time, u.nick_name AS publisher_nick_name, u.avatar_url AS publisher_avatar FROM task_info t LEFT JOIN user_info u ON t.publish_user_id u.id WHERE t.status 1 AND t.expire_time NOW() ORDER BY t.publish_time DESC /select参数说明PageHelper.startPage(pageNum, pageSize)必须在执行selectPublishedTasks()之前调用且该方法不能有Select注解否则 PageHelper 拦截器无法生效LEFT JOIN是为了即使用户被删除任务列表仍能展示昵称为空字符串t.expire_time NOW()是硬性过滤条件防止前端传错时间导致查出过期任务。3. 小程序端对接登录态透传、请求拦截与数据渲染闭环3.1 微信登录态如何安全传递给 Java 后端小程序不能直接把wx.login()获取的code发给后端去调用微信接口存在中间人窃取风险。正确做法是小程序调用自己的后端/api/auth/login接口携带code后端用该code向微信服务器换取openid和session_key再生成一个服务端签发的短时效 token如 JWT有效期 2 小时返回给小程序存储于wx.setStorageSync(token, token)。后续所有请求都在 header 中带上Authorization: Bearer xxx。Java 端登录接口实现要点PostMapping(/login) public ResultMapString, String login(RequestBody LoginRequest request) { // 1. 校验 code 非空 if (StringUtils.isBlank(request.getCode())) { return Result.fail(code 不能为空); } // 2. 调用微信接口换取 openid String url https://api.weixin.qq.com/sns/jscode2session? appid wechatConfig.getAppId() secret wechatConfig.getSecret() js_code request.getCode() grant_typeauthorization_code; String response HttpUtil.get(url); // 使用 Apache HttpClient 或 OkHttp JSONObject json JSON.parseObject(response); String openid json.getString(openid); if (StringUtils.isBlank(openid)) { return Result.fail(登录失败 json.getString(errmsg)); } // 3. 查询或创建用户 UserInfo user userInfoMapper.selectByOpenId(openid); if (user null) { user new UserInfo(); user.setOpenId(openid); user.setNickName(request.getNickName()); user.setAvatarUrl(request.getAvatarUrl()); user.setCreateTime(new Date()); userInfoMapper.insert(user); } // 4. 生成 JWT token使用 jjwt String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(openid, openid) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000)) // 2小时 .signWith(SignatureAlgorithm.HS256, your-secret-key-here) .compact(); MapString, String data new HashMap(); data.put(token, token); data.put(userId, String.valueOf(user.getId())); return Result.success(data); }注意your-secret-key-here必须替换成你自己的密钥且不能硬编码在代码里应从配置中心或环境变量读取LoginRequest需包含nickName和avatarUrl从小程序wx.getUserProfile获取需用户授权。3.2 小程序请求拦截器自动注入 token 与统一错误处理在app.js中全局配置wx.request拦截器避免每个页面重复写 header// app.js App({ onLaunch() { // 尝试从本地缓存读取 token const token wx.getStorageSync(token); if (token) { this.globalData.token token; } }, globalData: { token: } })在公共工具函数utils/request.js中封装function request(options) { const token getApp().globalData.token; const header { Content-Type: application/json }; if (token) { header[Authorization] Bearer ${token}; } return new Promise((resolve, reject) { wx.request({ ...options, header: { ...options.header, ...header }, success: (res) { if (res.statusCode 200) { const data res.data; if (data.code 200) { resolve(data.data); } else if (data.code 401) { // token 过期跳转登录页 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(new Error(登录已过期)); } else { wx.showToast({ title: data.msg || 请求失败, icon: none }); reject(new Error(data.msg)); } } else { wx.showToast({ title: 网络错误, icon: none }); reject(new Error(HTTP res.statusCode)); } }, fail: (err) { wx.showToast({ title: 请求失败, icon: none }); reject(err); } }); }); } module.exports { request };这样任何页面只需import { request } from ../../utils/request调用request({ url: /api/task/list })即可自动带 token且 401 会自动跳转登录页——省去 90% 的重复判断。3.3 渲染悬赏列表WXML 结构与 JS 数据绑定技巧小程序端index.wxml列表结构要兼顾性能与可维护性。避免直接wx:for{{tasks}}渲染复杂卡片而是用template抽离!-- index.wxml -- view classcontainer block wx:for{{tasks}} wx:keyid template istaskItem data{{...item}} / /block !-- 上拉加载更多 -- view wx:if{{loading}} classloading加载中.../view view wx:elif{{noMore}} classno-more没有更多了/view /view !-- template.wxml -- template nametaskItem view classtask-card view classtask-header image classavatar src{{publisherAvatar || /images/default-avatar.png}} / text classnick-name{{publisherNickName || 匿名用户}}/text text classtime{{publishTime | formatTime}}/text /view view classtask-title{{title}}/view view classtask-content{{content}}/view view classtask-footer text classreward¥{{rewardAmount}}/text button classbtn-accept bindtaphandleAccept>// index.js const { request } require(../../utils/request); Page({ data: { tasks: [], pageNum: 1, pageSize: 10, loading: false, noMore: false }, onLoad() { this.loadTasks(); }, loadTasks() { if (this.data.loading || this.data.noMore) return; this.setData({ loading: true }); request({ url: /api/task/list, data: { pageNum: this.data.pageNum, pageSize: this.data.pageSize } }).then(res { const newTasks [...this.data.tasks, ...res.list]; this.setData({ tasks: newTasks, pageNum: this.data.pageNum 1, loading: false, noMore: res.list.length this.data.pageSize }); }).catch(err { this.setData({ loading: false }); console.error(加载失败, err); }); }, onReachBottom() { this.loadTasks(); }, handleAccept(e) { const taskId e.currentTarget.dataset.id; wx.navigateTo({ url: /pages/order/order?taskId${taskId} }); } });关键技巧wx:for的wx:key必须用唯一字段如id否则列表滚动时会出现视图错乱onReachBottom触发后必须先判断loading状态否则快速上滑会触发多次请求handleAccept用navigateTo跳转而非redirectTo保留返回路径方便用户修改接单信息。4. 权限与状态流转审核员后台、接单锁机制与防重复提交4.1 审核员后台路由守卫如何让/admin页面只对user_type 1用户开放小程序端不能仅靠隐藏菜单实现权限控制前端代码可被调试篡改必须在后端接口层做强校验。我们为审核员专用接口如/api/admin/task/pending添加自定义注解RequireAuditRoleTarget({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface RequireAuditRole { }然后写一个全局拦截器AdminAuthInterceptorComponent public class AdminAuthInterceptor implements HandlerInterceptor { Autowired private UserInfoMapper userInfoMapper; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequireAuditRole auditRole handlerMethod.getMethodAnnotation(RequireAuditRole.class); if (auditRole null) { return true; // 不需要审核权限放行 } // 从 header 中解析 token String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { sendUnauthorized(response, 缺少权限凭证); return false; } String token authHeader.substring(7); try { Claims claims Jwts.parser() .setSigningKey(your-secret-key-here) .parseClaimsJws(token) .getBody(); Long userId Long.parseLong(claims.getSubject()); UserInfo user userInfoMapper.selectById(userId); if (user null || user.getUserType() ! 1) { // 1 审核员 sendUnauthorized(response, 无审核权限); return false; } } catch (Exception e) { sendUnauthorized(response, 凭证无效); return false; } return true; } private void sendUnauthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(Result.fail(msg))); } }最后在WebMvcConfigurer中注册Configuration public class WebConfig implements WebMvcConfigurer { Autowired private AdminAuthInterceptor adminAuthInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(adminAuthInterceptor) .addPathPatterns(/api/admin/**); // 所有 /api/admin/ 下接口都走此拦截器 } }这样哪怕用户手动在浏览器里访问/api/admin/task/pending只要 token 对应用户user_type ! 1就会直接返回 401 错误。4.2 接单操作的分布式锁为什么 MySQLSELECT ... FOR UPDATE是最稳妥选择接单本质是「抢」多个用户同时点击同一个悬赏的「立即接单」按钮后端必须保证只有一个用户成功创建task_order记录且task_info.status从1已发布变为2已接单。常见错误做法是❌ 先SELECT查状态再UPDATE改状态 —— 存在竞态条件两个请求查到都是1都去UPDATE最终状态被覆盖❌ 用 RedisSETNX实现锁 —— 增加运维复杂度且锁粒度难控制按 task_id 锁还是按用户锁✅ 正确做法用 MySQL 行级锁SELECT ... FOR UPDATE在事务中完成「查改」原子操作。TaskService.acceptTask()方法如下Transactional(rollbackFor Exception.class) public ResultString acceptTask(Long taskId, Long userId) { // 1. 查询任务加行锁必须用 FOR UPDATE且 WHERE 条件要命中索引 TaskInfo task taskMapper.selectForUpdateById(taskId); if (task null) { return Result.fail(任务不存在); } if (task.getStatus() ! 1) { return Result.fail(任务不可接单状态 task.getStatus() ); } // 2. 检查该用户是否已接此任务防重复提交 int existed taskOrderMapper.countByTaskAndUser(taskId, userId); if (existed 0) { return Result.fail(您已接此任务); } // 3. 创建接单记录 TaskOrder order new TaskOrder(); order.setTaskId(taskId); order.setAcceptUserId(userId); order.setOrderStatus((byte) 1); // 进行中 taskOrderMapper.insert(order); // 4. 更新任务状态为「已接单」 task.setStatus((byte) 2); taskMapper.updateById(task); return Result.success(接单成功); }对应的TaskMapper.xml!-- 加锁查询必须走主键索引 -- select idselectForUpdateById resultTypecom.example.entity.TaskInfo SELECT * FROM task_info WHERE id #{id} FOR UPDATE /select !-- 检查是否已接单 -- select idcountByTaskAndUser resultTypeint SELECT COUNT(*) FROM task_order WHERE task_id #{taskId} AND accept_user_id #{userId} /select关键细节FOR UPDATE只在事务中生效所以整个方法必须加TransactionalselectForUpdateById的WHERE id #{id}必须命中主键索引否则会锁整张表countByTaskAndUser也要走联合索引uk_task_user否则高并发下可能变慢。4.3 防重复提交小程序端按钮置灰 后端幂等 Token 双保险光靠后端锁还不够。用户网络慢时可能连续点两次「接单」按钮前端发出两个几乎一样的请求。我们加一层幂等控制小程序端点击后立即将按钮disabled并显示「提交中…」防止手抖后端要求每次接单请求携带一个idempotent-token由小程序生成 UUID存入wx.setStorageSync每次新请求生成新 token后端用 Redis 缓存该 token 10 分钟若重复提交则直接拒绝。Java 端增加幂等校验PostMapping(/accept) RequireLogin public ResultString acceptTask(RequestBody AcceptRequest request) { String token request.getIdempotentToken(); if (StringUtils.isBlank(token)) { return Result.fail(缺少幂等 token); } Boolean exists redisTemplate.hasKey(idempotent: token); if (Boolean.TRUE.equals(exists)) { return Result.fail(请勿重复提交); } // 设置 token 10 分钟过期 redisTemplate.opsForValue().set(idempotent: token, 1, 10, TimeUnit.MINUTES); return taskService.acceptTask(request.getTaskId(), getCurrentUserId()); }小程序端调用时生成 tokenhandleAccept(e) { const taskId e.currentTarget.dataset.id; const idempotentToken wx.getStorageSync(idempotentToken) || Date.now() - Math.random().toString(36).substr(2, 9); wx.setStorageSync(idempotentToken, idempotentToken); this.setData({ btnDisabled: true }); request({ url: /api/task/accept, method: POST, data: { taskId, idempotentToken } }).then(() { wx.showToast({ title: 接单成功 }); }).catch(err { console.error(err); }).finally(() { this.setData({ btnDisabled: false }); }); }这样即使用户狂点按钮也只会有一个请求真正走到数据库锁逻辑其余都被 Redis 拦截——比纯前端限制更可靠。5. 避坑指南5 个真实踩过的雷与绕过方案5.1 现象小程序调用/api/task/list返回空数组但数据库里明明有status 1的任务原因MySQL 时区与 Java 应用时区不一致。微信服务器返回的expire_time是 UTC 时间而你的 MySQL 服务器时区是SYSTEM即东八区但 Spring Boot 默认用 JVM 时区可能也是东八区导致t.expire_time NOW()中的NOW()是东八区时间而expire_time字段存的是 UTC 时间比较永远为假。解决统一时区。在 MySQL 启动参数中加--default-time-zone08:00并在application.yml中配置spring: jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss同时确保task_info.expire_time字段插入时已转为东八区时间如用LocalDateTime.now(ZoneId.of(Asia/Shanghai))。5.2 现象审核员通过/api/admin/task/approve审核后任务状态变成1但小程序端刷新列表仍显示「待审核」原因MyBatis 一级缓存未刷新。taskMapper.selectById(taskId)查询后同一 SqlSession 再次调用相同方法会直接返回缓存结果而审核接口中先查再更新更新后未清空缓存导致后续查询拿到旧值。解决在审核方法末尾手动清除缓存Update(UPDATE task_info SET status #{status} WHERE id #{id}) int updateStatus(Param(id) Long id, Param(status) Byte status); // 调用 updateStatus 后加一行 sqlSession.clearCache(); // 从 Autowired 的 SqlSessionTemplate 获取或者更彻底审核完成后用Redis发布一个task_updated事件让所有节点清空本地缓存适合集群部署。5.3 现象用户上传头像后avatar_url字段存的是临时路径如http://tmp/xxx.jpg小程序端无法显示原因微信小程序wx.uploadFile上传到自己的服务器后后端没做文件持久化只是保存了临时路径。而临时文件几小时后会被系统清理。解决后端收到文件后必须用Files.move()将其移到项目static/upload/目录下并返回https://yourdomain.com/upload/xxx.jpg这样的永久 URL。同时在application.yml中配置静态资源映射spring: web: resources: static-locations: classpath:/static/,file:./static/并在static/目录下建upload文件夹。5.4 现象PageHelper分页在LEFT JOIN查询中总数不准total总是 10原因PageHelper 的COUNT语句默认只统计主表遇到LEFT JOIN时COUNT(*)会把关联表的 NULL 行也计入导致总数虚高而 PageHelper 的优化版COUNT语句SELECT COUNT(1) FROM (...) tmp_count在复杂 JOIN 下可能生成错误 SQL。解决强制使用PageHelper.startPage(pageNum, pageSize, countSqlfalse)关闭自动 COUNT改为手动写 COUNT SQLselect idselectPublishedTasksCount resultTypelong SELECT COUNT(*) FROM task_info t WHERE t.status 1 AND t.expire_time NOW() /select然后在 Service 中long total taskMapper.selectPublishedTasksCount(); PageHelper.startPage(pageNum, pageSize, false); // false 表示不自动 COUNT ListTaskVO list taskMapper.selectPublishedTasks(); return PageResult.TaskVObuilder().list(list).total(total).build();5.5 现象task_order表插入后task_info.status没更新导致同一任务被多人接单原因Transactional注解失效。常见于方法不是publicSpring AOP 代理不生效自调用acceptTask()内部调用了另一个加了Transactional的方法但没通过 Spring 容器代理调用异常类型不对默认只对RuntimeException回滚而你抛的是Exception。解决检查方法签名是否为public避免自调用拆成独立 Service 方法明确声明Transactional(rollbackFor Exception.class)。6. 生产就绪技巧日志追踪、SQL 审计与一键部署脚本6.1 用 MDC 实现全链路日志追踪让一次请求的日志串在一起当用户反馈「我接单失败但没报错」你得快速定位是哪个环节出问题。靠System.out.println或零散log.info()是低效的。我们用 SLF4J 的 MDCMapped Diagnostic Context为每次请求打上唯一 traceId在WebMvcConfigurer中添加拦截器Component public class TraceIdInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId MDC.get(traceId); if (traceId null) { traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); } // 记录请求基本信息 log.info(REQ: {} {} {} | IP: {} | UA: {}, request.getMethod(), request.getRequestURI(), request.getQueryString(), getClientIp(request), request.getHeader(User-Agent)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { log.info(RES: {} {} | Status: {} | Cost: {}ms, request.getMethod(), request.getRequestURI(), response.getStatus(), System.currentTimeMillis() - (Long) request.getAttribute(startTime)); MDC.clear(); } private String getClientIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isEmpty() !unknown.equalsIgnoreCase(xff)) { return xff.split(,)[0].trim(); } return request.getRemoteAddr(); } }然后在logback-spring.xml中配置 patternappender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n/pattern /encoder /appender这样控制台日志每一行开头都有[traceId]你只要复制一个 traceId在日志系统里搜索就能看到从请求进来到数据库操作、再到响应返回的完整链条——排查效率提升 3 倍以上。6.2 SQL 审计自动拦截危险操作并告警开发时手抖写了DELETE FROM task_info没加WHERE测试环境还好一旦上生产就是灾难。我们用p6spy实现 SQL 审计引入依赖dependency groupIdp6spy/groupId artifactIdp6spy/artifactId version3.9.1/version /dependency修改application.yml中的 JDBC URLspring: datasource: url: jdbc:p6spy:mysql://localhost:3306/xiaochengxu?useSSLfalseserverTimezoneAsia/Shanghai在src/main/resources/下新建spy.propertiesmodulelistcom.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.slowquery.P6SlowQueryFactory executionthreshold3000 outagedetectiontrue outagedetectioninterval2 filtertrue include ^INSERT|^UPDATE|^DELETE|^CREATE|^DROP|^ALTER|^TRUNCATE exclude ^SELECT.*from.*dual|^SELECT.*from.*information_schema dateformatyyyy-MM-dd HH:mm:ss appendercom.p6spy.engine.spy.appender.FileLogger logfile./logs/sql.log这样所有INSERT/UPDATE/DELETE语句都会被记录到./logs/sql.log且执行超 3本文还有配套的精品资源点击获取