Spec Kit 实战 03 · 图书管理系统实战:从需求到实现
预计阅读 34 分钟

Spec Kit 实战 03 · 图书管理系统实战:从需求到实现

上一章:安装与项目初始化 | 返回索引 | 下一章:个人博客增量需求实战

阅读指南 这是全书的主案例。从已经初始化的 library-system/ 出发,完整走过 Constitution、Specify、Clarify、Plan、Checklist、Tasks、Analyze、Implement、测试验收、Converge 和规格回溯,最后把交付的能力登记进 OpenSpec 的现行能力目录。章节多、工件多,但每一节都在回答同一个问题——怎么让“做完”有据可查。读的时候重点盯三样东西:错误码、并发语义、任务的文件路径。

【AI 产出】以下工件和代码是连贯示例,不是 GitHub 官方 golden output,也没有在本仓库实际运行成一个应用。 【人工决策】示例技术栈采用 Java 8、Spring Boot 2、MyBatis-Plus 3.4、MySQL、JUnit + MockMvc 与 Maven 多模块(api/service/web)的工程形态;Spec Kit 不默认推荐这些技术。

0. 固定案例契约

后续所有文档使用同一组标识,避免 Spec、Tasks 和测试各叫一套名字。

需求编号

ID需求
FR-001管理员录入图书,ISBN、书名、作者必填,ISBN 唯一,库存为非负整数
FR-002管理员编辑书名、作者和总库存;总库存不得小于当前借出数
FR-003按书名、作者或完整/部分 ISBN 搜索,返回总库存与可借数量
FR-004管理员为有效会员创建 14 天借阅记录
FR-005借书与库存扣减原子完成;并发竞争最后一本时只有一个请求成功
FR-006同一会员、同一书目最多一条 active loan
FR-007归还只能成功一次,并原子增加可借数量
FR-008借阅状态为 ACTIVEOVERDUERETURNEDdue_at == as_of 仍为 ACTIVE
FR-009会员可查看自己的借阅;管理员可查看指定时刻的全部逾期借阅
FR-010只有 LIBRARIAN 可录入/编辑图书及办理借还;MEMBER 只能查询图书和本人借阅
FR-011校验失败和冲突返回稳定错误码,不暴露内部异常
FR-012应用上下文提供现有用户身份;登录、注册和角色配置不在本期范围

成功标准

ID标准
SC-001P1 的图书维护、查询、角色边界、借书和还书场景都有自动化测试
SC-002两个请求竞争最后一本时恰好一个成功,库存不为负
SC-003新开发者按 quickstart.md 可在 10 分钟内完成一次借还验证

接口契约

本案例的接口惯例:控制器挂在 api/资源名 前缀下,动作用 /create/updateById/queryPage 这类动词后缀,查询分页一律 POST + 请求体:

方法与路径目的关联需求
POST /api/book/create录入图书FR-001、FR-010、FR-011
POST /api/book/updateById编辑图书与库存FR-002、FR-010、FR-011
POST /api/book/queryPage搜索和查看库存FR-003、FR-010
POST /api/loan/create管理员办理借书FR-004、FR-005、FR-006、FR-010、FR-011
POST /api/loan/returnById管理员办理还书FR-007、FR-010、FR-011
POST /api/loan/queryMemberPage查看本人借阅及计算状态FR-008、FR-009、FR-010
POST /api/loan/queryOverduePage管理员查看逾期FR-008、FR-009、FR-010

1. Constitution:先约束不可接受的实现

当前场景与目标

项目刚初始化,只有 Spec Kit 基础设施。团队需要先规定库存一致性、测试、依赖和错误边界,否则 agent 可能用"读库存再减一"的非原子逻辑,或者无理由引入队列和缓存。

开工前先把开发链摆正:需求简报和 Backlog 收敛业务判断,再调用 Spec Kit 展开规格、计划、任务和实现。简报和 Backlog 是团队自定义资产,不是 Spec Kit 的固定文件。

开始前的项目状态

TEXT
1library-system/
2├── .specify/
3│   └── memory/constitution.md
4└── .agents/skills/

先整理需求简报和 Backlog

这两份文档不来自任何 Spec Kit 命令,是团队自己动笔写的,也是整条流水线的源头。做法很朴素:把“做一个图书管理系统”背后的模糊想法摊开,收进一页简报;暂时不做又不甘心丢掉的想法,全部放进 Backlog,别让它们悄悄混进本期范围。

先建目录和两个空文件(内容在下面),随写随提交:

TEXT
1library-system/
2└── docs/requirements/
3    ├── library-mvp-brief.md    # 需求简报:一页纸的产品判断
4    └── backlog.md              # Backlog:暂不做的事项登记

【团队起草,AI 可代拟后人工修订】简报写成这样(内容怎么来的:产品把原始诉求聊清楚,工程把角色和边界补上):

Markdown
1# 图书管理 MVP 需求简报
2
3## 背景
4内部图书角目前靠一张共享表格登记借还:谁借了哪本、是否逾期,
5全靠人翻表格核对。本期做一个最小的管理系统替代它。
6
7## 本期目标
81. 管理员能录入和编辑图书(书名、作者、ISBN、库存)。
92. 管理员能按书名、作者或 ISBN 查书,看到库存。
103. 管理员能办理借书和还书,借期 14 天。
114. 会员能查看自己的借阅和逾期状态。
12
13## 角色
14- LIBRARIAN(图书管理员):维护书目、办理借还、查逾期。
15- MEMBER(普通会员):查书、看本人借阅。
16- 身份由应用上下文提供;登录、注册本期不做。
17
18## 明确不做
19细粒度权限、预约、罚款、采购、多分馆、通知。
20
21## 未知项(先记下,留给 Clarify 回答)
22- 库存按书目聚合还是按实体副本管理?
23- ISBN 采用什么校验规则?
24- 编辑总库存时,当前借出数怎么约束?
25- 逾期边界:到期当天算不算逾期?

Backlog 是一张登记表,每行注明来源,谁提议的就能追溯:

Markdown
1# Backlog
2
3| 想法 | 来源 | 状态 |
4|---|---|---|
5| 预约已借出的图书 | 产品提议 | 未排期 |
6| 逾期罚款 | 产品提议 | 未排期 |
7| 实体副本条码管理 | 工程提议 | 未排期 |
8| 到期提醒通知 | 产品提议 | 未排期 |
9| 登录与角色配置 | 架构决定 | 另立 feature |
10
11以上均未进入 001-library-circulation。将来启动时每项单独立项,
12不回头扩本期范围。

两份文件的作用要分清:简报写产品判断(做什么、为谁做、不做什么),不写技术方案,那是 plan 的事;简报里的“未知项”也不用现在回答,它们就是下一阶段 Clarify 的提问清单。这一步做完的预期结果:两个文件已提交,后续 /speckit.specify 的输入整段取自简报(见第 2 节)。

使用的命令与完整输入

TEXT
1/speckit.constitution
2为图书管理系统制定项目原则:
31. 借书、还书和库存更新必须在单个数据库事务中完成;任何路径都不得让
4   available_copies 小于 0 或大于 total_copies。
52. 业务规则必须有自动化测试,至少覆盖正常借还、无库存、重复借阅、
6   重复归还和最后一本的并发竞争。
73. API 错误必须返回稳定、机器可读的错误码,不暴露内部堆栈和 SQL。
84. 优先使用 JDK、Spring Boot 与父 POM 已管理的依赖;新增依赖必须在
9   plan.md 记录理由与替代方案。
105. 每个 feature 必须提供可重复执行的 quickstart、DDL 脚本与回滚说明。
116. 规格、计划、任务和实现必须一起评审;AI 生成内容不得自行批准。

生成的文件及关键内容

【官方能力】更新 .specify/memory/constitution.md

【AI 产出】关键原则示例:

Markdown
1# Library System Constitution
2
3## I. Transactional Circulation
4借书、还书与库存更新 MUST 在一个事务中完成(rollbackFor = Exception.class)。
5数据库与条件更新 MUST 共同保证:0 <= available_copies <= total_copies。
6
7## II. Testable Business Rules
8每条借还规则 MUST 有可执行测试;并发测试不得用两个顺序请求冒充。
9
10## III. Stable External Errors
11冲突 MUST 映射为 CodeEnum 稳定错误码;内部异常、SQL 和堆栈不得进入响应。
12
13## IV. Minimal Dependencies
14新增依赖 MUST 在 plan.md 记录 Decision、Rationale、Alternatives。
15
16## Governance
17Constitution 高于 feature plan。修改需人工评审、版本说明和迁移影响。

开发者需要人工确认

  • 这些原则是否是团队愿意长期执行的项目规则,而不是本功能临时要求;
  • 目标 MySQL 的隔离级别(默认 REPEATABLE READ)下条件更新的行为是否满足库存语义,本地与生产版本差异要写进 research;
  • "测试先行"是严格 TDD,还是只要求实现合并前有测试,不要让文本产生假承诺。

执行后的项目状态

TEXT
1docs/requirements/library-mvp-brief.md   # 团队起草,已提交
2docs/requirements/backlog.md             # 已提交,含五项未排期想法
3.specify/memory/constitution.md          # 已由团队审查

常见错误、原因和修正

  • 错误:"代码高质量、性能优秀。"原因:无法检查。修正:写库存不变量、稳定错误码和具体测试边界。
  • 错误: 把"借期 14 天"写进 constitution。原因:它是业务需求,不是全项目原则。修正:放入 spec.md
  • 错误: 每个 feature 都重写 constitution。原因:治理规则失去稳定性。修正:仅在项目原则变化时更新。

本阶段完成检查清单

  • 原则可通过代码、测试或评审动作检查。
  • 没有把特性需求误写成全局原则。
  • 团队已人工批准,而不是接受 AI 的自评结果。

2. Specify:把需求写成可独立验收的故事

当前场景与目标

Constitution 已建立,但"图书管理系统"仍太宽。现在只定义 what/why、用户故事、边界、功能需求和成功标准,不决定框架或数据库。

开始前的项目状态

TEXT
1.specify/memory/constitution.md  # 已批准
2specs/                           # 尚无本 feature

使用的命令与完整输入

TEXT
1/speckit.specify
2根据 docs/requirements/library-mvp-brief.md 创建图书管理 MVP 的规格。
3
4角色:LIBRARIAN 和 MEMBER。身份由应用上下文提供,本期不实现登录、注册和角色配置。
5
6P1:LIBRARIAN 可以录入和编辑图书。ISBN、书名、作者必填;ISBN 唯一;
7库存为非负整数;总库存不能改到小于当前借出数。
8P1:LIBRARIAN 和 MEMBER 都可以按书名、作者或完整/部分 ISBN 查询图书,
9并看到总库存与可借数量。
10P1:LIBRARIAN 为有效会员办理借书和归还。借期是 borrowed_at 后 14 天;
11最后一本的并发借阅只允许一个成功;同一会员不能同时借同一书目两次;
12重复归还不能再次增加库存。
13P1:MEMBER 可以查看自己的借阅及 ACTIVE、OVERDUE、RETURNED 状态,不能查看他人记录。
14P2:LIBRARIAN 可以查看指定时刻所有逾期且未归还的借阅。
15
16成功标准:P1 的图书维护、查询、角色边界和借还场景有自动化测试;
17两个请求竞争最后一本时只允许一个成功;新开发者能按 quickstart
18在 10 分钟内完成录书、查询、借书、查看状态和归还。
19
20不做细粒度权限配置、实体副本条码、预约、罚款、采购、多分馆和通知。
21请只描述用户目标、业务规则、边界和可测结果,不选择技术栈。

生成的文件及关键内容

目录编号由仓库状态决定,此处假设是:

TEXT
1specs/001-library-circulation/
2├── spec.md
3└── checklists/requirements.md

【AI 产出】spec.md 应至少包含:

Markdown
1# Feature Specification: Library Circulation MVP
2
3## User Story 1 - Maintain catalog (Priority: P1)
4LIBRARIAN 录入和编辑图书。
5
6**Acceptance Scenarios**:
71. Given 合法 ISBN、非空书名/作者、total_copies=3,When 录入,Then 返回新图书且 available_copies=3。
82. Given ISBN 已存在,When 再次录入,Then 返回 ISBN_ALREADY_EXISTS。
93. Given 当前借出 2 本,When 把 total_copies 改为 1,Then 返回 TOTAL_BELOW_BORROWED,库存不变。
104. Given MEMBER 身份,When 录入或编辑图书,Then 返回 FORBIDDEN。
11
12## User Story 2 - Search books (Priority: P1)
13LIBRARIAN 和 MEMBER 按书名、作者或 ISBN 查询,并看到总库存和可借数。
14
15**Independent Test**: 录入三本图书,按部分书名和部分 ISBN 查询;无匹配时返回空列表。
16
17## User Story 3 - Borrow and return (Priority: P1)
181. Given 有库存且会员无相同 active loan,When LIBRARIAN 办理借书,
19   Then due_at=borrowed_at+14 days、available_copies 减 1。
202. Given available_copies=0,When 借书,Then 返回 BOOK_UNAVAILABLE。
213. Given 相同 active loan,When 再借,Then 返回 ACTIVE_LOAN_EXISTS。
224. Given 借阅未归还,When 归还,Then returned_at 被记录且库存加 1。
235. Given 借阅已归还,When 再次归还,Then 返回 LOAN_ALREADY_RETURNED 且库存不变。
24
25## User Story 4 - View loan status (Priority: P1)
26MEMBER 只能查看自己的借阅。as_of 时刻下:returned_at 非空为 RETURNED;
27未归还且 due_at < as_of 为 OVERDUE;其余为 ACTIVE。
28
29## User Story 5 - View all overdue loans (Priority: P2)
30LIBRARIAN 查看指定时刻全部 OVERDUE 借阅;MEMBER 请求该列表返回 FORBIDDEN。
31
32## Functional Requirements
33- FR-001: LIBRARIAN 可录入合法图书,ISBN、书名、作者必填,ISBN 唯一。
34- FR-002: LIBRARIAN 可编辑书名、作者和库存,总库存不得小于当前借出数。
35- FR-003: LIBRARIAN 和 MEMBER 可按书名、作者或 ISBN 搜索并看到库存。
36- FR-004: LIBRARIAN 可为有效会员创建借期 14 天的借阅。
37- FR-005: 借书与库存扣减原子完成,最后一本并发竞争恰好一个成功。
38- FR-006: 同一会员、同一书目最多一条 active loan。
39- FR-007: 归还只成功一次并原子增加库存。
40- FR-008: 系统按 as_of 派生 ACTIVE、OVERDUE、RETURNED 状态。
41- FR-009: MEMBER 只能查看本人借阅,LIBRARIAN 可查看全部逾期。
42- FR-010: 只有 LIBRARIAN 可维护图书和办理借还。
43- FR-011: 校验失败和冲突返回稳定错误码,不暴露内部异常。
44- FR-012: 身份由受信任应用上下文提供,本期不实现登录、注册和角色配置。
45
46## Success Criteria
47- SC-001: P1 的图书维护、查询、角色边界、借书和还书场景都有自动化测试。
48- SC-002: 两个请求竞争最后一本时恰好一个成功,库存不为负。
49- SC-003: 新开发者按 quickstart 可在 10 分钟内完成录书、查询、借书、查看状态和归还。
50
51## Out of Scope
52登录、注册、细粒度权限配置、实体副本条码、预约、罚款、采购、多分馆、通知。

spec 里的 ISBN_ALREADY_EXISTSBOOK_UNAVAILABLE 等语义名是业务层契约名。实现时它们落到 CodeEnum 的七位数字错误码上,映射关系在 Plan 阶段固定(见第 4 节)。

开发者需要人工确认

  • P1 是否真的能独立交付价值;
  • LIBRARIAN/MEMBER 权限是否足够,身份从哪里进入应用;
  • ISBN 校验采用什么业务规则,编辑总库存时怎样计算当前借出数;
  • SC-003 的"10 分钟"从什么环境和什么起点测量;
  • 错误码是外部契约还是仅示例;一旦确认,后续 plan/tasks/tests 必须一致;
  • out-of-scope 是否覆盖最可能膨胀的功能。

