简介:这是一套面向计算机专业本科生的微信小程序毕业设计实战项目,聚焦悬赏信息发布场景,兼顾课程设计与期末大作业需求,适合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 全文检索。它就是一个真实可上线、能跑通「用户发悬赏→审核员初审→接单者抢单→完成交付→评价结算」全链路的轻量级系统,核心逻辑全部由 Java(Spring 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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 Result<PageResult<TaskVO>> listTasks( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { // 防止恶意传超大页码 if (pageNum < 1 || pageSize < 1 || pageSize > 50) { return Result.fail("参数错误"); } PageResult<TaskVO> result = taskService.listPublishedTasks(pageNum, pageSize); return Result.success(result); } }Service 层(业务逻辑封装):
@Service public class TaskService { @Autowired private TaskMapper taskMapper; @Autowired private UserInfoMapper userInfoMapper; public PageResult<TaskVO> listPublishedTasks(Integer pageNum, Integer pageSize) { // PageHelper 会自动将后续的 select 查询包装成分页 SQL PageHelper.startPage(pageNum, pageSize); List<TaskVO> taskVOS = taskMapper.selectPublishedTasks(); // 手动补充发布者昵称和头像(避免 N+1 查询) Map<Long, UserInfo> userMap = new HashMap<>(); List<Long> userIds = taskVOS.stream() .map(TaskVO::getPublishUserId) .distinct() .collect(Collectors.toList()); if (!userIds.isEmpty()) { List<UserInfo> 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()); } } PageInfo<TaskVO> pageInfo = new PageInfo<>(taskVOS); return PageResult.<TaskVO>builder() .list(taskVOS) .total(pageInfo.getTotal()) .pageNum(pageInfo.getPageNum()) .pageSize(pageInfo.getPageSize()) .build(); } }Mapper XML(精准控制 SQL,拒绝全自动):
<!-- TaskMapper.xml --> <select id="selectPublishedTasks" resultType="com.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 Result<Map<String, 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_type=authorization_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(); Map<String, 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 class="container"> <block wx:for="{{tasks}}" wx:key="id"> <template is="taskItem" data="{{...item}}" /> </block> <!-- 上拉加载更多 --> <view wx:if="{{loading}}" class="loading">加载中...</view> <view wx:elif="{{noMore}}" class="no-more">没有更多了</view> </view> <!-- template.wxml --> <template name="taskItem"> <view class="task-card"> <view class="task-header"> <image class="avatar" src="{{publisherAvatar || '/images/default-avatar.png'}}" /> <text class="nick-name">{{publisherNickName || '匿名用户'}}</text> <text class="time">{{publishTime | formatTime}}</text> </view> <view class="task-title">{{title}}</view> <view class="task-content">{{content}}</view> <view class="task-footer"> <text class="reward">¥{{rewardAmount}}</text> <button class="btn-accept" bindtap="handleAccept">// 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)添加自定义注解@RequireAuditRole:
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireAuditRole { }然后写一个全局拦截器AdminAuthInterceptor:
@Component 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;charset=UTF-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,最终状态被覆盖); - ❌ 用 Redis
SETNX实现锁 —— 增加运维复杂度,且锁粒度难控制(按 task_id 锁?还是按用户锁?); - ✅ 正确做法:用 MySQL 行级锁
SELECT ... FOR UPDATE,在事务中完成「查+改」原子操作。
TaskService.acceptTask()方法如下:
@Transactional(rollbackFor = Exception.class) public Result<String> 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 id="selectForUpdateById" resultType="com.example.entity.TaskInfo"> SELECT * FROM task_info WHERE id = #{id} FOR UPDATE </select> <!-- 检查是否已接单 --> <select id="countByTaskAndUser" resultType="int"> SELECT COUNT(*) FROM task_order WHERE task_id = #{taskId} AND accept_user_id = #{userId} </select>关键细节:
FOR UPDATE只在事务中生效,所以整个方法必须加@Transactional;selectForUpdateById的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 Result<String> 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()); }小程序端调用时生成 token:
handleAccept(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-zone='+08:00',并在application.yml中配置:
spring: jackson: time-zone: GMT+8 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, "countSql=false")关闭自动 COUNT,改为手动写 COUNT SQL:
<select id="selectPublishedTasksCount" resultType="long"> 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 List<TaskVO> list = taskMapper.selectPublishedTasks(); return PageResult.<TaskVO>builder().list(list).total(total).build();5.5 现象:task_order表插入后,task_info.status没更新,导致同一任务被多人接单
原因:@Transactional注解失效。常见于:
- 方法不是
public(Spring AOP 代理不生效); - 自调用:
acceptTask()内部调用了另一个加了@Transactional的方法,但没通过 Spring 容器代理调用; - 异常类型不对:默认只对
RuntimeException回滚,而你抛的是Exception。
解决:检查方法签名是否为public;避免自调用,拆成独立 Service 方法;明确声明@Transactional(rollbackFor = Exception.class)。
6. 生产就绪技巧:日志追踪、SQL 审计与一键部署脚本
6.1 用 MDC 实现全链路日志追踪:让一次请求的日志串在一起
当用户反馈「我接单失败,但没报错」,你得快速定位是哪个环节出问题。靠System.out.println或零散log.info()是低效的。我们用 SLF4J 的 MDC(Mapped 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中配置 pattern:
<appender name="CONSOLE" class="ch.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> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>- 修改
application.yml中的 JDBC URL:
spring: datasource: url: jdbc:p6spy:mysql://localhost:3306/xiaochengxu?useSSL=false&serverTimezone=Asia/Shanghai- 在
src/main/resources/下新建spy.properties:
modulelist=com.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.slowquery.P6SlowQueryFactory executionthreshold=3000 outagedetection=true outagedetectioninterval=2 filter=true include = ^INSERT|^UPDATE|^DELETE|^CREATE|^DROP|^ALTER|^TRUNCATE exclude = ^SELECT.*from.*dual|^SELECT.*from.*information_schema dateformat=yyyy-MM-dd HH:mm:ss appender=com.p6spy.engine.spy.appender.FileLogger logfile=./logs/sql.log这样,所有INSERT/UPDATE/DELETE语句都会被记录到./logs/sql.log,且执行超 3
本文还有配套的精品资源,点击获取