news 2026/10/18 18:15:07

Spring Boot+MyBatis实现绿植领养系统:从表设计到并发防重

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot+MyBatis实现绿植领养系统:从表设计到并发防重

简介:这是基于BS架构的绿植领养系统完整项目源码,后端采用Spring Boot搭建,持久层使用MyBatis Plus,视图由Thymeleaf渲染,结合Shiro完成权限控制,前端基于Bootstrap构建,整体技术栈贴近真实企业开发。面向正在学习Java Web、需要完成毕业设计或课程项目的学生,以及想理解权限管理、数据表设计与增删改查流程的初级开发者。压缩包共40个文件,包含22个Java源文件、13个HTML页面模板、1个SQL脚本、配置文件和启动脚本,包体约70KB,按Maven标准结构组织,并附有README说明文件,导入IDE后即可运行。通过源码可重点学习Thymeleaf与Spring Boot的整合方式、MyBatis Plus的CRUD写法、Shiro认证授权逻辑,以及Bootstrap响应式页面的布局思路,并掌握完整数据库表设计要点。目前已有49人学习使用,代码量适中、模块完整、文档清晰,适合快速读懂并在此基础上二次开发。

1. 绿植领养从纸质登记到在线系统:B/S架构到底解决了什么

某高校的环保社团期末清点绿植,登记表上写着 20 株绿萝,花架上只剩 13 株,被谁领走、何时归还,全无记录。这是绿植领养最常见的痛点:领养关系散落在纸张和 Excel 里,状态无法同步,去向成了黑匣子。基于B/S系统的绿植领养系统,让浏览器做客户端、服务器管业务、数据库管状态,领养、归还、成长记录在浏览器里闭环完成。

这个系统的核心价值有三点:状态实时可见,管理员打开页面就知道每株绿植在谁手里;流程可追溯,谁在什么时间领养了哪株绿植、归还了没有,全部留痕;免安装,平板、手机、教室电脑都能访问。

适合高校社团、社区绿化角、企业前台绿植角这类中小场景,也是练手B/S架构的理想完整项目。接下来从表设计、核心接口、并发防重到图片 404,逐段拆开讲。

2. 技术选型与项目初始化:为什么BS系统首选分层架构

2.1 三层架构与请求流转:浏览器、业务层、数据库各管什么

B/S 系统的本质是计算和服务集中在服务器端,浏览器只负责展示和采集。用户点下“申请领养”按钮到页面弹出“领养成功”,请求要经过四段才能走完:

  1. 浏览器把表单数据组装成 HTTP 请求发到服务器;
  2. 控制器接收请求,调用业务层校验用户是否登录、绿植是否还在待领养状态;
  3. 业务层调用数据访问层,把状态变更写进数据库;
  4. 数据库返回结果逐层回传,最终以 JSON 或渲染后的 HTML 呈现在浏览器里。

这个分层不是可选项。绿植领养天然涉及多用户并发操作,如果业务判断和 SQL 混在一个方法里,后面要加权限控制、加消息提醒时只能在旧代码上继续打补丁,越补越乱。分层之后,Controller 只负责参数解析和响应组装,Service 只负责业务规则,DAO 只负责 SQL,出了问题顺着链路逐层定位就行。

我一般会在设计阶段给 Service 层定一条硬规矩:事务边界必须落在 Service 方法上,绝不在 Controller 里开事务。领养业务中“修改绿植状态”和“插入领养记录”这两个动作必须同生共死,拆到两个方法里并且不加事务的话,任何一步失败都会留下数据不一致的烂摊子。

2.2 技术栈对比:SSM、Spring Boot、前后端分离怎么选

这个标题下面最常见的三种实现路线,差异集中在工程化程度和部署方式上:

方案前端后端适合场景主要坑点
Servlet + JSPJSPServlet教学最小版页面与 Java 耦合,后期难维护
SSMJSP/ThymeleafSpring + SpringMVC + MyBatis经典企业版XML 配置多,起步慢
Spring Boot + ThymeleafThymeleafSpring Boot + MyBatis中小型真实项目没有明显短板
前后端分离Vue/ReactSpring Boot多人协作的大项目要处理跨域、Token 鉴权、接口文档

对绿植领养这种规模,我推荐 Spring Boot + MyBatis + Thymeleaf + MySQL。

理由有三个。第一,Spring Boot 内置 Tomcat,一条 java -jar 就能跑起来,省掉单独装 Tomcat、打 war 包、配置数据源这一整套操作,新手最友好。第二,MyBatis 的 SQL 写在 XML 里,像 UPDATE t_plant SET status=1 WHERE id=? AND status=0 这种带条件的原子更新写起来比 ORM 直观,后面讲并发防重还会用到。第三,Thymeleaf 在服务端渲染页面,天然适合小团队不做复杂前端工程的情况,页面数据通过 Model 传过去直接输出 HTML。

如果你的团队有人特别熟悉 Vue,把前端拆出去做前后端分离当然可行,但代价要算清楚:CORS 跨域配置、Token 登录态、接口文档维护、前端构建部署这四件事一起加进来,工作量至少上浮 30%。绿植领养的业务复杂度撑不起这个成本,服务端渲染已经够了。

2.3 初始化项目:Maven 工程结构与最小启动配置

我建这类工程时用标准的 Spring Boot 分层包结构,按 controller / service / mapper / entity / config / common 分目录,这样职责边界和 2.1 讲的三层一一对应:

com.greenadopt └── src/main/java ├── Application.java // 启动类,加 @SpringBootApplication ├── controller // 接收 HTTP 请求 │ ├── UserController.java │ ├── PlantController.java │ └── AdoptionController.java ├── service // 业务规则与事务边界 │ ├── PlantService.java │ └── AdoptionService.java ├── mapper // MyBatis 接口 │ ├── UserMapper.java │ ├── PlantMapper.java │ └── AdoptionMapper.java ├── entity // 数据库实体 │ ├── User.java │ ├── Plant.java │ └── Adoption.java ├── config // 拦截器、资源映射、CORS │ └── WebConfig.java └── common // 统一返回封装 └── Result.java

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-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>

三个细节值得注意。第一,mybatis-spring-boot-starter 不受 Spring Boot 父工程依赖管理管,必须写版本号;而 spring-boot-starter-web 和 spring-boot-starter-thymeleaf 由父 POM 统一管版本,不用写。第二,mysql 驱动的新坐标是 com.mysql:mysql-connector-j,老资料里的 mysql-connector-java 在 Spring Boot 高版本里会自动替换成新坐标,直接写新的省得控制台打 deprecation 告警。第三,绿植总量通常几百条,不需要分页插件,如果要加再引入 pagehelper 也不迟。

application.yml 的最小配置如下:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/green_adopt?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password thymeleaf: cache: false jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

这里有两个参数是血泪经验的产物。serverTimezone=Asia/Shanghai 不加的话,MySQL 驱动按 UTC 处理时间,你存进去的 12:00 查询出来会变成 20:00,前后端一起查错。map-underscore-to-camel-case 打开后,create_time 自动映射到 createTime 字段,几十个实体类加 @Column 注解的重复劳动全省了。

提示:如果项目用的 Spring Boot 版本偏老,数据库驱动坐标仍是 mysql-connector-java,版本由框架管理,不必精确指定。

3. 数据库设计:绿植档案、领养订单与成长记录的三张核心表

3.1 表结构设计与字段约束

绿植领养系统一共四张表:用户表、绿植表、领养表、成长记录表。用户表管准入,绿植表管资源,领养表管关系,成长记录表管过程。

用户表的关键字段:

