单体项目编写思路和落地形式

目录

[空气质量监测信息管理系统 ------ 单体项目从 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练习题,里面包括的题目图片,这篇我们文章主要做"空气质量监测信息管理系统"

读完本文你将掌握:

  1. 拿到需求后,如何拆解页面、梳理出接口;
  2. 单体项目的标准分层结构与开发顺序;
  3. MyBatis 关联查询、动态 SQL、PageHelper 分页的实战用法;
  4. Spring 声明式事务在增删改中的正确落地位置;
  5. 一整套可以直接复用到其它项目的"开发套路"。

项目虽小,五脏俱全。把它跑通,你就具备了单体项目独立落地的能力。


二、项目简介

空气质量监测信息管理系统(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-java 8.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 步来,顺序不要乱:

  1. Entity ------ 看这个接口要操作哪张表,实体类有没有?字段够不够?(比如列表要展示区域名称,就要给 Air 加 districtName
  2. Mapper 接口 + Mapper.xml ------ 先把 SQL 想清楚,写成方法 + XML
  3. Service ------ 处理业务逻辑(分页、参数校验、事务、组装返回值)
  4. Controller ------ 映射请求路径和方式,调用 Service
  5. 测试 ------ 用 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>

三个技术点讲透:

  1. 关联查询left join district d on a.district_id = d.id,再用 d.name as districtName 起别名。配合 map-underscore-to-camel-case,别名 districtName 能直接映射到实体属性。
  2. 动态 SQL<where> 标签会自动处理 where 关键字,<if> 判断条件为空就不拼这段 SQL。这就是"条件可选"的实现方式 ------不传 districtId 就是查全部。
  3. 分页不写 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>

idlast_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 选 POSThttp://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 POSThttp://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 选 DELETEhttp://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 本次项目的收获

  1. 先设计再编码:库表 → 业务 → 接口文档 → 代码,顺序对了,写起来就是"填空题"。
  2. 分层要清晰:Controller 只负责收发,Service 管业务和事务,Mapper 管 SQL,各司其职。
  3. 统一响应结构 :用 Result 封装,前端对接成本极低,代码风格也统一。
  4. 动态 SQL 是 MyBatis 的精髓<where> / <if> / <set> 三个标签,解决了条件查询和部分更新两大场景。
  5. 事务交给声明式@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 缓存、分布式锁、微服务拆分,都是在这个基础之上加东西。

相关推荐
Ivanqhz1 小时前
Slope One 算法详解:与矩阵分解、共现矩阵的异同
java·服务器·网络·人工智能·深度学习
Bs_MoneyMagnet2 小时前
基于springboot+vue的非遗物质文化遗产系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
万物智能2 小时前
波形发生AD9833模块配置—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
java
计算机毕设定制辅导-无忧学长2 小时前
《基于SpringBoot的未成年人健康知识科普平台的设计与实现》
java·开发语言·vue.js·spring boot·未成年人健康知识科普平台
Wang's Blog2 小时前
Java 项目实战: 外卖平台-数据库环境搭建与十一张表结构解析
java·服务器·redis
Ivanqhz2 小时前
Ping-Pong 双缓冲
开发语言·人工智能·python·深度学习·mlir
m0_734571762 小时前
深入理解C++ RAII
开发语言·c++
白远山2 小时前
上海24小时自助健身房系统软件开发实战指南
java·架构·uni-app·需求分析
泡海椒2 小时前
jquick-pdf 表格实战:动态数据 PDF 报表生成
java·开发语言·pdf