执行后的项目状态

TEXT
1specs/001-library-circulation/spec.md
2specs/001-library-circulation/checklists/requirements.md
3.specify/feature.json              # 指向当前 feature,通常机器本地状态

常见错误、原因和修正

  • 错误: 在 spec 写"使用 MyBatis-Plus/Redis"。原因:把 how 混进 what。修正:移到 plan。
  • 错误: 只有正常借还。原因:AI 会猜重复和并发语义。修正:写成 Given/When/Then。
  • 错误: 所有故事都是 P1。原因:没有可裁剪 MVP。修正:逾期列表为 P2,借还和搜索为 P1。

需求简报和 SPEC 是两层东西。简报保留目标、范围和未知项,是产品判断的载体;SPEC 再把它们写成用户故事、规则和验收场景,是开发与验收之间的约定。会议纪要或简报不能原样替代 spec.md,反过来也一样。

本阶段完成检查清单

  • FR-001..FR-012 各自可验证且没有技术实现词替代业务结果。
  • 每个 P1 故事可以独立测试和演示。
  • SC-001..SC-003 有明确测量方式。
  • Out of Scope 已由产品和工程共同确认。

3. Clarify:在数据模型形成前消除歧义

当前场景与目标

Spec 看似完整,但库存粒度、ISBN 校验、身份来源、总库存编辑和 14 天的算法都会改变 Plan。

开始前的项目状态

spec.md 有用户故事和 FR,但尚未进入技术设计。

使用的命令与完整输入

TEXT
1/speckit.clarify
2重点检查库存模型、ISBN 与字段校验、LIBRARIAN/MEMBER 的身份来源和权限、
3总库存编辑、14 天的时间语义、同书重复借阅、最后一本并发竞争、
4重复归还和借阅状态边界。只问会改变需求、接口或数据模型的问题。

AI 提问与人工回答

【官方能力】一次运行最多提出 5 个针对性问题;需要时可再次运行并换关注域。

问题【人工决策】答案回写位置
库存按实体副本还是书目聚合?MVP 用 total_copies/available_copies 聚合,不建副本条码FR-001、FR-002、Assumptions
ISBN 和文本怎样校验?ISBN 去连字符后校验 ISBN-10/13;书名/作者 trim 后 1..200 字符FR-001、FR-011
身份和角色从哪里来?由受信任应用上下文提供在线账号;公开认证不在本期FR-010、FR-012
总库存能否低于借出数?不能;返回 TOTAL_BELOW_BORROWED,数据不变FR-002、FR-011
due_at == as_of 是否逾期?不逾期;只有 due_at < as_of 才为 OVERDUEFR-008、FR-009

第二轮继续确认借期和并发:due_at = borrowed_at + 14 days;同一会员同一书目只允许一条 active loan;最后一本并发竞争恰好一个成功。会员只需存在,不检查欠费或逾期资格。

生成的文件及关键内容

【AI 产出】答案应直接写回 spec.md,而不是只留在聊天:

Markdown
1### Clarifications
2- 2026-09-09: MVP 按书目聚合库存,不跟踪实体副本。
3- 2026-09-09: ISBN 去连字符后校验 ISBN-10/13;书名和作者长度为 1..200。
4- 2026-09-09: 身份由受信任应用上下文提供;本期不实现认证。
5- 2026-09-09: total_copies 不得小于 total_copies - available_copies。
6- 2026-09-09: 同一会员和书目最多一条 active loan。
7- 2026-09-09: 时间存库统一 UTC 口径,due_at = borrowed_at + 14 days。
8- 2026-09-09: returned_at 非空为 RETURNED;否则 due_at < as_of 为 OVERDUE,其余 ACTIVE。
9
10### Assumptions
11- 在线账号由应用上下文提供;角色只有 LIBRARIAN 和 MEMBER。
12- 借书只验证目标会员存在,不检查欠费或逾期资格。

开发者需要人工确认

逐行检查 spec diff,确认答案没有被 AI 改写成不同语义;尤其核对 <<=、UTC、重复 active loan 和库存粒度。

执行后的项目状态

spec.md 已包含足够的信息来决定数据模型和接口,不再有影响 plan 的隐藏假设。

常见错误、原因和修正

  • 错误: 回答"按最佳实践"。原因:没有做业务决定。修正:明确 <、UTC、唯一性等语义。
  • 错误: 答案只在聊天。原因:下一会话无法恢复。修正:确认已回写 spec.md
  • 错误: 用 Clarify 选择 ORM。原因:这是技术问题。修正:留给 Plan/Research。

本阶段完成检查清单

  • 所有会改变数据模型的歧义已回答。
  • 回答已写入 spec.md
  • FR-001、FR-002、FR-008、FR-010、FR-011、FR-012 已同步修订。
  • 没有用技术方案掩盖业务决策。

4. Plan:把规格映射到技术方案

当前场景与目标

需求已稳定,现在需要决定技术栈、数据约束、事务策略、API、DDL、测试和目录。

开始前的项目状态

TEXT
1spec.md            # what/why 已澄清
2constitution.md    # 项目 gate 已批准

先写一页 Planning Brief

Plan 前把已经确定的技术边界和待调研项分开,避免它们散落在多轮提示词里。这页 planning-brief.md 是团队自定义输入,Spec Kit 不会自动生成或发现它:

Markdown
1## 已确定
2- Java 8、Spring Boot 2、MyBatis-Plus 3.4、MySQL;沿用公司父 POM 管理依赖。
3- Maven 多模块:library-api(对外契约)、library-service(业务核心)、library-web(HTTP 入口)。
4- 身份由应用上下文提供在线账号,不实现认证。
5- 接口沿用本章固定契约(api/book、api/loan 动词式端点)。
6- 不引入缓存、消息队列、微服务拆分或额外的 repository 包装层(Mapper 已承担该职责)。
7
8## 待调研
9- MySQL 5.x 不执行列级 CHECK 约束,库存不变量由条件 UPDATE 与服务层校验共同保证。
10- MySQL 没有 partial unique index,"同一会员同一书目仅一条 active loan"需要等价实现。
11- ISBN-10/13 校验没有现成注解,决定自写最小校验还是引入工具库。
12
13## 交付和验证
14- 接口、DDL 脚本(向前/回滚)、自动化测试、quickstart、需求追溯表。
15- 当前开发完成不等于已部署。

这一步适合技术边界已经讨论过、但 Plan 仍有研究项的功能。小而明确的 feature 不必额外维护该文件。

使用的命令与完整输入

TEXT
1/speckit.plan
2使用 Java 8、Spring Boot 2、MyBatis-Plus 3.4、MySQL,Maven 多模块
3(library-api / library-service / library-web),统一工程风格:
4ResultDTO 统一响应、CodeEnum 七位错误码、Request/DTO/VO/Entity 四层模型、
5校验分组、knife4j 接口文档。research.md 必须记录 MySQL 5.x 的约束差异
6与并发条件更新的验证边界。
7
8接口契约(POST + 请求体,分页用 PageDTO,页码从 0 开始):
9- POST /api/book/create
10- POST /api/book/updateById
11- POST /api/book/queryPage
12- POST /api/loan/create
13- POST /api/loan/returnById
14- POST /api/loan/queryMemberPage
15- POST /api/loan/queryOverduePage
16
17身份由应用上下文提供在线账号(id、角色);LIBRARIAN 才能维护图书和办理借还,
18MEMBER 只能查询图书和本人借阅。ISBN、文本长度、库存和角色错误都要有稳定错误码
19(CodeEnum,业务错误 HTTP 状态码统一 200,靠 code 区分)。
20借书使用带 available_copies > 0 条件的原子 UPDATE,并在同一事务插入 Loan;
21"同一 member_id/book_id 仅一条 active loan"用冗余 active_flag 列 + 唯一索引
22(MySQL 无 partial unique index 的等价方案,归还时置 NULL)兜底并发。
23归还只允许从 active 状态原子迁移一次。所有时间按 UTC 口径,状态按 as_of 派生。
24
25测试使用 JUnit + spring-boot-starter-test(MockMvc);父 POM 默认 skipTests=true,
26测试命令必须显式 -DskipTests=false;并发测试必须真实并发,不能用顺序请求代替。
27输出 research.md、data-model.md、contracts/openapi.yaml、quickstart.md、
28向前/回滚 DDL 方案、真实项目目录与 Constitution Check。