字段类型说明
idbigint 自增主键用户 ID
usernamevarchar(50) 唯一登录名
passwordvarchar(64)加密后密码
nicknamevarchar(50)微信/页面展示昵称
phonevarchar(20)联系手机
roletinyint0 普通用户,1 管理员
create_timedatetime注册时间

绿植表是资源表,status 是灵魂字段,后续所有并发问题都围着它转:

字段类型说明
idbigint 自增主键绿植 ID
namevarchar(50)绿植名称,如绿萝
speciesvarchar(50)品种
descriptionvarchar(500)养护说明
image_urlvarchar(200)图片路径
locationvarchar(100)摆放位置
statustinyint0 待领养,1 已领养,2 下架
create_timedatetime入库时间

领养表记录一次完整的领养关系,谁、什么时间、领了哪株、归还没有。成长记录表挂靠在领养记录下面,一次领养可以有多条成长记录,所以必须独立成表。这两张表的字段按 3.2 的建表脚本展开。

关于外键,我的做法是不建物理外键,只建普通索引。物理外键在数据导入、分表时会变成拖累,而且 green_adopt 这种业务规模完全靠 Service 层保证一致性,SQL 里 JOIN 查询一样快。

3.2 建表SQL全文与关键约束说明

直接给一套能跑的 MySQL 建表脚本:

CREATE DATABASE IF NOT EXISTS green_adopt DEFAULT CHARACTER SET utf8mb4; USE green_adopt; CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '加密密码', nickname VARCHAR(50) DEFAULT '' COMMENT '昵称', phone VARCHAR(20) DEFAULT '' COMMENT '手机号', role TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE t_plant ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '绿植ID', name VARCHAR(50) NOT NULL COMMENT '绿植名称', species VARCHAR(50) NOT NULL COMMENT '品种', description VARCHAR(500) DEFAULT '' COMMENT '养护说明', image_url VARCHAR(200) DEFAULT '' COMMENT '图片路径', location VARCHAR(100) DEFAULT '' COMMENT '摆放位置', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待领养 1已领养 2下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', PRIMARY KEY (id), KEY idx_status (status) ) ENGINE=InnoDB COMMENT='绿植表'; CREATE TABLE t_adoption ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '领养ID', user_id BIGINT NOT NULL COMMENT '领养人', plant_id BIGINT NOT NULL COMMENT '绿植', adopt_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '领养时间', return_time DATETIME DEFAULT NULL COMMENT '归还时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0领养中 1已归还 2逾期未还', remark VARCHAR(200) DEFAULT '' COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_user_plant (user_id, plant_id), KEY idx_plant (plant_id) ) ENGINE=InnoDB COMMENT='领养记录表'; CREATE TABLE t_growth ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', adoption_id BIGINT NOT NULL COMMENT '关联领养记录', plant_id BIGINT NOT NULL COMMENT '冗余绿植ID', record_date DATE NOT NULL COMMENT '记录日期', note VARCHAR(500) DEFAULT '' COMMENT '成长描述', image_url VARCHAR(200) DEFAULT '' COMMENT '拍照路径', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', PRIMARY KEY (id), KEY idx_adoption (adoption_id) ) ENGINE=InnoDB COMMENT='成长记录表';

逐个说约束的用意。uk_username 保证登录名不重复。idx_status 加上是因为“查所有待领养绿植”是这个系统最频繁的查询,带有 WHERE status=0 条件,索引能省一次全表扫描。uk_user_plant 是给并发防重加的兜底唯一索引——业务层的 UPDATE 条件判断万一有漏网之鱼,数据库层面会直接拒绝同一用户重复领养同一株绿植,这是最后一道保险。

t_growth 里的 plant_id 看似冗余,实则是故意的。管理员想按绿植维度看“这株绿萝这一年长了多少”,如果只有 adoption_id,就得先查领养表拿到 plant_id 再回查,多一次 JOIN。冗余字段以极小的空间代价换常见查询的高效,这是数据库设计里的常规取舍。

