Skip to content

08 待办事项项目实战

本章用一个待办事项 API 串联前面的知识。先完成最小版本,再增加数据库、校验、异常处理和测试。

1. 项目目标

实现以下接口:

功能方法URL
创建任务POST/api/tasks
查询任务列表GET/api/tasks
查询单个任务GET/api/tasks/{id}
修改任务PUT/api/tasks/{id}
标记完成PATCH/api/tasks/{id}/complete
删除任务DELETE/api/tasks/{id}

任务包含:编号、标题、描述、状态、截止日期、创建时间和更新时间。

2. 创建项目

使用 Spring Initializr 创建 Maven 项目:

text
Group: com.example
Artifact: todo
Java: 21
Packaging: Jar

选择依赖:

  • Spring Web
  • Validation
  • Spring Data JPA
  • H2 Database
  • Spring Boot Starter Test

推荐目录:

text
src/main/java/com/example/todo/
├── TodoApplication.java
├── common/
│   ├── ErrorResponse.java
│   ├── GlobalExceptionHandler.java
│   └── ResourceNotFoundException.java
└── task/
    ├── Task.java
    ├── TaskStatus.java
    ├── TaskRepository.java
    ├── TaskService.java
    ├── TaskController.java
    └── dto/
        ├── CreateTaskRequest.java
        ├── UpdateTaskRequest.java
        └── TaskResponse.java

这里按业务功能组织代码。小项目也可以使用全局 controller/service/repository 分层,但随着模块增加,按功能组织通常更容易维护。

3. 配置 H2

application.yml

yaml
spring:
  datasource:
    url: jdbc:h2:mem:todo
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    open-in-view: false
  h2:
    console:
      enabled: true

4. 定义状态枚举

java
package com.example.todo.task;

public enum TaskStatus {
    TODO,
    COMPLETED
}

使用枚举比使用任意字符串更安全,编译器能帮助发现非法状态。

5. 定义实体

java
package com.example.todo.task;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Table(name = "tasks")
public class Task {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 1000)
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private TaskStatus status;

    private LocalDate dueDate;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    protected Task() {
    }

    public Task(String title, String description, LocalDate dueDate) {
        this.title = title;
        this.description = description;
        this.dueDate = dueDate;
        this.status = TaskStatus.TODO;
    }

    @PrePersist
    void onCreate() {
        LocalDateTime now = LocalDateTime.now();
        createdAt = now;
        updatedAt = now;
    }

    @PreUpdate
    void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    public void update(String title, String description, LocalDate dueDate) {
        this.title = title;
        this.description = description;
        this.dueDate = dueDate;
    }

    public void complete() {
        this.status = TaskStatus.COMPLETED;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public TaskStatus getStatus() { return status; }
    public LocalDate getDueDate() { return dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

@Enumerated(EnumType.STRING) 会把 TODOCOMPLETED 保存为字符串。不要依赖默认序号保存枚举,否则调整枚举顺序可能破坏已有数据。

6. 定义 DTO

java
package com.example.todo.task.dto;

import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;

public record CreateTaskRequest(
        @NotBlank(message = "标题不能为空")
        @Size(max = 100, message = "标题不能超过 100 个字符")
        String title,

        @Size(max = 1000, message = "描述不能超过 1000 个字符")
        String description,

        @FutureOrPresent(message = "截止日期不能早于今天")
        LocalDate dueDate
) {
}
java
public record UpdateTaskRequest(
        @NotBlank(message = "标题不能为空")
        @Size(max = 100, message = "标题不能超过 100 个字符")
        String title,

        @Size(max = 1000, message = "描述不能超过 1000 个字符")
        String description,

        @FutureOrPresent(message = "截止日期不能早于今天")
        LocalDate dueDate
) {
}
java
public record TaskResponse(
        Long id,
        String title,
        String description,
        String status,
        LocalDate dueDate,
        LocalDateTime createdAt,
        LocalDateTime updatedAt
) {
}

7. Repository

java
package com.example.todo.task;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TaskRepository extends JpaRepository<Task, Long> {
    Page<Task> findByStatus(TaskStatus status, Pageable pageable);
}

8. Service

java
package com.example.todo.task;

@Service
public class TaskService {
    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    @Transactional
    public TaskResponse create(CreateTaskRequest request) {
        Task task = new Task(request.title(), request.description(), request.dueDate());
        return toResponse(taskRepository.save(task));
    }

    @Transactional(readOnly = true)
    public Page<TaskResponse> findAll(TaskStatus status, Pageable pageable) {
        Page<Task> page = status == null
                ? taskRepository.findAll(pageable)
                : taskRepository.findByStatus(status, pageable);
        return page.map(this::toResponse);
    }

    @Transactional(readOnly = true)
    public TaskResponse findById(Long id) {
        return toResponse(findEntity(id));
    }

    @Transactional
    public TaskResponse update(Long id, UpdateTaskRequest request) {
        Task task = findEntity(id);
        task.update(request.title(), request.description(), request.dueDate());
        return toResponse(task);
    }

    @Transactional
    public TaskResponse complete(Long id) {
        Task task = findEntity(id);
        task.complete();
        return toResponse(task);
    }

    @Transactional
    public void delete(Long id) {
        Task task = findEntity(id);
        taskRepository.delete(task);
    }

    private Task findEntity(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("任务不存在:" + id));
    }

    private TaskResponse toResponse(Task task) {
        return new TaskResponse(
                task.getId(),
                task.getTitle(),
                task.getDescription(),
                task.getStatus().name(),
                task.getDueDate(),
                task.getCreatedAt(),
                task.getUpdatedAt()
        );
    }
}

在事务中加载的实体发生变化后,JPA 会进行脏检查,在提交事务时生成 UPDATE,所以 updatecomplete 中不一定需要再次调用 save

9. Controller

java
package com.example.todo.task;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {
    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @PostMapping
    public ResponseEntity<TaskResponse> create(
            @Valid @RequestBody CreateTaskRequest request
    ) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(taskService.create(request));
    }

    @GetMapping
    public Page<TaskResponse> findAll(
            @RequestParam(required = false) TaskStatus status,
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
            Pageable pageable
    ) {
        return taskService.findAll(status, pageable);
    }

    @GetMapping("/{id}")
    public TaskResponse findById(@PathVariable Long id) {
        return taskService.findById(id);
    }

    @PutMapping("/{id}")
    public TaskResponse update(
            @PathVariable Long id,
            @Valid @RequestBody UpdateTaskRequest request
    ) {
        return taskService.update(id, request);
    }

    @PatchMapping("/{id}/complete")
    public TaskResponse complete(@PathVariable Long id) {
        return taskService.complete(id);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        taskService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

IDE 会自动提示需要导入的 Spring、Validation 和 Domain 类型。导包后再用编译错误检查是否漏掉依赖。

10. 异常处理

ResourceNotFoundException

java
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

ErrorResponse

java
public record ErrorResponse(
        String code,
        String message,
        LocalDateTime timestamp
) {
}

GlobalExceptionHandler

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException exception
    ) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
                new ErrorResponse(
                        "TASK_NOT_FOUND",
                        exception.getMessage(),
                        LocalDateTime.now()
                )
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException exception
    ) {
        String message = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(Collectors.joining("; "));

        return ResponseEntity.badRequest().body(
                new ErrorResponse("VALIDATION_FAILED", message, LocalDateTime.now())
        );
    }
}

