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: true4. 定义状态枚举
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) 会把 TODO、COMPLETED 保存为字符串。不要依赖默认序号保存枚举,否则调整枚举顺序可能破坏已有数据。
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,所以 update 和 complete 中不一定需要再次调用 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/112. 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.jar15. 第二阶段扩展任务
基础版本完成后,按顺序增加:
- 接入 MySQL,并使用 Flyway 建表。
- 添加任务优先级和标签。
- 增加标题关键词搜索。
- 禁止重复完成已经完成的任务,返回 409。
- 使用 Spring Security 增加登录和用户隔离。
- 添加 OpenAPI 接口文档。
- 使用 Docker Compose 启动应用和数据库。
一次只做一个扩展,并为新规则补充测试。
16. 完成标准
- [ ] 所有接口能返回正确状态码。
- [ ] 非法参数返回 400,找不到任务返回 404。
- [ ] Controller 不直接访问 Repository。
- [ ] 接口使用 DTO,而不是直接暴露 Entity。
- [ ] Service 的写操作有明确事务边界。
- [ ]
mvn test和mvn package能成功执行。 - [ ] 项目重启、日志查看和接口调试都能独立完成。