楼层: 首页/ 软件技术/ Spring Boot 3.5/ 小项目:任务管理 API
13

小项目:任务管理 API

Mini Project: Task Manager API

把 Web 层、JPA、校验、全局异常、分页全部串起来,做一个完整的任务管理后端。技术栈:Spring Boot 3.5 + Spring Data JPA + H2 + Validation。

目录结构

src/main/java/com/example/task/ ├── TaskManagerApplication.java // 主启动类 ├── entity/Task.java // JPA 实体 ├── repo/TaskRepository.java // Spring Data 接口 ├── dto/TaskRequest.java // 请求 DTO,带校验 ├── service/TaskService.java // 业务逻辑 └── web/TaskController.java // REST 接口 src/main/resources/ └── application.yml // H2 配置

Task 实体

package com.example.task.entity; import jakarta.persistence.*; @Entity public class Task { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; private String title; private boolean done; // JPA 要求无参构造器 protected Task() {} public Task(String title) { this.title = title; } // getter / setter 省略 }

TaskRepository:继承 JpaRepository 即拥有 CRUD

package com.example.task.repo; import com.example.task.entity.Task; import org.springframework.data.jpa.repository.JpaRepository; public interface TaskRepository extends JpaRepository<Task, Long> { // JpaRepository 已经自带 save/findById/findAll/deleteById // 分页 findAll(Pageable) 也有,不用自己写 }

TaskRequest:带校验的请求 DTO

package com.example.task.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public class TaskRequest { @NotBlank(message = "标题不能为空") @Size(max = 200, message = "标题最长 200 字") private String title; // getter / setter 省略 }

TaskService:业务逻辑

package com.example.task.service; import com.example.task.dto.TaskRequest; import com.example.task.entity.Task; import com.example.task.repo.TaskRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @Service public class TaskService { private final TaskRepository repo; public TaskService(TaskRepository repo) { this.repo = repo; } // 分页查询 public Page<Task> list(int page, int size) { return repo.findAll(PageRequest.of(page, size)); } // 创建任务 public Task create(TaskRequest req) { return repo.save(new Task(req.getTitle())); } // 标记完成,找不到就抛异常 → 全局处理器转 404 public Task markDone(long id) { Task t = repo.findById(id) .orElseThrow(() -> new IllegalArgumentException("任务不存在:" + id)); t.setDone(true); return repo.save(t); } }

TaskController:REST 接口

package com.example.task.web; import com.example.task.dto.TaskRequest; import com.example.task.entity.Task; import com.example.task.service.TaskService; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/tasks") public class TaskController { private final TaskService service; public TaskController(TaskService service) { this.service = service; } @GetMapping public Page<Task> list(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { return service.list(page, size); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Task create(@Valid @RequestBody TaskRequest req) { return service.create(req); } @PatchMapping("/{id}/done") public Task done(@PathVariable long id) { return service.markDone(id); } }

GlobalExceptionHandler:统一错误响应

package com.example.task.web; import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; @RestControllerAdvice public class GlobalExceptionHandler { // 校验失败:把字段错误整理成 Map 返回 @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleValidation(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .reduce((a, b) -> a + "; " + b) .orElse("参数错误"); return new ErrorResponse(400, msg); } // 业务异常(任务不存在)→ 404 @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNotFound(IllegalArgumentException e) { return new ErrorResponse(404, e.getMessage()); } // 兜底 @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse handleOther(Exception e) { return new ErrorResponse(500, "服务器开小差了"); } // 统一错误响应体 public record ErrorResponse(int code, String message) {} }

application.yml:H2 内存库

spring: datasource: url: jdbc:h2:mem:taskdb driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true jpa: hibernate: ddl-auto: update show-sql: true server: port: 8080
启动后用 curl 测: # 创建任务 curl -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title":"学完 Spring Boot"}' # 分页查 curl http://localhost:8080/api/tasks?page=0&size=5 # 标记完成 curl -X PATCH http://localhost:8080/api/tasks/1/done

从 0 到 1 补齐:pom.xml、单元测试、Docker

上面是核心代码,这里把"跑起来"需要的最后几块拼图补齐:Maven 依赖、一个 Service 单元测试、Docker 部署。照这份就能完整交付。

pom.xml:只要四个 starter

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.0</version> <!-- 版本由 parent 仲裁,子依赖不用写版本 --> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency><!-- Web:MVC + 内嵌 Tomcat --> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency><!-- JPA + Hibernate --> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency><!-- 参数校验 --> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency><!-- H2 内存数据库 --> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency><!-- 测试全家桶 --> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

TaskService 单元测试:Mock 掉 Repository

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) // 纯 Mockito 测试,不起 Spring 容器 class TaskServiceTest { @Mock TaskRepository repo; // 假的 Repository @InjectMocks TaskService service; // 被测对象,自动注入上面的假 repo @Test void markDoneWhenNotFoundShouldThrow() { // 打桩:findById(99) 返回空 → 应抛 IllegalArgumentException when(repo.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.markDone(99L)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("任务不存在"); } }
mvn test 运行结果: [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS # 纯 Mockito 不起容器,毫秒级跑完——单元测试就该这么快。

Dockerfile(沿用第 11 章多阶段构建)

FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /app COPY pom.xml . &&& mvn dependency:go-offline COPY src ./src &&& mvn package -DskipTests FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]
一条龙: docker build -t task-manager:1.0 . docker run -p 8080:8080 task-manager:1.0 # 打开 http://localhost:8080/api/tasks 就能用了。
本章面试题 · 第 12 章(任务管理项目)

1.(架构题)为什么 Controller 不直接调 Repository,非要中间隔一层 Service?

查看答案

答案:分层是为了解耦和复用。业务逻辑(校验、事务、多步操作)放 Service,将来加缓存、加权限、加日志只改 Service;Controller 只负责收参数、调 Service、返回视图。解析:一个 Service 可能被多个 Controller 调用,直接 Controller 调 Repository 就没法复用了。

2.(实践题)创建任务时标题不能为空,你在哪一层做这个校验?

查看答案

答案:DTO 上贴 @NotBlank,Controller 参数加 @Valid,非法请求直接 400,不进业务层。复杂的业务规则(如"标题不能和现有任务重复")才放 Service。解析:参数格式校验在入口(@Valid),业务规则在 Service。