密码字段长度设 64 而不是 32,是为了将来从 MD5 换 BCrypt 时不改表。MD5 摘要 32 位,BCrypt 输出 60 位,长度设 64 一步到位,这种后悔药能不吃就不吃。

注意:所有字符集相关配置都显式指定 utf8mb4。旧项目里只写 utf8 的话,emoji 表情和生僻字入库会报错,绿植名称里的“𪜶”这种字直接存不进去。

3.3 领养状态流转:待领养→已领养→归还→异常

绿植表和领养表各有一个 status,我必须先说清楚它们的配合关系。绿植表的 status 管资源是否可用,领养表的 status 管这次领养关系是否正常结束。两边的变更必须同步,这是整个系统最容易出错的地方。

业务操作绿植表 status领养表 status前置条件
申请领养0 → 1插入新记录,status=0绿植当前 status=0
正常归还1 → 00 → 1,记录 return_time当前用户是领养人
管理员下架任意 → 2保持原样绿植枯死或丢失
逾期标记保持原样0 → 2定时任务发现超过归还日期

新手最容易犯的错误是在 Service 里先 SELECT 再 UPDATE:先查绿植的 status 是不是 0,是的话执行 UPDATE。两个用户并发操作时,两个请求都查到 status=0,然后先后执行 UPDATE,同一株绿植就被领养了两次。正确做法是把状态判断直接揉进 UPDATE 的 WHERE 条件里,交给数据库的行锁去裁决,下一章给出完整代码。

还有一种情况:归还操作要把绿植表改成待领养,同时把领养表改成已归还。这两个动作如果分开执行,中间断电或抛异常,就会留下“绿植显示已领养但领养记录已经归还”的矛盾态。要么用 @Transactional 把两个动作包进一个事务,要么在代码里先更新领养表再更新绿植表,配合失败补偿脚本。实务中前者是标准解法。

4. 核心功能实现:登录鉴权、绿植展示与领养申请

4.1 统一返回格式与登录鉴权拦截器

前后端交互需要一个稳定的协议。我习惯用 code / msg / data 三段式 JSON,code 等于 0 表示成功,非 0 是业务错误码,前端拿 code 做分支判断,拿 msg 做人读提示:

