目录
[空气质量监测信息管理系统 ------ 单体项目从 0 到 1 落地实战](#空气质量监测信息管理系统 —— 单体项目从 0 到 1 落地实战)
空气质量监测信息管理系统(SpringMVC+Spring+MyBatis+MySQL5.5)
[1. district 区域表](#1. district 区域表)
[2. air 空气质量指数表](#2. air 空气质量指数表)
[3. SQL 建表语句](#3. SQL 建表语句)
[4.0 先讲清楚"开发套路"(方法论)](#4.0 先讲清楚"开发套路"(方法论))
[4.0.1 关于技术栈的说明](#4.0.1 关于技术栈的说明)
[4.0.2 依赖与配置](#4.0.2 依赖与配置)
[4.0.3 分层架构](#4.0.3 分层架构)
[4.0.4 标准开发五步法](#4.0.4 标准开发五步法)
[4.0.5 实体类](#4.0.5 实体类)
[4.0.6 统一响应结果(所有接口共用)](#4.0.6 统一响应结果(所有接口共用))
[4.1 接口一:查询全部区域信息](#4.1 接口一:查询全部区域信息)
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.2 接口二:分页 & 条件查询空气质量信息](#4.2 接口二:分页 & 条件查询空气质量信息)
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.3 接口三:添加空气质量信息](#4.3 接口三:添加空气质量信息)
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.4 接口四:查看详细内容(根据 id 查询单条)](#4.4 接口四:查看详细内容(根据 id 查询单条))
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.5 接口五:修改空气质量信息](#4.5 接口五:修改空气质量信息)
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.6 接口六:删除空气质量信息](#4.6 接口六:删除空气质量信息)
[第 1 步:Entity](#第 1 步:Entity)
[第 2 步:Mapper](#第 2 步:Mapper)
[第 3 步:Service](#第 3 步:Service)
[第 4 步:Controller](#第 4 步:Controller)
[第 5 步:测试](#第 5 步:测试)
[4.7 完整代码汇总](#4.7 完整代码汇总)
[4.8 开发套路总结(可以直接抄的模板)](#4.8 开发套路总结(可以直接抄的模板))
[5.1 本次项目的收获](#5.1 本次项目的收获)
[5.2 新手常踩的坑](#5.2 新手常踩的坑)
[5.3 下一步可以做什么](#5.3 下一步可以做什么)
空气质量监测信息管理系统 ------ 单体项目从 0 到 1 落地实战
技术栈:Spring 5 + SpringMVC + MyBatis + MySQL | 不含前端,只做后端接口,能返回准确 JSON 即算完成
一、前文介绍
本人也是初学,文中难免存在疏漏,欢迎大家多多包涵。本文围绕单体项目编写思路与落地实现展开,会基于若干 Demo 完成后端完整落地实现(不含前端开发),目标是实现接口开发,输出规范可用的 JSON 响应,适合学完 SpringBoot 之后用来做项目实战练习。
通过网盘分享的文件:ssm练习题 链接:百度网盘 请输入提取码 提取码:uqg7 --来自百度网盘超级会员v4的分享 这个是一些ssm练习题,里面包括的题目图片,这篇我们文章主要做"空气质量监测信息管理系统"
读完本文你将掌握:
- 拿到需求后,如何拆解页面、梳理出接口;
- 单体项目的标准分层结构与开发顺序;
- MyBatis 关联查询、动态 SQL、PageHelper 分页的实战用法;
- Spring 声明式事务在增删改中的正确落地位置;
- 一整套可以直接复用到其它项目的"开发套路"。
项目虽小,五脏俱全。把它跑通,你就具备了单体项目独立落地的能力。
二、项目简介
空气质量监测信息管理系统(SpringMVC+Spring+MyBatis+MySQL5.5)
技术栈
- 后端:Spring 5 + SpringMVC + MyBatis
- 数据库:MySQL5.5
- 前端:JSP + JavaScript表单校验(不包含)
- 事务:Spring声明式事务(不使用硬编码事务)
核心业务功能
- 检索空气质量监测信息库:分页列表,支持按区域下拉筛选;切换分页保留查询条件;监测时间降序;区域名称超链接跳转详情。
- 添加监测信息:JS前端日期格式校验;新增自动填充服务器当前时间作为最后修改时间;事务控制,成功跳转列表、失败留在添加页提示错误。
- 查看详细内容:点击列表区域超链接,根据ID查询单条记录,进入维护页面。
- 修改监测信息:回显原有数据;更新时刷新最后修改时间;事务控制,成功返回列表,失败留在维护页提示更新失败。
- 删除监测信息:事务控制;成功跳转查询页;失败停留在维护页面弹出删除失败提示。
三、数据库设计
一共两张表:district(区域表) 、air(空气质量指数表)
1. district 区域表
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | int | 主键、自增 | 区域编号 |
| name | varchar | 非空 | 区域名称 |
2. air 空气质量指数表
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | int | 主键、自增 | 记录编号 |
| districtId | int | 非空 | 关联district表id(区域编号) |
| monitorTime | date | 非空 | 监测日期 |
| pm10 | int | 非空 | PM10数值 |
| pm25 | int | 非空 | PM2.5数值 |
| monitoringStation | varchar | 非空 | 监测站名称 |
| lastModifyTime | timestamp | 非空 | 最后修改时间,取服务器系统时间(新增/修改时自动刷新) |
3. SQL 建表语句
/* air 数据库:district 区域表 + air 空气质量指数表(含全部数据) */
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 1. 建库并切换
CREATE DATABASE IF NOT EXISTS `air`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `air`;
-- 2. 重复执行前先清表(先子表后父表)
DROP TABLE IF EXISTS `air`;
DROP TABLE IF EXISTS `district`;
-- 3. 区域表 district(5 条)
CREATE TABLE `district` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '区域编号',
`name` varchar(16) NOT NULL COMMENT '区域名称',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='区域表';
INSERT INTO `district` (`id`, `name`) VALUES
(1, '西城区'),
(2, '东城区'),
(3, '海淀区'),
(4, '朝阳区'),
(5, '丰台区');
-- 4. 空气质量表 air(6 条)
CREATE TABLE `air` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '记录编号',
`district_id` int NOT NULL COMMENT '区域编号',
`monitor_time` date NOT NULL COMMENT '检测时间',
`pm10` int NOT NULL COMMENT 'pm10',
`pm25` int NOT NULL COMMENT 'pm2.5',
`monitoring_station` varchar(32) NOT NULL COMMENT '检测站',
`last_modify_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='空气质量指数表';
INSERT INTO `air`
(`id`, `district_id`, `monitor_time`, `pm10`, `pm25`, `monitoring_station`, `last_modify_time`)
VALUES
(1, 1, '2013-02-02', 24, 17, '灵境胡同检测站', '2023-11-24 20:45:41'),
(2, 2, '2013-02-02', 27, 33, '东四检测站', '2023-11-24 20:45:58'),
(3, 3, '2013-02-02', 29, 37, '航天桥检测站', '2023-11-24 20:46:19'),
(4, 5, '2013-02-02', 21, 30, '七里庄检测站', '2023-11-24 20:46:32'),
(5, 1, '2013-02-02', 4, 76, '灵境胡同监测站', '2023-11-24 20:46:51'),
(6, 1, '2013-02-02', 123, 345, '喂喂喂检测站', '2023-11-24 20:47:05');
SET FOREIGN_KEY_CHECKS = 1;
小提示 :建库脚本用的是
utf8mb4_general_ci排序规则,MySQL 5.5 / 5.7 / 8.0 都能直接跑 (MySQL 8.0 默认的utf8mb4_0900_ai_ci在 5.5 / 5.7 上不认,所以这里统一用兼容性更好的utf8mb4_general_ci)。另外,
last_modify_time上配了DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,意思是:新增时自动填当前时间、修改时自动刷新 ------这正是业务里"新增自动填充服务器当前时间""更新时刷新最后修改时间"的实现方式,后端代码里完全不用手动维护这个字段。补充:这里故意没有给
air.district_id建外键约束,两张表只做逻辑关联,方便演示、也方便随意插入测试数据;真实项目里建议按需补上外键。
四、项目整体开发步骤
这一章是全文的重点。前面都是准备工作,从这里开始才是真正"写项目"。
4.0 先讲清楚"开发套路"(方法论)
很多同学写项目卡住,根本原因不是不会写代码,而是不知道该按什么顺序写。这里先给出一套固定的套路,后面所有接口都套这个模板。
4.0.1 关于技术栈的说明
项目用 IDEA 直接构建 SpringBoot 工程,但它的底层正是 Spring + SpringMVC + MyBatis 这套经典组合 ------SpringBoot 只是帮我们省掉了大量 XML 配置,把三者自动整合起来。所以你在下面会看到 @RestController、@Service、@Mapper 这些注解,它们本质就是 SpringMVC + Spring + MyBatis 的组件,学的是 SSM,写的是 SpringBoot,一举两得。
4.0.2 依赖与配置
pom.xml 依赖:
<dependencies>
<!-- starter-web,和页面交互-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 与数据库交互需要的内容-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.49</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.1</version>
</dependency>
</dependencies>
application.yml:
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///air?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
# district_id == districtId 开启驼峰映射
map-underscore-to-camel-case: true
# 调试期打开:把执行的 SQL 打印到控制台,排查问题非常方便
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
驱动类名注意 :
com.mysql.jdbc.Driver是 mysql-connector-java 5.1.x 的驱动类名。如果你换成 8.x 的驱动(mysql-connector-java8.x,或新的com.mysql:mysql-connector-j),类名要改成com.mysql.cj.jdbc.Driver,否则会报Loading class ... is deprecated,严重时直接连不上库。
启动类:
package com.mashibing;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan(basePackages = "com.mashibing.mapper")
public class AirStarterApp {
public static void main(String[] args) {
SpringApplication.run(AirStarterApp.class, args);
}
}
@MapperScan一定要加,否则 Spring 扫不到 Mapper 接口,启动就会报找不到 Bean。
4.0.3 分层架构
浏览器 / Postman
│ ① HTTP 请求
▼
┌──────────────┐
│ Controller │ 控制层:接收参数、调用 Service、返回统一结果
└──────┬───────┘
│ ②
▼
┌──────────────┐
│ Service │ 业务层:业务逻辑(分页、校验、事务、组装数据)
└──────┬───────┘
│ ③
▼
┌──────────────┐
│ Mapper │ 持久层:只负责和数据库打交道
└──────┬───────┘
│ ④
▼
MySQL 数据库
各层职责必须清晰:
| 层 | 职责 | 不该做的事 |
|---|---|---|
| Controller | 接收参数、返回结果 | 不写业务逻辑、不写 SQL |
| Service | 业务逻辑、事务控制 | 不直接拼 SQL |
| Mapper | 数据访问 | 不写业务判断 |
4.0.4 标准开发五步法
每写一个接口,都按这 5 步来,顺序不要乱:
- Entity ------ 看这个接口要操作哪张表,实体类有没有?字段够不够?(比如列表要展示区域名称,就要给 Air 加
districtName) - Mapper 接口 + Mapper.xml ------ 先把 SQL 想清楚,写成方法 + XML
- Service ------ 处理业务逻辑(分页、参数校验、事务、组装返回值)
- Controller ------ 映射请求路径和方式,调用 Service
- 测试 ------ 用 Postman / 浏览器把接口跑通,确认 JSON 正确
为什么是这个顺序? 因为依赖方向是
Controller → Service → Mapper,自底向上写,每写一层就能立刻验证一层,不会写到一半发现底层有问题。
4.0.5 实体类
District 区域表对应实体类:
package com.mashibing.entity;
public class District {
private Integer id;
private String name;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Air 空气质量表对应实体类:
package com.mashibing.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;
public class Air {
private Integer id;
private Integer districtId;
/** 入参用 yyyy-MM-dd 解析,出参用 yyyy-MM-dd 输出 */
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date monitorTime;
private Integer pm10;
private Integer pm25;
private String monitoringStation;
/** 出参格式化成 yyyy-MM-dd HH:mm:ss */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date lastModifyTime;
/** 关联查询出来的区域名称,air 表本身没有这个字段 */
private String districtName;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getDistrictId() {
return districtId;
}
public void setDistrictId(Integer districtId) {
this.districtId = districtId;
}
public Date getMonitorTime() {
return monitorTime;
}
public void setMonitorTime(Date monitorTime) {
this.monitorTime = monitorTime;
}
public Integer getPm10() {
return pm10;
}
public void setPm10(Integer pm10) {
this.pm10 = pm10;
}
public Integer getPm25() {
return pm25;
}
public void setPm25(Integer pm25) {
this.pm25 = pm25;
}
public String getMonitoringStation() {
return monitoringStation;
}
public void setMonitoringStation(String monitoringStation) {
this.monitoringStation = monitoringStation;
}
public Date getLastModifyTime() {
return lastModifyTime;
}
public void setLastModifyTime(Date lastModifyTime) {
this.lastModifyTime = lastModifyTime;
}
public String getDistrictName() {
return districtName;
}
public void setDistrictName(String districtName) {
this.districtName = districtName;
}
}
新手最容易踩的坑 :
Date类型的字段,入参和出参的格式要分别用@DateTimeFormat(Spring 处理表单/URL 参数)和@JsonFormat(Jackson 处理 JSON 输出)。不配的话,前端会收到一串时间戳,日期参数也传不进来。
4.0.6 统一响应结果(所有接口共用)
接口文档里每个接口的响应都是 code / msg / data(分页多一个 total),所以我们先封装一个统一的结果类,所有接口都返回它。
package com.mashibing.common;
import java.io.Serializable;
/**
* 统一响应结果封装
* 所有接口统一返回该结构,前端只需按 code / msg / data / total 解析
*/
public class Result implements Serializable {
/** 状态码:0-成功,非 0-失败 */
private Integer code;
/** 提示信息:成功为空串,失败为错误描述 */
private String msg;
/** 业务数据 */
private Object data;
/** 总记录数:仅分页查询时返回 */
private Long total;
public Result() {
}
/** 成功,无数据 */
public static Result ok() {
Result result = new Result();
result.setCode(0);
result.setMsg("");
return result;
}
/** 成功,带数据 */
public static Result ok(Object data) {
Result result = ok();
result.setData(data);
return result;
}
/** 成功,带数据 + 总条数(分页专用) */
public static Result ok(Object data, Long total) {
Result result = ok(data);
result.setTotal(total);
return result;
}
/** 失败 */
public static Result error(Integer code, String msg) {
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
return result;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public Long getTotal() {
return total;
}
public void setTotal(Long total) {
this.total = total;
}
}
有了它,后面每个 Controller 方法都只需 return Result.ok(...) 或 return Result.error(...),代码风格高度统一。
小提示 :
total只有分页查询会赋值,其它接口返回时会是"total": null(例如接口一的响应)。如果不想让它出现在 JSON 里,在类上加一个@JsonInclude(JsonInclude.Include.NON_NULL)(com.fasterxml.jackson.annotation.JsonInclude)即可,null 字段就不会被序列化出去。
最终项目结构:
air
├── src/main/java/com/mashibing
│ ├── AirStarterApp.java # 启动类
│ ├── common/Result.java # 统一响应结果
│ ├── controller
│ │ ├── AirController.java
│ │ └── DistrictController.java
│ ├── service
│ │ ├── AirService.java
│ │ └── DistrictService.java
│ ├── mapper
│ │ ├── AirMapper.java
│ │ └── DistrictMapper.java
│ └── entity
│ ├── Air.java
│ └── District.java
└── src/main/resources
├── application.yml
└── mapper
├── AirMapper.xml
└── DistrictMapper.xml
4.1 接口一:查询全部区域信息
对应业务:按区域下拉筛选,下拉列表框需要从数据库查询全部区域信息。
接口文档
# 查询区域信息
# 请求方式&请求路径
GET http://localhost:8080/district/list
# 请求参数
无
# 业务流程
直接查询库中的 district 表中全部数据
# 响应数据
成功响应:
{
"code": 0,
"msg": "",
"data": [
{"id": 1,"name": "西城区"},
{"id": 2,"name": "东城区"},
............
]
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息",
"data": null
}
第 1 步:Entity
District 实体类在 4.0.5 已经写好,字段 id / name 完全够用,跳过。
第 2 步:Mapper
DistrictMapper.java
package com.mashibing.mapper;
import com.mashibing.entity.District;
import java.util.List;
public interface DistrictMapper {
/** 查询全部区域 */
List<District> findAll();
}
resources/mapper/DistrictMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mashibing.mapper.DistrictMapper">
<select id="findAll" resultType="com.mashibing.entity.District">
select id, name from district
</select>
</mapper>
两个必须对齐的地方 :
namespace必须是 Mapper 接口的全限定名;id必须和方法名一模一样。对不上就会报Invalid bound statement。
第 3 步:Service
package com.mashibing.service;
import com.mashibing.common.Result;
import com.mashibing.mapper.DistrictMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class DistrictService {
@Autowired
private DistrictMapper districtMapper;
public Result list() {
return Result.ok(districtMapper.findAll());
}
}
第 4 步:Controller
package com.mashibing.controller;
import com.mashibing.common.Result;
import com.mashibing.service.DistrictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/district")
public class DistrictController {
@Autowired
private DistrictService districtService;
@GetMapping("/list")
public Result list() {
return districtService.list();
}
}
第 5 步:测试
浏览器直接访问:http://localhost:8080/district/list
{
"code": 0,
"msg": "",
"data": [
{"id": 1, "name": "西城区"},
{"id": 2, "name": "东城区"},
{"id": 3, "name": "海淀区"},
{"id": 4, "name": "朝阳区"},
{"id": 5, "name": "丰台区"}
],
"total": null
}
✅ 接口一完成。这是最简单的接口,但它把"五步法"完整走了一遍,后面的接口都是它的加强版。
4.2 接口二:分页 & 条件查询空气质量信息
这是最复杂、也最能体现工程能力 的一个接口,涉及三个技术点:关联查询、动态 SQL、分页。 对应业务:分页列表、按区域下拉筛选、切换分页保留查询条件、监测时间降序。
接口文档
# 分页&条件查询空气质量信息
# 请求方式&路径
GET http://localhost:8080/air/list
# 请求参数
page = 1 (当前页,默认1)
size = 5 (每页显示条数,默认5)
districtId = null (区域条件)
# 业务流程
Controller 映射好路径和方式,并且接收参数
Service 层做好分页处理
Mapper 写好动态 SQL,追加查询条件
# 响应数据
成功响应:
{
"code": 0,
"msg": "",
"total": 6,
"data": [
{"id": 6,"districtId": 1,"districtName": "西城区","monitorTime": "2013-02-02","pm10": 123,"pm25": 345,"monitoringStation": "喂喂喂检测站","lastModifyTime": "2023-11-24 20:47:05"},
{"id": 5,"districtId": 1,"districtName": "西城区","monitorTime": "2013-02-02","pm10": 4,"pm25": 76,"monitoringStation": "灵境胡同监测站","lastModifyTime": "2023-11-24 20:46:51"},
{"id": 4,"districtId": 5,"districtName": "丰台区","monitorTime": "2013-02-02","pm10": 21,"pm25": 30,"monitoringStation": "七里庄检测站","lastModifyTime": "2023-11-24 20:46:32"},
............
]
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息",
"data": null
}
第 1 步:Entity
Air 实体类在 4.0.5 已经加了 districtName 字段,够用,跳过。
第 2 步:Mapper
AirMapper.java
package com.mashibing.mapper;
import com.mashibing.entity.Air;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface AirMapper {
/** 分页 & 条件查询(区域条件可选) */
List<Air> findByPage(@Param("districtId") Integer districtId);
/** 根据 id 查询单条记录(详情/回显用) */
Air findById(@Param("id") Integer id);
/** 新增 */
int insert(Air air);
/** 修改(只更新传入的字段) */
int update(Air air);
/** 根据 id 删除 */
int deleteById(@Param("id") Integer id);
}
多参数时建议用
@Param给参数起名,XML 里就能用#{districtId}引用,避免arg0/param1这种可读性差的写法。
resources/mapper/AirMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mashibing.mapper.AirMapper">
<!-- 分页 & 条件查询:air 关联 district 拿到区域名称 -->
<select id="findByPage" resultType="com.mashibing.entity.Air">
select a.id,
a.district_id,
a.monitor_time,
a.pm10,
a.pm25,
a.monitoring_station,
a.last_modify_time,
d.name as districtName
from air a
left join district d on a.district_id = d.id
<where>
<if test="districtId != null">
a.district_id = #{districtId}
</if>
</where>
order by a.monitor_time desc, a.id desc
</select>
<!-- 根据 id 查询单条:同样关联出区域名称 -->
<select id="findById" resultType="com.mashibing.entity.Air">
select a.id,
a.district_id,
a.monitor_time,
a.pm10,
a.pm25,
a.monitoring_station,
a.last_modify_time,
d.name as districtName
from air a
left join district d on a.district_id = d.id
where a.id = #{id}
</select>
</mapper>
三个技术点讲透:
- 关联查询 :
left join district d on a.district_id = d.id,再用d.name as districtName起别名。配合map-underscore-to-camel-case,别名districtName能直接映射到实体属性。- 动态 SQL :
<where>标签会自动处理where关键字,<if>判断条件为空就不拼这段 SQL。这就是"条件可选"的实现方式 ------不传districtId就是查全部。- 分页不写 limit :分页交给 PageHelper,SQL 里不要写 limit,否则会重复分页。
第 3 步:Service
package com.mashibing.service;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.mashibing.common.Result;
import com.mashibing.entity.Air;
import com.mashibing.mapper.AirMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AirService {
@Autowired
private AirMapper airMapper;
public Result list(Integer page, Integer size, Integer districtId) {
// 1. 开启分页:紧跟其后的第一条查询会被自动分页
PageHelper.startPage(page, size);
// 2. 执行查询(注意:SQL 里不要写 limit)
List<Air> list = airMapper.findByPage(districtId);
// 3. 用 PageInfo 包装,拿到总记录数
PageInfo<Air> pageInfo = new PageInfo<>(list);
// 4. 返回数据 + 总数
return Result.ok(pageInfo.getList(), pageInfo.getTotal());
}
}
PageHelper 的使用口诀 :
startPage和查询语句必须紧挨着,中间不能插入其它数据库操作,否则分页会"跑偏"到别的查询上。
第 4 步:Controller
package com.mashibing.controller;
import com.mashibing.common.Result;
import com.mashibing.service.AirService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/air")
public class AirController {
@Autowired
private AirService airService;
/** 分页 & 条件查询 */
@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "5") Integer size,
@RequestParam(required = false) Integer districtId) {
return airService.list(page, size, districtId);
}
}
@RequestParam(defaultValue = "1")负责默认值,required = false负责可选参数------这样即使前端什么都不传,接口也能正常返回第一页。切换分页时前端只要把districtId一起带上,查询条件就自动保留了。
第 5 步:测试
| 场景 | 请求 | 预期 |
|---|---|---|
| 默认分页 | GET /air/list |
返回第 1 页 5 条 |
| 翻页 | GET /air/list?page=2&size=2 |
返回第 2 页 2 条 |
| 按区域筛选 | GET /air/list?districtId=1 |
只返回西城区的数据 |
✅ 接口二完成。关联查询 + 动态 SQL + 分页,是单体项目里最核心的三件套。
4.3 接口三:添加空气质量信息
对应业务 :JS前端日期格式校验;新增自动填充服务器当前时间作为最后修改时间;事务控制,成功跳转列表、失败留在添加页提示错误。
接口文档
# 添加空气质量信息
# 请求方式&路径
POST http://localhost:8080/air/add
# 请求参数
districtId = Integer (必传项)
monitorTime = yyyy-MM-dd (必传项)
pm10 = Integer (必传项)
pm25 = Integer (必传项)
monitoringStation = String (必传项)
# 业务流程
1、接收参数
2、做参数的非空校验
3、数据扔到数据库
# 响应数据
成功响应:
{
"code": 0,
"msg": ""
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息"
}
第 1 步:Entity
Air 实体类字段够用,跳过。
第 2 步:Mapper
在 AirMapper.xml 中追加(接口方法已在 4.2 一并声明):
<!-- 新增:last_modify_time 由数据库自动维护,不写 -->
<insert id="insert">
insert into air(district_id, monitor_time, pm10, pm25, monitoring_station)
values(#{districtId}, #{monitorTime}, #{pm10}, #{pm25}, #{monitoringStation})
</insert>
id和last_modify_time都不用管:id是自增主键,last_modify_time有数据库默认值DEFAULT CURRENT_TIMESTAMP,新增时自动填充服务器当前时间,完美对应业务要求。
第 3 步:Service
在 AirService 中追加:
/** 新增:加声明式事务 */
@Transactional
public Result add(Air air) {
// 参数非空校验
if (air.getDistrictId() == null) {
return Result.error(1, "区域编号不能为空");
}
if (air.getMonitorTime() == null) {
return Result.error(1, "检测时间不能为空");
}
if (air.getPm10() == null) {
return Result.error(1, "pm10 不能为空");
}
if (air.getPm25() == null) {
return Result.error(1, "pm2.5 不能为空");
}
if (air.getMonitoringStation() == null || air.getMonitoringStation().trim().isEmpty()) {
return Result.error(1, "检测站不能为空");
}
// 入库
airMapper.insert(air);
return Result.ok();
}
别忘在类顶部 import
org.springframework.transaction.annotation.Transactional。 校验放在 Service 而不是 Controller ,是为了保证业务规则的唯一入口;事务加在 Service 方法上 ,就是题目要求的"Spring 声明式事务,不使用硬编码事务"。 一个容易忽略的点 :@Transactional默认只在抛出运行时异常 时回滚。上面校验失败走的是return Result.error(...),不会触发回滚 ------本项目每个方法只有一次写库操作,不存在"改了一半"的风险,所以没问题;但如果将来一个方法里有多步写操作,校验失败就应该抛异常 (或手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly())来保证回滚。
第 4 步:Controller
在 AirController 中追加:
/** 添加 */
@PostMapping("/add")
public Result add(Air air) {
return airService.add(air);
}
表单参数(
application/x-www-form-urlencoded)直接用一个Air对象接收即可,Spring 会自动按字段名绑定,不需要写@RequestBody。
第 5 步:测试
Postman 选 POST → http://localhost:8080/air/add → Body 选 x-www-form-urlencoded:
| key | value |
|---|---|
| districtId | 1 |
| monitorTime | 2026-09-14 |
| pm10 | 45 |
| pm25 | 78 |
| monitoringStation | 曲靖监测站 |
成功返回:
{"code": 0, "msg": ""}
漏传 pm10 时返回:
{"code": 1, "msg": "pm10 不能为空"}
✅ 接口三完成。
4.4 接口四:查看详细内容(根据 id 查询单条)
对应业务:点击列表区域名称超链接,根据 ID 查询单条记录,进入维护页面。
接口文档
# 查看详细内容
# 请求方式&路径
GET http://localhost:8080/air/{id}
# 请求参数
路径上的 id
# 业务流程
根据 id 查询单条空气质量记录(关联出区域名称)
# 响应数据
成功响应:
{
"code": 0,
"msg": "",
"data": {
"id": 1,
"districtId": 1,
"districtName": "西城区",
"monitorTime": "2013-02-02",
"pm10": 24,
"pm25": 17,
"monitoringStation": "灵境胡同检测站",
"lastModifyTime": "2023-11-24 20:45:41"
}
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息",
"data": null
}
第 1 步:Entity
跳过。
第 2 步:Mapper
findById 方法已在 4.2 声明,XML 也已在 4.2 一并写好,跳过。
第 3 步:Service
在 AirService 中追加:
/** 根据 id 查询单条(详情/回显) */
public Result findById(Integer id) {
if (id == null) {
return Result.error(1, "id 不能为空");
}
Air air = airMapper.findById(id);
if (air == null) {
return Result.error(1, "记录不存在");
}
return Result.ok(air);
}
第 4 步:Controller
在 AirController 中追加:
/** 查看详情:id 在路径上 */
@GetMapping("/{id}")
public Result findById(@PathVariable Integer id) {
return airService.findById(id);
}
路径参数用
@PathVariable,它会自动把 URL 里的{id}取出来并转成Integer。
第 5 步:测试
浏览器 / Postman:GET http://localhost:8080/air/1
{
"code": 0,
"msg": "",
"data": {
"id": 1,
"districtId": 1,
"districtName": "西城区",
"monitorTime": "2013-02-02",
"pm10": 24,
"pm25": 17,
"monitoringStation": "灵境胡同检测站",
"lastModifyTime": "2023-11-24 20:45:41"
}
}
✅ 接口四完成。
4.5 接口五:修改空气质量信息
对应业务 :回显原有数据;更新时刷新最后修改时间;事务控制,成功返回列表、失败留在维护页提示更新失败。
接口文档
# 修改空气质量信息
# 请求方式&路径
POST http://localhost:8080/air/update
# 请求参数
id = Integer (必传项)
districtId = Integer (非必传)
monitorTime = yyyy-MM-dd (非必传)
pm10 = Integer (非必传)
pm25 = Integer (非必传)
monitoringStation = String (非必传)
# 业务流程
1、接收参数
2、做参数的非空校验
3、修改指定 id 的数据
# 响应数据
成功响应:
{
"code": 0,
"msg": ""
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息"
}
第 1 步:Entity
跳过。
第 2 步:Mapper
在 AirMapper.xml 中追加:
<!-- 修改:<set> 自动处理逗号,<if> 实现"传了才更新" -->
<update id="update">
update air
<set>
<if test="districtId != null">district_id = #{districtId},</if>
<if test="monitorTime != null">monitor_time = #{monitorTime},</if>
<if test="pm10 != null">pm10 = #{pm10},</if>
<if test="pm25 != null">pm25 = #{pm25},</if>
<if test="monitoringStation != null and monitoringStation != ''">
monitoring_station = #{monitoringStation},
</if>
</set>
where id = #{id}
</update>
<set>标签的作用 :它会自动去掉最后一个多余的逗号,并在有内容时补上set关键字。配合<if>就能实现**"字段传了就更新,没传就不动"**的部分更新效果。 注意last_modify_time不用写,数据库的ON UPDATE CURRENT_TIMESTAMP会自动刷新它------这正是业务里"更新时刷新最后修改时间"的实现。
第 3 步:Service
在 AirService 中追加:
/** 修改:加声明式事务 */
@Transactional
public Result update(Air air) {
// id 是必传项
if (air.getId() == null) {
return Result.error(1, "id 不能为空");
}
airMapper.update(air);
return Result.ok();
}
第 4 步:Controller
在 AirController 中追加:
/** 修改 */
@PostMapping("/update")
public Result update(Air air) {
return airService.update(air);
}
第 5 步:测试
Postman POST → http://localhost:8080/air/update,Body 选 x-www-form-urlencoded:
只改 pm10 和 pm25:
| key | value |
|---|---|
| id | 1 |
| pm10 | 30 |
| pm25 | 50 |
成功返回:
{"code": 0, "msg": ""}
回到列表查询,会发现 id=1 这条数据的 pm10/pm25 变了,lastModifyTime 也自动刷新了,而其它字段没被清空------这就是动态 SQL 的威力。
✅ 接口五完成。
4.6 接口六:删除空气质量信息
对应业务 :事务控制;成功跳转查询页;失败停留在维护页面弹出删除失败提示。
接口文档
# 删除空气质量信息
# 请求方式&路径
DELETE http://localhost:8080/air/delete/{id}
# 请求参数
路径上的 id
# 业务流程
1、接收参数
2、删除指定 id 的数据
# 响应数据
成功响应:
{
"code": 0,
"msg": ""
}
失败响应:
{
"code": 错误编码,
"msg": "错误信息"
}
第 1 步:Entity
跳过。
第 2 步:Mapper
在 AirMapper.xml 中追加:
<delete id="deleteById">
delete from air where id = #{id}
</delete>
第 3 步:Service
在 AirService 中追加:
/** 删除:加声明式事务 */
@Transactional
public Result delete(Integer id) {
if (id == null) {
return Result.error(1, "id 不能为空");
}
airMapper.deleteById(id);
return Result.ok();
}
第 4 步:Controller
在 AirController 中追加:
/** 删除:id 在路径上 */
@DeleteMapping("/delete/{id}")
public Result delete(@PathVariable Integer id) {
return airService.delete(id);
}
第 5 步:测试
Postman 选 DELETE → http://localhost:8080/air/delete/1
{"code": 0, "msg": ""}
再查一次列表,id=1 的记录已经没了。
✅ 接口六完成。
4.7 完整代码汇总
为了方便对照,把三个核心文件(追加后的最终版)完整贴出。
AirMapper.java
package com.mashibing.mapper;
import com.mashibing.entity.Air;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface AirMapper {
/** 分页 & 条件查询(区域条件可选) */
List<Air> findByPage(@Param("districtId") Integer districtId);
/** 根据 id 查询单条记录(详情/回显用) */
Air findById(@Param("id") Integer id);
/** 新增 */
int insert(Air air);
/** 修改(只更新传入的字段) */
int update(Air air);
/** 根据 id 删除 */
int deleteById(@Param("id") Integer id);
}
AirService.java
package com.mashibing.service;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.mashibing.common.Result;
import com.mashibing.entity.Air;
import com.mashibing.mapper.AirMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class AirService {
@Autowired
private AirMapper airMapper;
/** 分页 & 条件查询 */
public Result list(Integer page, Integer size, Integer districtId) {
PageHelper.startPage(page, size);
List<Air> list = airMapper.findByPage(districtId);
PageInfo<Air> pageInfo = new PageInfo<>(list);
return Result.ok(pageInfo.getList(), pageInfo.getTotal());
}
/** 根据 id 查询单条(详情/回显) */
public Result findById(Integer id) {
if (id == null) {
return Result.error(1, "id 不能为空");
}
Air air = airMapper.findById(id);
if (air == null) {
return Result.error(1, "记录不存在");
}
return Result.ok(air);
}
/** 新增:加声明式事务 */
@Transactional
public Result add(Air air) {
if (air.getDistrictId() == null) {
return Result.error(1, "区域编号不能为空");
}
if (air.getMonitorTime() == null) {
return Result.error(1, "检测时间不能为空");
}
if (air.getPm10() == null) {
return Result.error(1, "pm10 不能为空");
}
if (air.getPm25() == null) {
return Result.error(1, "pm2.5 不能为空");
}
if (air.getMonitoringStation() == null || air.getMonitoringStation().trim().isEmpty()) {
return Result.error(1, "检测站不能为空");
}
airMapper.insert(air);
return Result.ok();
}
/** 修改:加声明式事务 */
@Transactional
public Result update(Air air) {
if (air.getId() == null) {
return Result.error(1, "id 不能为空");
}
airMapper.update(air);
return Result.ok();
}
/** 删除:加声明式事务 */
@Transactional
public Result delete(Integer id) {
if (id == null) {
return Result.error(1, "id 不能为空");
}
airMapper.deleteById(id);
return Result.ok();
}
}
AirController.java
package com.mashibing.controller;
import com.mashibing.common.Result;
import com.mashibing.entity.Air;
import com.mashibing.service.AirService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/air")
public class AirController {
@Autowired
private AirService airService;
/** 分页 & 条件查询 */
@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "5") Integer size,
@RequestParam(required = false) Integer districtId) {
return airService.list(page, size, districtId);
}
/** 查看详情 */
@GetMapping("/{id}")
public Result findById(@PathVariable Integer id) {
return airService.findById(id);
}
/** 添加 */
@PostMapping("/add")
public Result add(Air air) {
return airService.add(air);
}
/** 修改 */
@PostMapping("/update")
public Result update(Air air) {
return airService.update(air);
}
/** 删除 */
@DeleteMapping("/delete/{id}")
public Result delete(@PathVariable Integer id) {
return airService.delete(id);
}
}
4.8 开发套路总结(可以直接抄的模板)
把整章的思路浓缩成一张表,以后拿到新需求直接套:
| 步骤 | 做什么 | 关键点 |
|---|---|---|
| ① 看页面列接口 | 拆页面 → 定接口(路径、方式、参数、响应) | 先写接口文档,再写代码 |
| ② Entity | 检查实体字段是否够用 | 关联字段要补(如 districtName) |
| ③ Mapper 接口 + XML | 先把 SQL 想清楚 | namespace/id 对齐;动态 SQL 用 <where>/<if>/<set> |
| ④ Service | 业务逻辑:分页、校验、事务、组装 | 校验和事务都放这里 |
| ⑤ Controller | 映射路径、方式、参数 | @RequestParam / @PathVariable |
| ⑥ 测试 | Postman / 浏览器验证 JSON | 每个接口都测,别攒到最后 |
接口 → 注解 对照表:
| 需求 | 注解 |
|---|---|
| 查询(GET) | @GetMapping |
| 新增(POST) | @PostMapping |
| 修改(POST) | @PostMapping |
| 删除(DELETE) | @DeleteMapping |
| URL 查询参数 | @RequestParam(可配 defaultValue / required) |
| URL 路径参数 | @PathVariable |
| 表单对象接收 | 直接写实体类参数,无需注解 |
| JSON 对象接收 | @RequestBody |
| 声明式事务 | @Transactional(加在 Service 方法上) |
本项目接口总览:
| 序号 | 功能 | 方式 | 路径 |
|---|---|---|---|
| 1 | 查询全部区域 | GET | /district/list |
| 2 | 分页 & 条件查询 | GET | /air/list |
| 3 | 添加 | POST | /air/add |
| 4 | 查看详情 | GET | /air/{id} |
| 5 | 修改 | POST | /air/update |
| 6 | 删除 | DELETE | /air/delete/{id} |
五、总结与踩坑记录
5.1 本次项目的收获
- 先设计再编码:库表 → 业务 → 接口文档 → 代码,顺序对了,写起来就是"填空题"。
- 分层要清晰:Controller 只负责收发,Service 管业务和事务,Mapper 管 SQL,各司其职。
- 统一响应结构 :用
Result封装,前端对接成本极低,代码风格也统一。 - 动态 SQL 是 MyBatis 的精髓 :
<where>/<if>/<set>三个标签,解决了条件查询和部分更新两大场景。 - 事务交给声明式 :
@Transactional加在 Service 方法上,干净利落,不写硬编码事务。
5.2 新手常踩的坑
| 现象 | 原因 | 解决 |
|---|---|---|
Invalid bound statement (not found) |
XML 的 namespace/id 与 Mapper 接口对不上,或 XML 没放在 resources/mapper/ |
检查三处对齐 + mapper-locations 配置 |
查询结果字段是 null |
下划线没映射成驼峰 | 开启 map-underscore-to-camel-case: true |
| 分页失效,返回全部数据 | startPage 和查询之间插了别的操作,或 SQL 里自己写了 limit |
两者紧挨着,SQL 不写 limit |
| 日期参数传不进来 / 前端收到时间戳 | 没配 @DateTimeFormat 和 @JsonFormat |
实体字段上加这两个注解 |
| 修改时没传的字段被清空 | 直接 update ... set 所有字段 |
用 <set> + <if> 做动态更新 |
| 启动报找不到 Mapper Bean | 忘了 @MapperScan |
启动类加 @MapperScan("com.mashibing.mapper") |
| 事务不生效 | @Transactional 加在了 Controller,或同类内部方法直接调用 |
加到 Service 的 public 方法上,通过代理调用 |
详情接口 /{id} 会匹配任意单段路径 |
@GetMapping("/{id}") 的模板路径匹配范围很宽 |
/air/list 这类字面量路径优先级更高,不会被误匹配;但以后新增单段路径接口时要留意顺序与命名 |
@Transactional 加了却不回滚 |
校验失败用的是 return Result.error(...),而 Spring 只在抛出运行时异常时才回滚 |
单次写库无碍;多步写操作时改为抛异常,或手动 setRollbackOnly() |
5.3 下一步可以做什么
- 把
Result的错误码抽成枚举 ,替代裸写的1; - 用
@RestControllerAdvice做全局异常处理 ,统一捕获异常并转成Result; - 用
@Validated+ JSR-303 注解替代 Service 里手写的非空校验; - 把重复的 CRUD 抽成通用 Mapper(如 MyBatis-Plus),减少样板代码;
- 引入 Redis 缓存,把区域列表这种低频变动的数据缓存起来。
项目虽小,五脏俱全。把这一套跑通,你就具备了单体项目独立落地的能力。 后续的 Redis 缓存、分布式锁、微服务拆分,都是在这个基础之上加东西。