生成的文件

TEXT
1specs/001-library-circulation/
2├── spec.md
3├── plan.md
4├── research.md
5├── data-model.md
6├── contracts/openapi.yaml
7├── quickstart.md
8└── checklists/requirements.md

plan.md 关键内容

【AI 产出】

Markdown
1## Technical Context
2- Runtime: Java 8(maven.compiler.source/target 1.8)
3- Framework: Spring Boot 2(继承公司父 POM)
4- Persistence: MyBatis-Plus 3.4 + MySQL(Druid 连接池)
5- Test: JUnit + spring-boot-starter-test(MockMvc;父 POM 默认 skipTests,命令需 -DskipTests=false)
6- Docs: knife4j(Swagger 2 注解)
7
8## Constitution Check
9- Transactional Circulation: PASS,借还各使用单事务和条件更新。
10- Testable Business Rules: PASS,FR-001..FR-012 映射到 integration tests。
11- Stable External Errors: PASS,业务错误统一 ResultDTO + CodeEnum,HTTP 200。
12- Minimal Dependencies: PASS,未引入队列、缓存或额外 repository 抽象。
13
14## Project Structure
15library-system/                          # Maven 父工程(packaging=pom)
16├── library-api/                         # 对外契约:ResultDTO/PageDTO/CodeEnum
17├── library-service/
18│   └── src/main/java/com/example/library/
19│       ├── entity/                      # BookEntity、LoanEntity(继承 BaseEntity)
20│       ├── mapper/                      # BookMapper、LoanMapper(含 XML)
21│       ├── dto/                         # BookDTO、LoanDTO、LoanCreateDTO 等
22│       ├── enums/                       # RoleEnum、LoanStatusEnum(派生状态)
23│       └── service/                     # BookService、LoanService + impl/
24├── library-web/
25│   └── src/main/java/com/example/library/
26│       ├── controller/                  # BookController、LoanController
27│       ├── request/                     # BookSaveRequest(校验分组)、LoanCreateRequest 等
28│       ├── request/validator/group/     # BookCreateValidationGroup 等
29│       ├── vo/                          # BookVO、LoanVO
30│       └── advice/                      # GlobalExceptionAdvice、GlobalResultAdvice
31├── library-web/src/main/resources/
32│   ├── application.yml + application-local.yml
33│   └── mapper/book/BookMapper.xml、mapper/loan/LoanMapper.xml
34├── docs/ddl/V001__library_schema.sql    # 向前 DDL
35├── docs/ddl/U001__library_schema_rollback.sql
36└── library-web/src/test/java/com/example/library/
37    ├── BookCatalogTest.java
38    ├── BookSearchTest.java
39    ├── RoleBoundaryTest.java
40    ├── CirculationTest.java
41    └── OverdueTest.java

data-model.md 关键内容

Markdown
1在线账号(User)
2- 不落库。由应用上下文提供 id(Long)与角色(RoleEnum: LIBRARIAN | MEMBER),
3  controller 层取值后显式传给 service。
4
5book
6- id: BIGINT, PK, AUTO_INCREMENT
7- isbn: VARCHAR(20), required, unique(uk_isbn)
8- title: VARCHAR(200), required
9- author: VARCHAR(200), required
10- total_copies: INT UNSIGNED, required
11- available_copies: INT UNSIGNED, required
12- create_time / update_time / is_delete(逻辑删除,沿用 BaseEntity)
13
14loan
15- id: BIGINT, PK, AUTO_INCREMENT
16- book_id: BIGINT, required
17- member_id: BIGINT, required(在线账号 ID)
18- member_name: VARCHAR(64)(回显用)
19- borrowed_at: DATETIME, required(UTC 口径)
20- due_at: DATETIME, required
21- returned_at: DATETIME, nullable
22- active_flag: TINYINT, nullable——在借时为 1,归还后置 NULL
23- create_time / update_time / is_delete
24
25Invariant: 同一 member_id + book_id 最多一条 active 记录。
26MySQL 无 partial unique index,等价实现为唯一索引
27uk_member_book_active(member_id, book_id, active_flag)——NULL 在唯一索引中可重复。
28Derived status(as_of): returned_at != NULL -> RETURNED; due_at < as_of -> OVERDUE; otherwise ACTIVE。

【人工决策】MySQL 5.x 解析但忽略列级 CHECK 约束,0 <= available_copies <= total_copies 不能只写在 DDL 里。research.md 必须记录:不变量由条件 UPDATE(WHERE available_copies > 0)+ 服务层校验共同保证,INT UNSIGNED 再兜一层负值防线。

上面的模型落到 T004 要交付的 docs/ddl/V001__library_schema.sql(节选,字段与 data-model 一一对应):

SQL
1CREATE TABLE `book` (
2    `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
3    `isbn`             VARCHAR(20)     NOT NULL,
4    `title`            VARCHAR(200)    NOT NULL,
5    `author`           VARCHAR(200)    NOT NULL,
6    `total_copies`     INT UNSIGNED    NOT NULL,
7    `available_copies` INT UNSIGNED    NOT NULL,
8    `create_time`      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
9    `update_time`      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
10    `is_delete`        TINYINT         NOT NULL DEFAULT 0,
11    PRIMARY KEY (`id`),
12    UNIQUE KEY `uk_isbn` (`isbn`)
13) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
14
15CREATE TABLE `loan` (
16    `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
17    `book_id`     BIGINT UNSIGNED NOT NULL,
18    `member_id`   BIGINT UNSIGNED NOT NULL,
19    `member_name` VARCHAR(64)     DEFAULT NULL,
20    `borrowed_at` DATETIME        NOT NULL,
21    `due_at`      DATETIME        NOT NULL,
22    `returned_at` DATETIME        DEFAULT NULL,
23    `active_flag` TINYINT         DEFAULT NULL COMMENT '在借=1,归还后置 NULL',
24    -- create_time / update_time / is_delete 同 book 表
25    PRIMARY KEY (`id`),
26    UNIQUE KEY `uk_member_book_active` (`member_id`, `book_id`, `active_flag`),
27    KEY `idx_member_borrowed` (`member_id`, `borrowed_at`)
28) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

回滚脚本 U001__library_schema_rollback.sql 做反向动作(DROP TABLE),quickstart 里会先升后降再升各跑一遍验证。旁边的 seed_demo.sql 插两条图书、一名演示账号和一笔记借阅——quickstart 的 10 分钟验证拿它当现成数据。

contracts/openapi.yaml 关键内容

响应统一包在 ResultDTO(code/message/success/model)里,业务错误 HTTP 状态码为 200:

YAML
1paths:
2  /api/book/create:
3    post:
4      responses:
5        "200":
6          description: 录入成功或返回参数/权限/重复错误码
7  /api/book/updateById:
8    post:
9      responses:
10        "200":
11          description: 编辑成功或 TOTAL_BELOW_BORROWED 等错误码
12  /api/book/queryPage:
13    post:
14      description: 关键词匹配书名/作者/ISBN,PageDTO 分页(页码从 0 开始)
15  /api/loan/create:
16    post:
17      responses:
18        "200":
19          description: 借阅成功或 BOOK_UNAVAILABLE / ACTIVE_LOAN_EXISTS
20  /api/loan/returnById:
21    post:
22      responses:
23        "200":
24          description: 归还成功或 LOAN_ALREADY_RETURNED
25  /api/loan/queryMemberPage:
26    post:
27      description: 会员本人借阅列表(含按 as_of 派生状态)
28  /api/loan/queryOverduePage:
29    post:
30      description: 指定时刻全部逾期借阅(仅 LIBRARIAN)

错误码和角色边界

错误码遵循七位数字约定:模块标识(2 位)+ 错误来源(1 位,1 用户/2 系统/3 第三方)+ 错误标识(4 位)。假设图书模块编号为 30,spec 语义名与 CodeEnum 映射如下:

spec 语义名CodeEnumcodedescription
INVALID_ISBNLIBRARY_USER_ERROR_01013010101ISBN 格式非法
INVALID_BOOK_FIELDLIBRARY_USER_ERROR_01003010100图书参数非法
FORBIDDENLIBRARY_USER_ERROR_01023010102无权限执行该操作
BOOK_NOT_FOUNDLIBRARY_USER_ERROR_02003010200图书不存在
LOAN_NOT_FOUNDLIBRARY_USER_ERROR_02013010201借阅记录不存在
ISBN_ALREADY_EXISTSLIBRARY_USER_ERROR_03003010300ISBN 已存在
TOTAL_BELOW_BORROWEDLIBRARY_USER_ERROR_03013010301总库存不得小于当前借出数
BOOK_UNAVAILABLELIBRARY_USER_ERROR_04003010400图书无可借库存
ACTIVE_LOAN_EXISTSLIBRARY_USER_ERROR_04013010401该会员已有同书在借记录
LOAN_ALREADY_RETURNEDLIBRARY_USER_ERROR_04023010402借阅记录已归还

【AI 产出】这些错误码是实现契约的示例,功能间步长预留 100。MEMBER 请求 /api/book/create/api/book/updateById/api/loan/create/api/loan/returnById 都返回 FORBIDDENLIBRARIAN 才能执行管理操作。

有一处差异要明确写进契约,不能含糊:本案例的全局异常处理器约定业务错误 HTTP 状态码一律 200,客户端以 ResultDTO.code 判断结果。错误响应统一长这样:

JSON
1{
2  "code": 3010400,
3  "message": "图书无可借库存",
4  "success": false,
5  "model": null
6}

message 可以本地化,code 作为稳定契约。内部 SQL、堆栈和数据库地址不进入响应。

research.md 决策格式

Markdown
1## Decision: 条件 UPDATE 扣减库存
2Rationale: 单条 SQL(UPDATE book SET available_copies = available_copies - 1
3WHERE id = ? AND available_copies > 0)将可借条件与扣减绑定,行锁天然串行化
4最后一本竞争;返回影响行数 0 即视为 BOOK_UNAVAILABLE。
5Alternatives: 悲观锁 SELECT ... FOR UPDATE;应用层全局锁。
6MySQL 行为需在目标版本实测(本地与生产隔离级别差异写入测试边界)。
7
8## Decision: active_flag 冗余列 + 唯一索引阻止重复在借
9Rationale: MySQL 无 partial unique index;active_flag 在借为 1、归还置 NULL,
10uk_member_book_active 利用唯一索引忽略 NULL 的语义实现"仅一条 active",
11并发下靠 DuplicateKeyException 兜底并翻译为业务错误码。
12Alternatives: 归还后删除记录(丢失历史);应用层串行检查(无法兜住并发)。
13
14## Decision: 不引入任务队列和缓存
15Rationale: 当前需求是同步借还和查询;新增基础设施没有明确收益。
16Alternatives: Redis 队列;事件驱动库存。达到跨服务吞吐需求时再评估。

quickstart.md 应包含

Bash
1mvn clean install -DskipTests=false          # 父 POM 默认 skipTests=true,必须显式打开
2mysql -u root -e "CREATE DATABASE IF NOT EXISTS library DEFAULT CHARACTER SET utf8mb4;"
3mysql -u root library < docs/ddl/V001__library_schema.sql
4mysql -u root library < docs/ddl/seed_demo.sql
5mvn --projects library-web spring-boot:run -Dspring-boot.run.profiles=local
6mvn --projects library-web test -DskipTests=false

并给出录入、非法 ISBN、越权编辑、查询、借书、查看本人状态、还书和逾期查询的请求与期望的 ResultDTO.code

quickstart.md 在 Plan 阶段只是"拟定验收方法"。此时没有代码可供验证。真正的运行证据要等 Implement 完成后再记录,不能因为 quickstart 写出来了就宣称验收通过。

开发者需要人工确认

  • 技术栈是团队批准的,不是 AI 随机选择;
  • 条件更新和唯一约束确实能在目标 MySQL 版本工作;
  • 回滚 DDL 不会丢失已有数据;
  • 契约文档中错误码、字段名与 FR 完全一致;
  • Plan 没有加入 out-of-scope 的登录、通知或前端;
  • HTTP 200 + code 的错误约定已同步给调用方确认。

执行后的项目状态

Spec 负责业务契约,Plan 与四类设计工件负责实现契约,尚未生成 tasks.md 或代码。

常见错误、原因和修正

  • 错误: Plan 只输入 "Execute"。原因:agent 会自由选择架构。修正:给技术边界、复用点、DDL 和测试。
  • 错误: plan.md 引用不存在目录。原因:没读仓库。修正:要求先探索并引用真实路径。
  • 错误: 把本地 MySQL 测试当生产并发证明。修正:记录版本与隔离级别差异,在目标环境验证。

本阶段完成检查清单

  • FR-001..FR-012 都能映射到数据、接口或测试设计。
  • 接口路径与本章固定契约一致。
  • 数据约束、事务、DDL 和回滚已人工评审。
  • research.md 有 Decision/Rationale/Alternatives,而不是搜索结果堆积。

5. Checklist:先检查需求质量

当前场景与目标

拆任务前确认需求是否完整、清晰、一致。Checklist 检查的是需求文本,不是代码完成度。

使用的命令与完整输入

TEXT
1/speckit.checklist
2重点检查 FR-001..FR-012 的字段校验、角色边界、事务、库存不变量、重复操作、并发竞争、
3UTC 时间语义、稳定错误码,以及 SC-001..SC-003 是否可验证。

生成内容

【AI 产出,评审者所有】

Markdown
1- [ ] 是否定义了同一会员重复 active loan 的结果?
2- [ ] 是否定义了最后一本并发竞争的唯一结果?
3- [ ] 是否明确 due_at == as_of 不算逾期?
4- [ ] 是否说明重复归还不得增加库存?
5- [ ] 是否为每种冲突定义稳定错误码?
6- [ ] 是否说明 SC-003 的计时起点和环境?

【人工决策】逐条审查,发现缺口就回 /speckit.clarify/speckit.specify。只有评审者确认后才勾选。/speckit.implement 不应替评审者修改这些 checkbox。

执行后的项目状态

需求质量 gate 已通过,可以从已批准工件生成任务。

本阶段完成检查清单

  • 每个 [x] 都有人工判断依据。
  • 缺口已修回 spec.md,不是在 checklist 里补一句就算完成。
  • Checklist checkbox 没有被当成实施进度。

6. Tasks:生成可执行、可追溯的工作包

当前场景与目标

需要把工件拆成有依赖、含文件路径、按用户故事组织的任务。字段约束与错误码不能在这一层丢失。

使用的命令

TEXT
1/speckit.tasks

生成的 tasks.md

【AI 产出,人工修订后作为实施基线】