public class Result<T> { private int code; // 0 成功,非 0 业务失败 private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } }

错误码要有约定:1001 未登录、1002 绿植已被领养、1003 参数缺失、1004 无权限。前端只判断 code 是否等于 0,不要用 msg 字符串去做流程分流——字符串一旦改动,前端和后端就要联调一次,这是项目后期接口改不动的主要原因之一。

登录态我用 Session + 拦截器实现。拦截器在请求进入 Controller 之前校验 Session 里有没有 loginUser:

@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { return true; // 已登录,放行 } String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { // Ajax 请求:返回 JSON 错误码,让前端统一处理 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":1001,\"msg\":\"未登录或登录已过期\"}"); } else { // 普通页面跳转:直接重定向到登录页 response.sendRedirect("/login"); } return false; } }

这段代码解决了一个很隐蔽的体验问题。Ajax 请求如果收到 302 重定向,浏览器会静默跟随,前端拿到的是登录页的 HTML,而不是 JSON,提示语全是乱的。所以拦截器要区分请求类型:带 X-Requested-With 头的是 Ajax,返回 JSON 错误码;普通链接访问则重定向。Spring MVC 里也可以用 @ControllerAdvice 统一捕获异常,但拦截器处理未登录更前置,两种方式配合使用。

注意 request.getSession(false) 传了 false,表示 Session 不存在时不要新创建。如果传 true,每个未登录请求都会强行造一个 Session,虽然功能上没错,但垃圾 Session 会占满服务器内存。Session 里存 loginUser 对象而不是只存 user id,是为了页面渲染时直接拿昵称、角色等字段,省一次数据库查询。

4.2 领养申请接口:并发场景下的原子更新

领养申请是核心业务接口,代码分三层。Controller 负责取登录用户、收集参数、调用 Service;Service 负责事务和业务规则;Mapper 负责那条关键的原子 UPDATE。

Controller:

@RestController @RequestMapping("/api/adoption") public class AdoptionController { @Autowired private AdoptionService adoptionService; @PostMapping("/apply") public Result<?> apply(@RequestBody AdoptionApplyDTO dto, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { return Result.error(1001, "请先登录"); } return adoptionService.apply(loginUser.getId(), dto.getPlantId()); } }

Service 实现:

@Service public class AdoptionServiceImpl implements AdoptionService { @Autowired private PlantMapper plantMapper; @Autowired private AdoptionMapper adoptionMapper; @Override @Transactional(rollbackFor = Exception.class) public Result<?> apply(Long userId, Long plantId) { // 核心:把“状态校验”放进 UPDATE 条件里 int rows = plantMapper.updateStatusIfAvailable(plantId, 0, 1); if (rows == 0) { return Result.error(1002, "绿植已被领养或不存在"); } Adoption adoption = new Adoption(); adoption.setUserId(userId); adoption.setPlantId(plantId); adoption.setStatus(0); adoptionMapper.insert(adoption); return Result.success(null); } }

PlantMapper.xml 里的声明:

<update id="updateStatusIfAvailable"> UPDATE t_plant SET status = #{targetStatus} WHERE id = #{plantId} AND status = #{expectStatus} </update>

理解这条 UPDATE 是理解整个并发防重的关键。WHERE 条件里写了 status = 0,数据库执行 UPDATE 时会锁定命中的行,其他事务再对这个 id 做相同的条件更新,要么被行锁挡住,要么影响行数为 0。第一个用户提交成功,影响行数是 1,继续插入领养记录;第二个用户的 UPDATE 因为 status 已经不是 0,影响行数为 0,直接走 rows == 0 分支返回“已被领养”。这比先 SELECT 再 UPDATE 少了两次数据库往返,且彻底杜绝了检查与更新之间的时间窗口。

@Transactional(rollbackFor = Exception.class) 保证“改绿植状态”与“插入领养记录”在同一事务。rollbackFor 指定运行时异常和受检异常都回滚,Spring 默认只在运行时异常回滚,写明白可以避免查错异常类型。为什么强调这一点?因为如果插入领养记录抛了异常,而绿植状态已经改成已领养,事务一提交,这株绿植就永远卡在“已领养”状态但没有人认领,只能人工改库补救。

4.3 前端对接:列表渲染与表单提交的最小实现

前端我提供一套无框架的最小实现:原生 HTML + jQuery,方便理解数据流。真实项目里可以换成 Vue 或 React,但接口协议完全不用变。

<div id="plantList"></div> <script src="jquery-3.6.0.min.js"></script> <script> // 加载绿植列表 function loadPlants() { $.ajax({ url: '/api/plant/list?status=0', // 只查待领养的 type: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest' } }).done(function (res) { if (res.code !== 0) { alert(res.msg); return; } renderPlants(res.data); }).fail(function () { alert('网络异常,请稍后重试'); }); } function renderPlants(list) { var html = ''; list.forEach(function (p) { html += '<div class="card">' + '<img src="' + p.imageUrl + '" alt="' + p.name + '">' + '<h3>' + p.name + '<small>(' + p.species + ')</small></h3>' + '<p>' + p.description + '</p>' + '<p>位置:' + p.location + '</p>' + '<button onclick="applyAdopt(' + p.id + ')">申请领养</button>' + '</div>'; }); $('#plantList').html(html); } // 提交领养申请 function applyAdopt(plantId) { $.ajax({ url: '/api/adoption/apply', type: 'POST', contentType: 'application/json', data: JSON.stringify({ plantId: plantId }), headers: { 'X-Requested-With': 'XMLHttpRequest' } }).done(function (res) { if (res.code === 0) { alert('领养成功,请按时浇水并记录成长情况'); loadPlants(); // 刷新列表,已领养的不再显示 } else if (res.code === 1001) { location.href = '/login'; // 未登录则跳登录页 } else { alert(res.msg); // 其他业务错误直接提示 } }); } // 初始化 loadPlants(); </script>

第一处细节:每个 Ajax 请求都手动带了 X-Requested-With 头。jQuery 的 $.ajax 只有在跨域请求时才会自动带这个头,同源请求不会带。4.1 的拦截器靠这个头区分 Ajax 和页面请求,所以前端必须在 headers 里显式声明,否则未登录时 Ajax 请求会被当页面请求重定向,报错信息全丢。第二处细节:领养成功后调用 loadPlants() 刷新列表,使已被领养的绿植从“待领养”列表消失,避免用户反复点击同一株。第三处细节:后端接口用 POST + JSON body 而不是表单编码,这样 DTO 里的嵌套对象和后续新增参数都不用调整前端协议。

如果你不想用字符串拼接来渲染,把 renderPlants 换成 Vue 的 v-for 模板即可,数据格式和接口协议保持原样。前端工程化属于可选优化,不要在项目初期引入构建工具链,先把接口跑通。

5. 避坑指南:绿植领养系统开发中常见的5个翻车现场

下面五个坑是我维护模拟项目X的绿植领养模块时踩过的,每个都按现象、原因、解决三段记录,按出现频率排序。

5.1 并发抢领导致同一株绿植被重复领养

现象:两个用户几乎同时提交领养申请,数据库里出现两条领养记录指向同一株绿植,两个人都收到了成功提示,绿植实际只能归一方。

原因:没有用 4.2 节的原子 UPDATE,而是先 SELECT t_plant 查 status,发现是 0 再 UPDATE。两个线程都通过了 SELECT 检查,先后执行无条件的 UPDATE,后执行的把先执行的覆盖掉,领养记录却插了两条。

解决:共三层。第一层是业务上的原子更新 UPDATE ... WHERE status=0,影响行数为 0 就返回业务错误,这是主防线。第二层是 t_adoption 表加 uk_user_plant 唯一索引,防止同一用户重复认领同一株绿植,这是兜底。第三层是前端领养成功后立即刷新列表并禁用按钮,减少人为重复点击。三层都做了,数据层面基本不存在重复领养的可能。

5.2 上传的绿植图片访问 404

现象:管理员在后台成功上传绿植照片,数据库里存的路径也正确,但浏览器打开图片地址直接 404,页面上的绿植图片全是裂图。

原因:Spring Boot 默认只把 classpath:/static/ 等目录映射为静态资源。项目把图片写到服务器本地磁盘 /data/upload/,这个路径不在映射范围内,Tomcat 收到 /upload/xxx.jpg 的请求找不到对应文件,返回 404。

解决:在 WebConfig 里追加一个资源处理器,把 URL 前缀映射到磁盘目录:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }

注意 addResourceLocations 的参数必须以 file: 开头且目录末尾要有斜杠,写成 file:D:/data/upload/ 才对。upload.path 在 application.yml 里配,部署环境不同就改配置,不重新打包。数据库里存相对路径 /upload/plant/1001.jpg,前端拼接域名直接访问。

5.3 前后端分离部署后接口调用被跨域拦截

现象:前端跑在 8081 端口,后端跑在 8080 端口,浏览器控制台报 No 'Access-Control-Allow-Origin' header is present,所有接口都调不通。

原因:浏览器同源策略把端口不同的两个地址视为跨源,Ajax 发出前会先发一个 OPTIONS 预检请求,后端没返回 CORS 响应头,浏览器就拦截了实际请求。在 Controller 上零散加 @CrossOrigin 只能解决单个接口,维护成本高。

解决:写一个全局 CORS 配置,按环境指定允许的来源:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8081") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }

三个注意点。allowCredentials(true) 不允许和 allowedOrigins("*") 同时使用,带 Cookie 的请求必须写明具体来源域名;allowedOrigins 里不要带结尾斜杠,浏览器匹配时会精确到源;如果项目里自定义了拦截器,必须放行 OPTIONS 方法,否则预检请求会被拦截器拦下来,CORS 配置形同虚设。

5.4 数据库连接池耗尽导致系统假死

现象:系统运行几天后,某段时间所有接口响应从几十毫秒涨到几十秒甚至超时,重启立刻恢复,过一阵又复发。

原因:排查日志能看到 Connection is not available, request timed out 这类关键字,说明连接池被借光了。常见诱因有三种:事务方法里做了耗时的外部调用(如远程图片上传),连接被长期占用;某条 SQL 漏了分页条件导致大结果集慢查询;异常路径断了连接归还。

解决:两件事并行做。第一,找出慢 SQL 和未释放连接的位置,开启连接池的泄漏检测,HikariCP 的 leak-detection-threshold 设置 60000 毫秒,超过就打印告警日志,能直接定位到具体 DAO 方法。第二,合理调参:

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 leak-detection-threshold: 60000

绿植领养这种规模的系统,20 个连接完全够用。连接池调参只能缓解症状,根治还得找到占用连接的那条代码路径。事务里不要做远程调用、大文件上传这类操作,把它们挪到事务提交之后再执行。

注意:调连接池上限前先看数据库侧 max_connections,连接池每个实例的最大连接数加起来不能超过 MySQL 的 max_connections,否则数据库直接拒绝连接。

5.5 日期字段显示成13位时间戳

现象:接口返回的 createTime 在前端显示成 1689241234567 这种 13 位数字,用户完全看不懂。

原因:Jackson 默认把 java.util.Date 序列化为毫秒时间戳。如果实体字段是 LocalDateTime,老版本 Jackson 不认,可能直接报 Invalid definition for field;新版本会输出数组,前端拿到更懵。

解决:在 application.yml 里配全局格式:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

如果个别字段需要不同格式,在字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm", timezone = "GMT+8")。反向的坑也要注意:前端传日期字符串给后端时,DTO 字段同样要加 @JsonFormat,否则 Jackson 解析 "2024-06-01 12:00" 失败,直接报 400。数据库连接 URL 里的 serverTimezone 和这里的 time-zone、@JsonFormat 里的 timezone 三者要保持一致,否则时间显示会差 8 小时。

6. 进阶技巧:领养到期提醒与接口并发验证

6.1 用Spring Schedule实现到期自动提醒

绿植领养的运营闭环里,到期归还提醒是不可少的一环。常见做法是用定时任务每天扫一遍领养记录,把超过应还日期还没归还的记录标记为逾期,同时给领养人发站内信。

@Component public class AdoptionRemindTask { @Autowired private AdoptionMapper adoptionMapper; @Autowired private MessageService messageService; // 每天 9 点执行 @Scheduled(cron = "0 0 9 * * ?") public void checkOverdue() { List<Adoption> list = adoptionMapper.findOverdueList(); for (Adoption a : list) { adoptionMapper.updateStatusToOverdue(a.getId()); messageService.sendRemind(a.getUserId(), a.getPlantId()); } } }

启动类上记得加 @EnableScheduling,否则定时任务不生效。Spring 的 cron 是 6 段格式,分别对应秒、分、时、日、月、周,"0 0 9 * * ?" 表示每天 9 点整,周字段用 ? 表示不指定。注意 findOverdueList 的 SQL 要加 WHERE status = 0 AND return_time < NOW(),只处理领养中且已过期的记录,避免重复发提醒。

6.2 用JMeter压测领养接口,验证数据一致性

接口写完后,我习惯用 JMeter 做一轮并发验证,确认防重逻辑真的生效,而不是只在单元测试里用单线程跑。

做法很简单:先在数据库里准备一株 status=0 的绿植;准备两个测试账号分别登录,拿到 Session;在 JMeter 里建一个线程组,线程数设 2,并发执行 POST /api/adoption/apply,参数都指向同一株绿植;跑完看聚合报告,正确的预期是一个请求 code=0 领养成功,另一个 code=1002 已被领养。如果两个都成功,说明 5.1 的防重失效,回去查 UPDATE 条件。

压测通过后我再做一次全链路验证:领养人登录后台能看到自己的领养记录,管理员能查到绿植当前归属,归还操作后绿植重新回到待领养列表。这套验证流程跑完,核心链路才算真正闭环。

做了多年一线开发,我养成了一个习惯:凡是涉及状态变更的接口,写完代码先压一遍看并发表现,再看慢查询日志。绿植领养这种小项目也一样,少写一个 WHERE 条件,就多一个翻车现场;多测一轮并发,就能少一次线上救火。排查问题别一上来就猜代码,先看连接池告警和数据库日志,定位效率高得多。希望帮到你。

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

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

视频预测策略VPP2:从预测到行动,打通视觉决策与策略控制

做视觉决策的朋友多少都有过这种体验&#xff1a;视频预测模型的指标已经刷得不错&#xff0c;生成出来的未来帧也挺自然&#xff0c;结果一接到下游策略里&#xff0c;智能体该撞墙还是撞墙、该拿不到的物体还是拿不到。这不是个别现象&#xff0c;而是预测模型和策略模型在优…

作者头像 李华
网站建设 2026/10/18 18:12:35

华为ONT固件编辑GUI工具:支持头部重写、CRC校验与分区表可视化

简介&#xff1a;HWFW_GUI是一款面向通信设备开发与逆向分析工程师的华为ONT固件可视化编辑工具&#xff0c;基于C开发&#xff0c;专用于解析、浏览、修改及导出华为光猫&#xff08;ONT&#xff09;固件镜像中的子项目&#xff08;如uboot、kernel、rootfs等&#xff09;&…

作者头像 李华
网站建设 2026/10/18 18:11:50

Flutter适配OpenHarmony:二手App设置页开发与平台通道实践

如果你问我&#xff0c;一个二手物品置换App里最不起眼但最容易翻车的模块是哪个&#xff0c;我的答案一定是设置页。刚接手这个项目时&#xff0c;我也觉得这个模块简单得很&#xff1a;几个列表项、几个开关、跳转几个二级页面&#xff0c;半天就能搞定。可真到用Flutter适配…

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

Keithley 2400软件控制三层次:从KickStart到TSP脚本实战指南

简介&#xff1a;本资源是专为Keithley 2400系列数字源表&#xff08;SMU&#xff09;开发的配套测试软件&#xff0c;面向电子测量、半导体器件表征及高校电学实验等领域的工程师与研究人员&#xff0c;解决I-V、I-t、V-t等基础电学特性曲线的自动化采集与分析需求。压缩包含4…

作者头像 李华
网站建设 2026/10/18 18:09:32

用pandas绘制频率分布直方图:groupby、agg、round实战详解

从“定量分析”这四个字切入&#xff0c;很多人第一反应是建模、回归、假设检验&#xff0c;但真正落到日常数据处理时&#xff0c;最常用、最朴素、也最能说明问题的手段&#xff0c;反而是一个频率分布直方图。它一眼就能告诉你数据长什么样&#xff1a;集中在哪个区间、有没…

作者头像 李华
网站建设 2026/10/18 18:07:37

生鲜电商源码解析:Spring Boot单体架构下单全链路

简介&#xff1a;这是一套面向生鲜电商场景的完整项目源码&#xff0c;采用主流框架构建&#xff0c;可在本地环境直接运行&#xff0c;主要服务于有一定编程基础的开发者&#xff0c;用于学习、二次开发或毕业设计。压缩包内共包含四百三十四个文件&#xff0c;整体大小约为十…

作者头像 李华