news 2026/10/20 16:17:36

微信小程序+Java后端悬赏系统实战:从登录态到分布式接单锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小程序+Java后端悬赏系统实战:从登录态到分布式接单锁

简介:这是一套面向计算机专业本科生的微信小程序毕业设计实战项目,聚焦悬赏信息发布场景,兼顾课程设计与期末大作业需求,适合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,最终状态被覆盖);
  • ❌ 用 RedisSETNX实现锁 —— 增加运维复杂度,且锁粒度难控制(按 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 审计:

  1. 引入依赖:
<dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>
  1. 修改application.yml中的 JDBC URL:
spring: datasource: url: jdbc:p6spy:mysql://localhost:3306/xiaochengxu?useSSL=false&serverTimezone=Asia/Shanghai
  1. 在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

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 15:33:00

Shapefile数据处理全攻略:从读取清洗到投影转换与交付校验

简介&#xff1a;面向GIS数据分析、城乡规划与环境研究人员的国家基础地理信息系统矢量数据集&#xff0c;涵盖全国主要湖泊、铁路、公路、行政边界、县级驻地、主要河流及县级统计数据等关键要素&#xff0c;可用于地图制图、空间统计与区域分析。压缩包共93个文件&#xff0c…

作者头像 李华
网站建设 2026/10/18 13:23:14

基于SSM+MySQL的酒店管理系统:环境搭建、数据库设计与核心业务实战

简介&#xff1a;基于SSM和MySQL的酒店管理系统项目代码及数据库&#xff0c;专为毕业设计、期末大作业和课程设计准备&#xff0c;适合Java初学者和在校学生参考。项目围绕酒店日常运营场景&#xff0c;涵盖了房间管理、用户登录、订单预订、评论互动等核心业务模块&#xff0…

作者头像 李华
网站建设 2026/10/17 17:39:07

数据分析师的机器学习落地:可复现环境与可解释模型实践

简介&#xff1a;本资源是面向数据科学初学者与转行学习者的机器学习系统性实践教程&#xff0c;聚焦数据分析岗位所需的核心算法能力培养。内容覆盖从基础理论到工程落地的完整链路&#xff0c;包含线性回归、逻辑回归、决策树与随机森林、SVM、聚类、神经网络、集成学习及模型…

作者头像 李华
网站建设 2026/10/17 22:14:35

PTA数据结构与算法题目集:C++图论、树与排序刷题代码解析

简介&#xff1a;这是一份PTA平台数据结构与算法题目的C题解合集&#xff0c;尤其贴近浙江大学PTA训练体系&#xff0c;适合正在备考、刷题或复习数据结构的高校学生和算法爱好者。压缩包体积仅38KB&#xff0c;共41个文件&#xff0c;其中38个cpp源代码是解题主体&#xff0c;…

作者头像 李华
网站建设 2026/10/19 6:58:45

AttBiLSTM实体关系抽取实战:从数据标注到模型调优的避坑指南

简介&#xff1a;这份资源面向自然语言处理初学者与知识图谱方向的学习者&#xff0c;聚焦实体关系抽取这一核心任务&#xff0c;提供基于注意力机制与双向LSTM结合的AttBiLSTM模型实现方案&#xff0c;可用于从文本中识别实体并判定其语义关系&#xff0c;为知识图谱构建提供原…

作者头像 李华
网站建设 2026/10/20 7:43:53

艺美装饰靠谱吗 从成立时间看公司正规程度

一家装修公司靠不靠谱&#xff0c;先看它在这座城市站了多久在泰安&#xff0c;问装修这件事&#xff0c;几乎每个业主都绕不开一个疑问&#xff1a;这家公司靠谱吗?报价低的怕增项&#xff0c;品牌大的怕转包&#xff0c;门店漂亮的怕交完钱就变脸。其实判断一家装修公司是否…

作者头像 李华