Markdown
1## Phase 1: Setup
2- [ ] T001 创建父 pom 与 library-api/library-service/library-web 模块骨架,继承公司父 POM,锁定 Spring Boot 2、MyBatis-Plus 3.4
3- [ ] T002 [P] 建立 service 模块包结构(entity/mapper/dto/enums/service)与 web 模块包结构(controller/request/vo/advice)及测试目录
4
5## Phase 2: Foundational
6- [ ] T003 定义 BookEntity/LoanEntity(继承 BaseEntity,含 active_flag)、RoleEnum/LoanStatusEnum 及全部字段约束 library-service/src/main/java/com/example/library/entity
7- [ ] T004 创建向前 DDL docs/ddl/V001__library_schema.sql 与回滚 docs/ddl/U001__library_schema_rollback.sql(含 uk_member_book_active)
8- [ ] T005 [P] 新增 CodeEnum 图书模块错误码(七位编码)与 GlobalExceptionAdvice/GlobalResultAdvice library-api、library-web/.../advice
9
10## Phase 3: US1 Maintain catalog (P1)
11- [ ] T006 [P] [US1] 编写 FR-001/FR-002/FR-010/FR-011 录入、编辑、非法 ISBN、库存下限和越权测试 library-web/src/test/java/com/example/library/BookCatalogTest.java
12- [ ] T007 [US1] 实现 BookSaveRequest(校验分组)、BookController 的 /api/book/create 与 /api/book/updateById 及 BookService 实现 library-web/.../controller、library-service/.../service/impl
13
14## Phase 4: US2 Search books (P1)
15- [ ] T008 [P] [US2] 编写 FR-003 搜索、空结果、角色可见性测试 library-web/src/test/java/com/example/library/BookSearchTest.java
16- [ ] T009 [US2] 实现 BookQueryRequest 与 /api/book/queryPage(PageDTO 分页,页码从 0 开始) library-web/.../request、library-web/.../controller
17
18## Phase 5: US3 Borrow and return (P1)
19- [ ] T010 [P] [US3] 编写 FR-004/FR-005/FR-006 借书测试 library-web/src/test/java/com/example/library/CirculationTest.java
20- [ ] T011 [US3] 编写 SC-002 真实并发测试(两个线程经 CountDownLatch 同时调用借书) library-web/src/test/java/com/example/library/CirculationTest.java
21- [ ] T012 [US3] 实现条件库存扣减 UPDATE 与借阅事务 BookMapper.xml、library-service/.../service/impl/LoanServiceImpl.java
22- [ ] T013 [US3] 实现 LoanCreateRequest 与 /api/loan/create library-web/.../controller
23- [ ] T014 [US3] 编写 FR-007 重复归还测试 library-web/src/test/java/com/example/library/CirculationTest.java
24- [ ] T015 [US3] 实现归还服务(条件迁移 returned_at/active_flag)与 /api/loan/returnById,修改 library-service/.../service/impl/LoanServiceImpl.java 和 library-web/.../controller/LoanController.java
25
26## Phase 6: US4 View loan status (P1)
27- [ ] T016 [P] [US4] 编写 FR-008/FR-009 状态边界和 MEMBER 隔离测试 library-web/src/test/java/com/example/library/RoleBoundaryTest.java
28- [ ] T017 [US4] 实现 /api/loan/queryMemberPage(as_of 派生状态) library-web/.../controller、library-service/.../service/impl
29
30## Phase 7: US5 Overdue loans (P2)
31- [ ] T018 [P] [US5] 编写 FR-008/FR-009 due_at < as_of 和角色测试 library-web/src/test/java/com/example/library/OverdueTest.java
32- [ ] T019 [US5] 实现 /api/loan/queryOverduePage library-web/.../controller、LoanMapper.xml
33
34## Final Phase: Polish
35- [ ] T020 运行完整测试(-DskipTests=false)、DDL 升级/回滚及契约检查(knife4j 导出与 contracts/openapi.yaml 比对)
36- [ ] T021 在干净环境执行 quickstart,记录 SC-003 结果

任务追溯矩阵

需求/标准接口或规则任务
FR-001、FR-002、FR-010、FR-011/api/book/create/api/book/updateByIdT003-T007
FR-003/api/book/queryPageT008-T009
FR-004、FR-005、FR-006、FR-011/api/loan/create、事务、错误码T003、T005、T010-T013
FR-007、FR-011/api/loan/returnByIdT005、T014-T015
FR-008、FR-009/api/loan/queryMemberPage/api/loan/queryOverduePageT016-T019
SC-001全部 P1 验收测试T006、T008、T010、T014、T016、T020
SC-002真实并发T011、T012、T020
SC-003干净环境 quickstartT020-T021

开发者需要人工确认

  • T003 是否把 data-model.md 的约束原样带入(含 active_flag 与唯一索引);
  • T009 与 T008 修改同一模块,因此 T009 没有 [P]
  • 每个用户故事是否有独立测试和 checkpoint;
  • 没有任务实现 out-of-scope。

执行后的项目状态

tasks.md 成为实施顺序,但还没有代码。下一步先做只读一致性分析。

常见错误、原因和修正

  • 错误:"实现借还功能"一个任务。原因:无法审查或恢复。修正:按测试、约束、服务、端点拆分。
  • 错误: 任务没有文件路径。原因:agent 需要重新猜影响面。修正:指向真实文件。
  • 错误: data-model.md 写长度/nullable,tasks 丢失。修正:在对应实体或 DDL 任务原样列出。

本阶段完成检查清单

  • T001..T021 连续且没有重复 ID。
  • 追溯矩阵覆盖 FR-001..FR-012、SC-001..SC-003。
  • [P] 不会造成同文件或依赖冲突。
  • 每个任务有明确文件或验证命令。

7. Analyze:编码前检查跨文档一致性

当前场景与目标

Spec、Plan、Tasks 各自看起来合理,但可能存在需求无任务、任务无需求、字段不一致或 Constitution 冲突。

使用的命令

TEXT
1/speckit.analyze

【官方能力】【版本相关】v1.0.5 支持该只读命令。它报告问题,不修改文件。

示例报告与修复

TEXT
1HIGH: FR-006 要求数据库层阻止重复 active loan,T004 只写"唯一索引",
2      未说明 active_flag 置 NULL 的归还路径与该索引的配合方式。
3MEDIUM: T010 与 T011 都修改 CirculationTest.java,不能同时并行。
4MEDIUM: SC-003 要求 10 分钟内完成,但 quickstart 未定义计时起点。

修复归属:

  1. FR-006 的方案缺口回 research.mddata-model.mdplan.md,再更新 T004/T015;
  2. 并行标记错误回 tasks.md,确保 T010/T011 串行;
  3. 测量定义回 spec.md,再更新 quickstart.md 与 T021;
  4. 重跑 /speckit.analyze

人工确认

Analyze clean 仅代表 agent 未发现跨工件问题,不代表架构正确、安全审查完成或测试通过。

执行后的项目状态

所有阻断项已修到源工件,tasks.md 可作为实施输入。

本阶段完成检查清单

  • Analyze 在实施前运行。
  • 每条问题按需求/设计/任务归属回源修正。
  • 修正后重新运行,阻断项清零。
  • 没有用改代码掩盖文档冲突。

8. Implement:按阶段编码,而不是一次吞完

当前场景与目标

T001..T021 已批准。为控制上下文和回归,先完成 Setup/Foundational,再逐个用户故事实施。

第一轮:T001-T005

TEXT
1/speckit.implement
2只执行 Phase 1 和 Phase 2(T001-T005)。运行 DDL 脚本、实体/错误码相关测试;
3报告修改文件、命令、退出码和未完成项。遇到未决业务规则时停止,
4不要实现接口或加入 out-of-scope 功能。

结束状态:模块骨架、实体、DDL 和错误映射存在;尚未实现用户故事。

第二轮:T006-T015

TEXT
1/speckit.implement
2执行 Phase 3 至 Phase 5(T006-T015)。先运行对应测试观察失败,再实现;
3每完成 US1、US2 或 US3 就运行该故事的测试(记得 -DskipTests=false)。
4T011 必须使用两个真实并发线程(ExecutorService + CountDownLatch 同时进入
5service 事务),不能用两个顺序请求冒充。不要实现 US4/US5。

【AI 产出】借书核心代码可能类似:

Java
1@Service
2@Slf4j
3public class LoanServiceImpl implements LoanService {
4
5    @Resource
6    private BookMapper bookMapper;
7
8    @Resource
9    private LoanMapper loanMapper;
10
11    @Override
12    @Transactional(rollbackFor = Exception.class)
13    public Long createLoan(LoanCreateDTO createDTO) {
14        // 应用层先挡一道明显的重复在借;并发漏网的交给唯一索引兜底
15        Long activeCount = loanMapper.selectCount(
16                new LambdaQueryWrapper<LoanEntity>()
17                        .eq(LoanEntity::getMemberId, createDTO.getMemberId())
18                        .eq(LoanEntity::getBookId, createDTO.getBookId())
19                        .isNull(LoanEntity::getReturnedAt));
20        if (activeCount > 0) {
21            throw new FailedException(CodeEnum.LIBRARY_USER_ERROR_0401.getCode());
22        }
23
24        // 条件更新把“可借”与“扣减”绑在同一条 SQL 上,最后一本竞争由行锁串行化
25        int deducted = bookMapper.deductAvailableCopy(createDTO.getBookId());
26        if (deducted != 1) {
27            throw new FailedException(CodeEnum.LIBRARY_USER_ERROR_0400.getCode());
28        }
29
30        Date borrowedAt = new Date();
31        LoanEntity loan = LoanEntity.builder()
32                .bookId(createDTO.getBookId())
33                .memberId(createDTO.getMemberId())
34                .memberName(createDTO.getMemberName())
35                .borrowedAt(borrowedAt)
36                .dueAt(DateUtil.offsetDay(borrowedAt, LoanConstants.LOAN_PERIOD_DAYS))
37                .activeFlag(LoanConstants.ACTIVE_FLAG_ON)
38                .build();
39        try {
40            loanMapper.insert(loan);
41        } catch (DuplicateKeyException ex) {
42            // uk_member_book_active 冲突说明并发下已产生在借记录,翻译为业务错误码
43            log.warn("[LoanServiceImpl.createLoan] 并发重复借阅冲突(uk_member_book_active),"
44                            + "会员ID: [{}],图书ID: [{}]",
45                    createDTO.getMemberId(), createDTO.getBookId());
46            throw new FailedException(CodeEnum.LIBRARY_USER_ERROR_0401.getCode());
47        }
48        return loan.getId();
49    }
50}

