主题
界面
主题
《项目 A:数据库设计》定好三张表;本节把它们变成可调用的接口,并讲清三层各自的边界。
Controller → 只做协议转换(HTTP ↔ 方法参数)
Service → 业务规则 + 事务边界 ← ★ 真正的代码在这里
Repository → 只做 SQL 与结果映射★ 本节的接口全部可调用 ✓(labs/project-a,9 个接口测试全绿)
@RequestParam / @PathVariable 要显式写 name ★★★;先看本项目踩到的一个真实故障 ✗✗:
现象:GET /api/books 返回 500,但代码看起来完全没问题 ✗
报错:Name for argument of type [int] not specified ...
Ensure that the compiler uses the '-parameters' flag
原因:@RequestParam(defaultValue = "1") int page —— 没写 name,
而编译时没开 -parameters,Spring 拿不到参数名 ✗✗★ 这类"代码对但跑不通"的问题,只有真跑一遍才会发现 ✓✓
| 层 | 做什么 | 不做什么 |
|---|---|---|
| Controller ★★★ | 收参数、返响应、状态码 | 不写业务判断 ✗✗ |
| Service ★★★ | 业务规则 + 事务边界 | 不碰 HTTP、不拼 SQL ✓ |
| Repository ★★ | 拼 SQL、映射结果 | 不写业务规则 ✗✗ |
★★ 判断 Controller 是否写对了的标准 ✓✓:
"把 HTTP 换成 gRPC,这个类需要改多少?"
如果要改业务逻辑 → 说明业务漏到了 Controller 层 ✗
★ 本项目的实例 ✓:
@RestController
@RequestMapping("/api/books")
public class BookController {
@GetMapping
public BookService.Page<Book> search(
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "10") int size) {
// ★ 一行转发给 Service,没有任何判断 ✓✓
return bookService.search(keyword, page, size);
}
}★ 为什么不是 Controller ✗:
① Controller 可能有多次 Service 调用 → 事务范围会变大 ✗
② 事务应该在"业务操作"这一层,而不是"协议"这一层 ✓✓
③ Service 可能被定时任务 / 消息消费者复用 —— 那时也必须带事务 ✓✓★ 本项目的写法 ✓:
@Transactional
public BorrowRecord borrow(long bookId, String cardNo) {
// 校验(读者、上限、重复借)
// ★ 原子扣库存
if (!bookRepository.tryBorrowOne(bookId)) { throw new BusinessException("NO_AVAILABLE_COPY", ...); }
// 插借阅记录
return borrowRepository.findById(id).orElseThrow();
}★★ 顺序也很讲究 ✓✓:"先改记录、再改库存"还是"先改库存、再改记录"?
借书:先原子扣库存(成功才插记录)—— 扣库存失败就直接拒绝 ✓
还书:先标记记录(成功才加库存)—— 记录没改成就不动库存 ✓✓
共同点:两个写操作都在同一事务里,失败会一起回滚 ✓
| 方法 | 路径 | 说明 | 成功码 |
|---|---|---|---|
GET | /api/books?keyword=&page=&size= | 分页查询 | 200 |
GET | /api/books/{id} | 详情 | 200 |
POST | /api/books | 新增图书 | 201 + Location ✓ |
POST | /api/books/{id}/copies | 增加副本 | 200 |
POST | /api/borrows | 借书 | 201 ✓ |
POST | /api/borrows/{id}/return | 还书 | 200 |
POST | /api/borrows/{id}/renew | 续借 | 200 |
GET | /api/readers/{cardNo}/borrows | 借阅历史 | 200 |
GET | /api/borrows/overdue | 超期清单 | 200 |
★ 三条 REST 约定 ✓✓:
| 约定 | 例子 | 为什么 |
|---|---|---|
| 写操作用 POST/PUT/DELETE,不用 GET ★★★ | 借书用 POST /api/borrows | GET 会被浏览器/爬虫/预取顺手触发 ✗✗ |
| 创建成功返回 201 + Location ★★★ | Location: /api/books/1 | 调用方能拿到新资源地址 ✓ |
| 资源用名词复数 ★★ | /api/books 而不是 /api/getBooks | 可读性 + 一致性 ✓ |
★ 4xx 还是 5xx?判断依据只有一条 ✓✓:
| 类别 | 含义 | 本项目 |
|---|---|---|
| 4xx | 调用方的问题(参数错、业务不允许) | BusinessException → 409 Conflict ✓ |
| 5xx | 我们的问题(空指针、数据库挂了) | 兜底 → 500 ✓ |
★ 本项目的错误码 ✓:
| 错误码 | 场景 |
|---|---|
READER_NOT_FOUND / READER_SUSPENDED | 读者不存在 / 已停用 |
BORROW_LIMIT_REACHED | 已达借阅上限 |
ALREADY_BORROWED | 同一本书未还 |
NO_AVAILABLE_COPY | 库存不足 |
ALREADY_RETURNED | 重复归还(幂等保护) |
OVERDUE_CANNOT_RENEW / RENEW_LIMIT_REACHED | 超期不能续借 / 次数用完 |
DUPLICATE_ISBN | ISBN 重复 |
INVALID_ARGUMENT / MALFORMED_JSON | 参数/JSON 问题 |
INTERNAL_ERROR | 兜底 |
★★ 为什么错误码必须是"契约" ✓✓:
前端要能按码分支: ·
NO_AVAILABLE_COPY→ 提示"换一本" ·READER_SUSPENDED→ 提示"去前台激活"如果只返回一句中文,前端只能整段显示,体验差且无法适配 ✓
★ 响应体结构统一 ✓:
{
"code": "NO_AVAILABLE_COPY",
"message": "《设计模式》暂无可借副本(共 1 本)",
"timestamp": "2026-09-22T16:00:00"
}★ 本项目踩到的坑(前面提到过) ✗✗:
// ✗ 没写 name:Spring 要靠反射读参数名,而这需要编译时带 -parameters
@RequestParam(defaultValue = "1") int page
// ✓ 显式写 name:不依赖编译配置 ✓✓
@RequestParam(name = "page", defaultValue = "1") int page★ 报错信息 ✓:
java.lang.IllegalArgumentException:
Name for argument of type [int] not specified, and parameter name information
not available via reflection. Ensure that the compiler uses the '-parameters' flag.★★ 两条应对,建议都做 ✓✓:
| # | 做法 |
|---|---|
| ① | 显式写 name = "..."(最稳,不依赖编译配置)✓✓ |
| ② | 在 pom 里开 <parameters>true</parameters>(对其它场景也有用)✓ |
★ 本项目踩到的第二个坑 ✗✗:
现象:请求体是坏 JSON,期望 400,实际得到 500 ✗
原因:只写了一个 @ExceptionHandler(Exception.class) 的兜底,
它把「客户端错误」也当成了 500 ✗✗★ 修复 ✓:先处理所有已知类型,兜底放在最后 ✓✓:
@ExceptionHandler(BusinessException.class) → 409(业务)
@ExceptionHandler(HttpMessageNotReadableException.class) → 400(坏 JSON)
@ExceptionHandler(MethodArgumentNotValidException.class) → 400(参数校验)
@ExceptionHandler(Exception.class) → 500(兜底,记 error 日志)★★★ 两条硬要求 ✓✓:
| # | 要求 |
|---|---|
| ① | 5xx 的响应里不能带堆栈(那是内部信息泄露)✗✗ |
| ② | 5xx 必须记 error 日志(否则线上无从排查)✓✓ |
private static final int MAX_PAGE_SIZE = 100;
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);★ 不限制 size 的接口,等于把数据库的生死交给调用方 ✗✗ (?size=1000000 会拖垮数据库)
★ 项目的测试直接断言了这一点 ✓:pageSizeIsCapped
@PostMapping
public ResponseEntity<Book> create(@RequestBody CreateBookRequest request) {
Book created = bookService.create(request.isbn(), request.title(),
request.author(), request.category(), request.copies());
return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
}见 5.2 ✓
// ✗ 业务判断跑到了 Controller —— 换成 gRPC 时这段代码要重写
@GetMapping("/{id}")
public Book get(@PathVariable(name = "id") long id) {
Book book = repo.findById(id);
if (book == null) { throw new RuntimeException("不存在"); } // ✗
if (book.availableCopies() <= 0) { /* 又一段业务 */ } // ✗✗
return book;
}代码:
labs/project-a/src/main/java/cn/kb/learn/library/
├── web/ BookController、BorrowController、ApiExceptionHandler
├── service/ BookService、BorrowService、BusinessException
├── repository/ BookRepository、ReaderRepository、BorrowRepository
├── domain/ Book、Reader、BorrowRecord
└── config/ LibraryRules运行与调用:
cd labs
.\mvnw.cmd -pl project-a spring-boot:run
# 查询
curl "http://localhost:18080/api/books?keyword=Java&page=1&size=5"
# 借书
curl -X POST http://localhost:18080/api/borrows \
-H "Content-Type: application/json" -d '{"bookId":4,"cardNo":"R001"}'
# 还书
curl -X POST http://localhost:18080/api/borrows/1/return★ 实测 ✓:接口层 9 个测试全部通过 ✓✓
@RequestParam 的 name:观察 500(本节开头的那个坑)✗✗;HttpMessageNotReadableException 的 handler:观察坏 JSON 变成 500 ✗✗;?size=100000:观察被压到 100 ✓;@Transactional 从 Service 移到 Controller:思考会多出什么问题 ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | Controller 里写业务判断 ✗✗ | 分层失效 ✓✓ |
| 2 | 事务画在 Controller ✗ | 范围变大、复用时丢事务 ✓ |
| 3 | @RequestParam 不写 name ✗✗ | 缺 -parameters 就 500 ✓✓ |
| 4 | 兜底 handler 覆盖太宽 ✗✗ | 客户端错误被当成 500 ✓✓ |
| 5 | 5xx 响应里带堆栈 ✗✗ | 信息泄露 ✓ |
| 6 | 不限制分页 size ✗✗ | 数据库被拖垮 ✓ |
| 7 | 错误码不规范 ✗ | 前端无法分支处理 ✓ |
目标:亲手跑通全部接口,并验证错误处理。
步骤:
name,观察 500 并修复 ✓;验收方式:能说出"你的接口在哪些输入下会返回 4xx、哪些会 5xx"。
扩展任务:给借书接口加参数校验(用 Bean Validation):
cardNo 不能为空、格式必须是 R + 数字 ✓;bookId 必须 > 0 ✓;@GetMapping("/{id}")
public Book get(@PathVariable long id) { ... }这段代码在什么情况下会出问题?怎么改? 5. (设计)给"还书"设计接口:要考虑重复归还、记录不存在、已经是已还状态。
Exception.class 会吃掉一切 ✗ —— 如果把它写在前面/只写它,"坏 JSON""参数错"这类客户端错误会被当成 500 ✓✓★-parameters 时会 500(Spring 读不到参数名)✗ —— 改成 @PathVariable(name = "id") long id ✓✓POST /api/borrows/{id}/return(写操作用 POST)✓; 记录不存在 → 404 或 409 + BORROW_NOT_FOUND ✓; 已归还 → 409 + ALREADY_RETURNED,且库存不变 ✓✓; 关键是"标记归还"的 SQL 要带 status = 'BORROWED' 条件(幂等)✓《项目 A:前端页面实现》:一个静态 HTML 怎么把接口用起来。
暂无