11. 手动测试

创建 requests.http

http
### 新建任务
POST http://localhost:8080/api/tasks
Content-Type: application/json

{
  "title": "学习 Spring Boot",
  "description": "完成 REST API 和 JPA 练习",
  "dueDate": "2030-12-31"
}

### 查询全部
GET http://localhost:8080/api/tasks?page=0&size=10

### 查询未完成
GET http://localhost:8080/api/tasks?status=TODO&page=0&size=10

### 完成任务
PATCH http://localhost:8080/api/tasks/1/complete

### 删除任务
DELETE http://localhost:8080/api/tasks/1

12. Repository 测试

java
@DataJpaTest
class TaskRepositoryTest {
    @Autowired
    private TaskRepository taskRepository;

    @Test
    void shouldFindTasksByStatus() {
        taskRepository.save(new Task("学习 JPA", null, null));

        Page<Task> page = taskRepository.findByStatus(
                TaskStatus.TODO,
                PageRequest.of(0, 10)
        );

        assertEquals(1, page.getTotalElements());
    }
}

13. Service 单元测试思路

Service 单元测试不必启动整个 Spring 容器,可以使用 Mockito 替代 Repository:

java
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
    @Mock
    private TaskRepository taskRepository;

    @InjectMocks
    private TaskService taskService;

    @Test
    void shouldRejectMissingTask() {
        when(taskRepository.findById(99L)).thenReturn(Optional.empty());

        assertThrows(
                ResourceNotFoundException.class,
                () -> taskService.findById(99L)
        );
    }
}

14. 运行与打包

开发运行:

bash
mvn spring-boot:run

测试并打包:

bash
mvn clean package

运行 JAR:

bash
java -jar target/todo-0.0.1-SNAPSHOT.jar

15. 第二阶段扩展任务

基础版本完成后,按顺序增加:

  1. 接入 MySQL,并使用 Flyway 建表。
  2. 添加任务优先级和标签。
  3. 增加标题关键词搜索。
  4. 禁止重复完成已经完成的任务,返回 409。
  5. 使用 Spring Security 增加登录和用户隔离。
  6. 添加 OpenAPI 接口文档。
  7. 使用 Docker Compose 启动应用和数据库。

一次只做一个扩展,并为新规则补充测试。

16. 完成标准

  • [ ] 所有接口能返回正确状态码。
  • [ ] 非法参数返回 400,找不到任务返回 404。
  • [ ] Controller 不直接访问 Repository。
  • [ ] 接口使用 DTO,而不是直接暴露 Entity。
  • [ ] Service 的写操作有明确事务边界。
  • [ ] mvn testmvn package 能成功执行。
  • [ ] 项目重启、日志查看和接口调试都能独立完成。