条件扣减写在 BookMapper.xml(更新 book 表的原子写操作落到 XML,单表链式查询仍可用 MyBatis-Plus):

XML
1<update id="deductAvailableCopy">
2    UPDATE `library`.`book`
3    SET `available_copies` = `available_copies` - 1
4    WHERE `id` = #{bookId}
5      AND `available_copies` &gt; 0
6      AND `is_delete` = 0
7</update>

录入图书的校验分两层:字段格式进 BookSaveRequest 注解,ISBN 业务规则在 service:

Java
1@Data
2@Builder
3@NoArgsConstructor
4@AllArgsConstructor
5@ApiModel(value = "图书新增/编辑请求", description = "图书新增/编辑请求")
6public class BookSaveRequest implements Serializable {
7
8    private static final long serialVersionUID = 1L;
9
10    @Null(message = "新增时不能传图书 ID", groups = BookCreateValidationGroup.class)
11    @NotNull(message = "图书 ID 不能为空", groups = BookUpdateValidationGroup.class)
12    @Positive(message = "图书 ID 必须为正数", groups = BookUpdateValidationGroup.class)
13    @ApiModelProperty(value = "图书 ID(编辑时必传,新增禁止传)")
14    private Long id;
15
16    @NotBlank(message = "ISBN 不能为空")
17    @ApiModelProperty(value = "ISBN(ISBN-10 或 ISBN-13,允许连字符)", required = true)
18    private String isbn;
19
20    @NotBlank(message = "书名不能为空")
21    @Size(max = 200, message = "书名长度不能超过 200")
22    @ApiModelProperty(value = "书名", required = true)
23    private String title;
24
25    @NotNull(message = "总库存不能为空")
26    @PositiveOrZero(message = "总库存必须为非负整数")
27    @ApiModelProperty(value = "总库存(非负整数)", required = true)
28    private Integer totalCopies;
29
30    public BookSaveDTO toSaveDTO() {
31        return BookSaveDTO.builder()
32                .id(this.id)
33                .isbn(this.isbn)
34                .title(this.title)
35                .totalCopies(this.totalCopies)
36                .build();
37    }
38}
Java
1private void validateIsbnOrThrow(String isbn) {
2    String normalized = StringUtils.deleteWhitespace(isbn.replace("-", ""));
3    if (!IsbnValidator.isIsbn10Or13(normalized)) {
4        throw new FailedException(CodeEnum.LIBRARY_USER_ERROR_0101.getCode());
5    }
6}

【人工决策】IsbnValidator.isIsbn10Or13 是自写最小校验还是引入工具库、是否允许 ISBN-13 校验位错误、空白如何处理,必须在 Plan/Research 中定下来。不要把示例函数当成官方实现。

这段代码不能单独证明正确。人工审查至少要问:

  • 重复在借检查、库存扣减和插入是否处于同一事务(rollbackFor = Exception.class 是否覆盖非受检异常之外的路径);
  • 唯一索引是否兜住并发重复借阅,DuplicateKeyException 的约束名判断是否必要;
  • FailedException 抛出后事务是否正确回滚(全局异常处理器只做翻译,不动事务);
  • member 不存在时返回什么;
  • 目标 MySQL 的隔离级别是否满足条件更新的假设。

第三轮:T016-T021

TEXT
1/speckit.implement
2执行 Phase 6、Phase 7 和 Final Phase(T016-T021)。实现借阅状态与逾期查询;
3运行完整测试(-DskipTests=false)、DDL 升级/回滚、契约检查(knife4j 导出比对
4contracts/openapi.yaml);在干净环境按 quickstart 计时。不要把测试通过描述为已部署。

自定义实施报告

实现结束后保留一份逐项实施报告,便于验收时逐条对证据。它不是 Spec Kit v1.0.5 的标准核心产物;如果团队不需要额外报告,可以直接使用 tasks.md、测试结果和 PR 描述。对本案例,建议创建:

TEXT
1specs/001-library-circulation/implementation-report.md

最小模板:

Markdown
1## Scope
2实现 T001-T021,未实现 Out of Scope。
3
4## Traceability
5| Requirement | Implementation | Test / evidence | Result |
6| FR-001 | library-web/.../controller/BookController.java | BookCatalogTest.java | passed |
7| FR-005 | LoanServiceImpl + LoanMapper.xml + V001 DDL | CirculationTest.java | passed |
8
9## Commands
10- `mvn --projects library-web test -DskipTests=false`: [真实结果]
11- `mysql -u root library < docs/ddl/V001__library_schema.sql`: [真实结果]
12- `mysql -u root library < docs/ddl/U001__library_schema_rollback.sql`: [真实结果]
13
14## Deviations and open items
15[没有运行、失败、阻塞、人工验收和 Plan 偏离必须逐项写明]

passedfailednot runblocked 不能混为“完成”。

执行后的项目状态

TEXT
1library-system/
2├── .specify/
3├── specs/001-library-circulation/
4│   ├── spec.md
5│   ├── plan.md
6│   ├── research.md
7│   ├── data-model.md
8│   ├── contracts/openapi.yaml
9│   ├── quickstart.md
10│   ├── checklists/requirements.md
11│   └── tasks.md                    # T001..T021 已更新状态
12├── docs/ddl/V001__library_schema.sql
13├── library-api/
14├── library-service/
15└── library-web/                    # 含 src/test/java/com/example/library/

实施中可以改代码;固定候选版本做验收时,发现缺陷应先记录并停止本轮,修复后重新生成候选版本、重新跑回归,再更新实施报告。正式运行中不应由处理用户请求的 Agent 顺手修改生产代码。

常见错误、原因和修正

  • 错误: 一次实施全部任务。原因:上下文、风险和 diff 过大。修正:按 foundation/故事分批。
  • 错误: agent 说“完成”即接受。原因:没有运行证据。修正:要求命令、退出码、未执行项。
  • 错误: 为“未来扩展”引入 repository/factory/cache。原因:YAGNI,Mapper 已是数据访问层。修正:先实现已批准 plan。
  • 错误:mvn test 显示全绿就当测试通过。原因:父 POM 默认 skipTests=true,实际什么都没跑。修正:命令必须带 -DskipTests=false 并记录退出码。

本阶段完成检查清单

  • 每一轮只修改约定范围。
  • 每个用户故事结束时都有测试结果与 diff review。
  • SC-002 使用真实并发测试。
  • T020/T021 的命令与结果已记录。

9. 测试验收:区分四种完成

自动化证据

记录真实命令和输出,不写模糊的“测试已通过”:

TEXT
1mvn --projects library-web test -DskipTests=false
2Tests run: 28, Failures: 0, Errors: 0, Skipped: 0
3exit 0
4
5mysql -u root library < docs/ddl/V001__library_schema.sql
6exit 0
7
8mysql -u root library < docs/ddl/U001__library_schema_rollback.sql
9exit 0
10
11mysql -u root library < docs/ddl/V001__library_schema.sql
12exit 0

验收矩阵

验收项关联自动化人工确认
图书维护与字段校验FR-001、FR-002、FR-011、T003-T007BookCatalogTest 录入/编辑/非法 ISBN/库存下限错误码和角色边界稳定
部分书名/ISBN 搜索及库存FR-003、T008-T009BookSearchTest(MockMvc)返回字段满足管理员需求
正常借书与 14 天 due_atFR-004、T010-T013固定时钟测试时间语义正确
最后一本并发竞争FR-005、SC-002、T011-T012两线程真实并发测试目标数据库已验证
重复 active loanFR-006、T003、T010唯一索引与接口测试冲突码稳定
重复归还FR-007、T014-T015库存不变测试操作反馈可理解
图书维护与角色FR-001、FR-002、FR-010、FR-011、T003-T007BookCatalogTest 越权场景字段和库存规则正确
逾期边界FR-008、FR-009、T016-T019< as_of 边界测试产品同意边界
错误响应FR-011、T005、T006、T010、T014、T018契约检查(ResultDTO 结构)不泄露内部异常
干净环境启动SC-003、T020-T021quickstart 实跑计时与环境已记录

并发测试的等价实现说明。MockMvc 跑在单线程 mock 环境里,证明不了 servlet 层的并发。SC-002 的做法是让两个线程经 CountDownLatch 同时进入 LoanServiceImpl.createLoan(事务边界在 service 层),断言恰好一个成功、available_copies 不为负。它和对容器发起两个并发 HTTP 请求验证的是同一份数据库行为,只是入口换了一层。

四种状态

  1. 已实施:代码存在;
  2. 已测试:指定测试实际通过;
  3. 已验收:产品/工程/测试确认需求;
  4. 已部署/已上线验证:发布物进入目标环境并验证。

不能用前一种状态替代后一种。

版本基线

把以下内容绑定到一个 commit 或 tag:

TEXT
1spec.md + plan.md + tasks.md
23代码 + 测试
45implementation-report.md
67V1 candidate

如果验收时修改了代码,原来的 V1 测试结论只对修改前版本有效。新问题应先分类:实现缺陷回代码/测试,需求遗漏回 spec/clarify,技术方案问题回 research/plan,新功能建立新 feature。这是工程纪律,不是 Spec Kit 自动执行的流程。

10. Converge:查漏,不替代验收

使用的命令

TEXT
1/speckit.converge

【官方能力】只有 implement 已针对当前 tasks.md 运行后才执行。它可能报告 Converged,也可能只向 tasks.md 追加任务。

示例:

TEXT
1Tasks appended:
2- T022 [US3] 验证重复归还后 available_copies 未变化
3- T023 补 quickstart 中错误码响应示例

继续:

TEXT
1/speckit.implement
2只执行 Convergence 新增的 T022-T023,运行相关测试并报告结果。
3
4/speckit.converge

直到:

TEXT
1Converged — implementation satisfies the spec, plan, and tasks.

【人工决策】即使 Converged,也仍需常规 code review、产品验收、CI 和部署验证。

11. 规格与实现回溯

实施过程中常发现新事实。不要只改代码,先判断哪个工件拥有这个事实。

发现首先修改后续动作
借期从 14 天改为按自然日截止spec.md重跑/修订 plan、tasks、analyze
单库 MySQL 无法满足目标并发/容量research.mdplan.md评估 Sharding-JDBC 分库分表,更新 DDL 和 T004/T012/T016
契约文档缺少 ResultDTO 错误结构contracts/openapi.yamltasks.md增加 contract task/test
实现发现事务边界与异常翻译需统一处理plan.md人工批准后更新 tasks 与 advice 代码
仅漏一个测试tasks.md追加任务并 implement/converge

最终交付记录应明确:

TEXT
1Artifact review: spec/plan/tasks approved
2Automated tests: Tests run: 28, Failures: 0, Errors: 0
3DDL upgrade/rollback: passed
4Quickstart: passed in 7m42s from clean checkout
5Product acceptance: US1/US2/US3/US4 accepted; US5 accepted separately if included
6Spec Kit converge: Converged
7Deployment: not performed

12. 交付收尾:把借还能力登记进 OpenSpec

V1 验收通过后,第 2 章初始化的 openspec/ 还是空壳。交付的收尾动作是把已验收的行为登记成现行能力规格:内容来源就是本章的 spec.md(FR-001..FR-012 与验收场景),但登记的角度不同——写的是"系统现在怎么表现",供后续变更对照,不是当时的 feature 快照。

【AI 产出】openspec/specs/circulation/spec.md(节选;完整版由 FR 逐条翻译,人工核对每条 Scenario 与验收矩阵一致):

Markdown
1# Circulation Specification
2
3## Purpose
4图书目录维护与借还流转能力:LIBRARIAN 维护书目并办理借还,
5MEMBER 查询书目与本人借阅。
6
7## Requirements
8
9### Requirement: Catalog maintenance
10系统 SHALL 允许 LIBRARIAN 录入与编辑图书(ISBN 唯一、库存非负整数);
11MEMBER 不得执行维护操作。
12
13#### Scenario: 重复 ISBN 被拒绝
14- **GIVEN** 某 ISBN 已存在
15- **WHEN** 再次录入同一 ISBN
16- **THEN** 返回 ISBN_ALREADY_EXISTS 错误码,库存不变
17
18### Requirement: Atomic borrow and return
19借书与库存扣减 SHALL 在同一事务内完成;并发竞争最后一本时恰好一个
20请求成功;归还 SHALL 只成功一次并原子恢复库存。
21
22### Requirement: Derived loan status
23借阅状态 SHALL 按查询时刻派生:已归还为 RETURNED,未归还且逾期为
24OVERDUE,其余为 ACTIVE。

写法沿用第 5 章会展开的约定:Requirement 正文用 SHALL 表达义务,Scenario 用 GIVEN/WHEN/THEN;只写行为契约,事务管理器、条件 UPDATE 这些实现细节不进 spec。校验并提交:

Bash
1openspec validate --specs
2git add openspec && git commit -m "20260910_library-system_seedCirculationSpec"

登记一次即可。此后"借期从 14 天改为自然日截止""逾期自动提醒"这类维护期增量,不再开完整 feature 目录,而是走第 5 章演示的变更提案循环:MODIFIED 那条 Requirement、评审差异、落地归档,现行规格始终保持与代码同步。

主案例最终检查清单

  • Constitution、Spec、Plan、Tasks、代码的规则没有互相矛盾。
  • FR-001..FR-012、SC-001..SC-003 均可追溯到任务和证据。
  • 接口路径从 Plan 到测试保持一致。
  • T001..T021 的依赖和文件路径成立。
  • Analyze 阻断项已回源修正。
  • Implement 分阶段完成,每阶段有测试和 review。
  • Converge 最终报告 Converged,且未被误写成部署完成。
  • 所有实施发现已反馈到相应工件。
  • 已交付行为已登记进 openspec/specs/circulation/spec.md 并通过 openspec validate --specs

主案例到此走完了从 0 到 1 的全流程。上线之后,图书系统同样会进入维护期——借期规则微调、逾期提醒这类小增量会不断到来。到那时不必每次都开完整 feature 目录:第 5 章会在老项目上用 OpenSpec 的"变更提案 + 归档合并回现行规格"轻循环管理这类日常增量。下一个实战(第 4 章)先演示在已有仓库里再做一次 Spec Kit 增量。

下一章:个人博客增量需求实战

继续阅读

推荐阅读

教程2026年9月9日Spec Kit 中文实战指南(系列导读)系列导读:这套资料写给准备把 Spec Kit 用进真实项目的开发者。图书管理系统走完整流程;个人博客定时发布解释增量变更;老项目接入章再用文章标签筛选做小范围试点。教程2026年9月9日Spec Kit 实战 01 · 认识 Spec Kit:从一个开发问题开始Spec Kit 实战系列第 1/8 章:先看产物如何流动、命令在哪里运行。教程2026年9月9日Spec Kit 实战 02 · 安装与项目初始化Spec Kit 实战系列第 2/8 章:安装 v1.0.5,分别初始化新项目和已有项目。
Spec Kit 实战 03 · 图书管理系统实战:从需求到实现 | 博击长空