上一章:图书管理系统实战 | 返回索引 | 下一章:老项目接入 Spec Kit
阅读指南 主案例走完了从 0 到 1,这一章换 brownfield 场景:仓库里已经有代码和既定行为,新需求只能做加法。命令说明不再重复,只盯老项目特有的坑——兼容边界、状态机、双 worker 幂等。结尾附一场纸面对照:这次增量若交给 OpenSpec 管理,会是什么形态。
示例项目是一个 Spring Boot 单体博客服务(Java 8、MyBatis-Plus、MySQL、xxl-job 定时任务),分层与响应约定和主案例一致:controller/request/vo 分层、ResultDTO 统一响应、CodeEnum 错误码。它比主案例小得多,但内部写法是同一套。真到博客需要对外提供 RPC 或拆发布入口那天,再按 api/service/web/publish 拆模块不迟。
1. 从现有事实开始
当前场景与目标
博客服务已经支持草稿、预览和立即发布。现在要增加“定时发布”,同时保持公开 URL、权限和立即发布路径不变。
开始前的项目状态
1personal-blog/
2├── src/main/java/com/example/blog/
3│ ├── BlogStarter.java # @SpringBootApplication 启动类
4│ ├── controller/PostController.java # 含 publishNow 等接口
5│ ├── request/PostSaveRequest.java
6│ ├── vo/PostVO.java
7│ ├── service/PostService.java
8│ ├── service/impl/PostServiceImpl.java # createDraft / publishNow / preview
9│ ├── mapper/PostMapper.java
10│ ├── entity/PostEntity.java
11│ └── job/ # 已有 xxl-job 任务处理器
12├── src/main/resources/
13│ ├── application.yml + application-local.yml
14│ ├── mapper/PostMapper.xml
15│ └── db/ddl/ # 现有表结构脚本
16├── src/test/java/com/example/blog/
17│ ├── PostServiceTest.java
18│ └── JobTest.java
19└── pom.xml先建立事实清单:
1现有状态:draft -> published(PostStatusEnum)
2现有入口:POST /api/post/publishNow
3现有公开详情:GET /api/post/queryBySlug(对外渲染为 /posts/{slug})
4现有调度:xxl-job 控制台注册的每分钟任务
5现有时间存储:数据库 DATETIME,统一 UTC 口径
6必须回归:立即发布、草稿预览、公开 URL、作者权限【社区实践】官方 Existing Projects 指南建议先从有界变化开始,不要把“为整个旧系统补规格”当作第一个 feature。
2. 描述增量需求并控制范围
错误输入
1给博客增加高级发布功能,顺便优化文章模块。问题:没有目标行为、兼容边界和非目标;“高级”“优化”会让 agent 自由扩张。
可执行输入
1/speckit.specify
2在现有个人博客服务中增加定时发布:作者可以为 draft 文章设置一个未来发布时间;
3到点后系统通过现有 xxl-job 任务自动公开文章。
4
5必须保持:现有立即发布接口、草稿预览、作者权限、文章 slug 和公开 URL 不变。
6定时发布后,文章内容和 URL 与立即发布结果一致。
7
8第一版不做周期发布、社交平台分发、发布失败通知、内容审批流、批量调度,
9也不允许用户选择展示时区。不要重构整个 posts 模块或引入新队列。增量需求编号
| ID | 需求 |
|---|---|
| BFR-001 | 仅 draft 文章可设置未来发布时间 |
| BFR-002 | API 接收 ISO 8601 offset 时间并转为 UTC 保存 |
| BFR-003 | scheduled 文章在到点前不可公开访问,但作者仍可预览 |
| BFR-004 | 作者可改期或取消,取消后回到 draft |
| BFR-005 | 到点后通过现有 xxl-job 任务只发布一次 |
| BFR-006 | 停机错过时间后,下一次任务运行应补发 |
| BFR-007 | 两个 worker 同时扫描时只有一个完成状态迁移 |
| BFR-008 | 立即发布、权限、slug 和公开 URL 行为保持不变 |
边界条件与验收标准
【AI 产出,人工确认】spec.md 应包含:
1### User Story 1 - Schedule a draft (P1)
21. Given 文章为 draft,When 作者设置未来时间,
3 Then 状态变为 scheduled,持久化 UTC 时间,到点前公开接口返回 404。
42. Given 时间等于或早于服务端当前时间,When 设置调度,
5 Then 返回 SCHEDULE_TIME_MUST_BE_FUTURE,文章保持 draft。
63. Given 文章已 published,When 设置调度,
7 Then 返回 POST_NOT_DRAFT,公开文章不变。
8
9### User Story 2 - Reschedule or cancel (P1)
101. Given 文章为 scheduled,When 改为另一个未来时间,Then 只有新时间生效。
112. Given 文章为 scheduled,When 取消,Then 回到 draft 且 scheduled_at 清空。
12
13### User Story 3 - Publish due posts (P1)
141. Given scheduled_at <= now,When 任务运行,Then 原子变为 published 且只执行一次。
152. Given 系统停机期间错过时间,When 恢复后任务首次运行,Then 文章被补发。
163. Given 两个 worker 同时扫描,When 都尝试发布,Then 只有一个状态更新成功。
17
18### Regression Acceptance
19- 立即发布仍使用原接口并保持响应契约。
20- draft/scheduled 的作者预览仍可用。
21- slug 和公开 URL 不改变。
22- 非作者仍不能修改调度。开发者需要人工确认
- 到点条件是
scheduled_at <= now; - 过去时间拒绝,而不是立即发布;
- 取消回到 draft,不删除文章;
- 公开访问在到点前返回现有系统规定的 404(公开详情查询本就过滤非 published 文章),不发明新状态码;
- “不引入新队列”是本 feature 的技术边界,若现有 xxl-job 不满足才回 plan 讨论。
执行后的项目状态
1specs/002-scheduled-publishing/spec.md3. 用 Clarify 补齐旧系统相关语义
完整提示词
1/speckit.clarify
2重点检查:过去时间、时区输入、改期/取消、文章编辑与调度的关系、
3停机补发、双 worker 幂等、立即发布与定时发布冲突。优先询问会改变
4现有状态机、数据迁移或外部 API 的问题。澄清决策
| 问题 | 【人工决策】答案 | 影响 |
|---|---|---|
| 调度后还能编辑内容吗? | 可以;发布时读取最新已保存内容 | BFR-003、测试 |
| scheduled 文章点立即发布? | 允许;原子转 published 并清空 scheduled_at | BFR-008、状态机 |
| API 时区格式? | 必须含 offset;服务端转 UTC | BFR-002、接口字段 |
| 两 worker 如何判定成功? | 条件更新 status=scheduled AND scheduled_at<=now | BFR-007、Plan |
| 发布动作失败怎么办? | 保持 scheduled,下一轮重试;本期不发通知 | BFR-005、Out of Scope |
【人工确认】这些答案必须回写 spec.md。如果只留在对话里,新会话和评审者无法恢复上下文。
4. Plan:优先复用,不另起架构
完整提示词
1/speckit.plan
2先读取 com/example/blog/service/impl/PostServiceImpl.java、
3controller/PostController.java、job/ 下现有任务处理器、PostMapper.xml、
4现有 DDL 脚本和测试。复用现有 xxl-job 调度和 ResultDTO/CodeEnum 响应约定,
5不引入 Redis、消息队列或第二套 PostService。
6
7为 t_post 增加 SCHEDULED 状态枚举值和 nullable 的 scheduled_at(DATETIME,
8UTC 口径);提供向前与回滚 DDL。增加设置、改期、取消调度的接口,沿用现有
9request/vo 风格。发布任务每分钟查找到期记录,用条件 UPDATE 从 scheduled
10原子迁移到 published;失败记录保持 scheduled,下一轮重试。
11立即发布 scheduled 文章时清空 scheduled_at,并保持现有 URL 与权限。
12
13输出真实文件路径、状态机、接口契约、DDL、双 worker/停机补发测试,
14以及全部现有 posts 回归命令(注意父 POM 可能默认 skipTests=true)。
15任何新依赖都必须说明现有能力为何不足。状态机
1stateDiagram-v2
2 [*] --> draft
3 draft --> published: 立即发布
4 draft --> scheduled: 设置未来时间
5 scheduled --> scheduled: 改期
6 scheduled --> draft: 取消调度
7 scheduled --> published: 到点任务或立即发布计划中的最小数据变化
1t_post.status: PostStatusEnum 增加 SCHEDULED(tinyint 新枚举值,不动旧值)
2t_post.scheduled_at: DATETIME, nullable, UTC 口径
3index: idx_status_scheduled_at(status, scheduled_at) 支持到期扫描落到 BT001 交付的 V042__schedule_post.sql:
1ALTER TABLE `t_post`
2 ADD COLUMN `scheduled_at` DATETIME NULL COMMENT '定时发布时间(UTC 口径)',
3 MODIFY COLUMN `status` TINYINT NOT NULL
4 COMMENT '0=draft,1=scheduled,2=published';
5ALTER TABLE `t_post`
6 ADD INDEX `idx_status_scheduled_at` (`status`, `scheduled_at`);两处细节值得多看一眼。SCHEDULED 用新值 1,不动 0 和 2 的既有含义,存量数据一行都不用迁移;索引建在 (status, scheduled_at) 上,因为发布任务的扫描条件永远是 status = 1 AND scheduled_at <= now。回滚脚本 U042__schedule_post_rollback.sql 反向执行 DROP INDEX、DROP COLUMN——但先想清楚再跑,生产数据里已经落了 scheduled_at 的文章回滚就丢了调度。
API 契约示例
1POST /api/post/schedule PostScheduleRequest { postId, scheduledAt }
2POST /api/post/cancelSchedule @RequestParam postId
3POST /api/post/publishNow # 原接口,兼容 scheduled 文章【人工决策】路径必须符合当前项目既有路由风格;示例不是 Spec Kit 官方规定。若原项目使用别的动作命名(如 /updateStatus),应沿用原模式。
防止平行架构
评审 plan 时搜索:
1rg -n "queue|redis|rocketmq|kafka|ScheduledPostService|第二个调度" specs/002-scheduled-publishing若出现未经批准的新基础设施,要求 agent 回答:现有 xxl-job 哪条已验证限制使它不能满足 BFR-005..007?没有证据就删除该方案。
5. Tasks:从“大任务”拆成可验证增量
错误任务
1- [ ] 实现定时发布功能它没有 ID、文件路径、需求映射、依赖或验证方法。
修订后的任务
1## Phase 1: Migration and state
2- [ ] BT001 添加 SCHEDULED 状态值与 scheduled_at 列的向前 DDL src/main/resources/db/ddl/V042__schedule_post.sql
3- [ ] BT002 添加回滚 DDL 并验证执行/回滚 src/main/resources/db/ddl/U042__schedule_post_rollback.sql
4- [ ] BT003 [P] 更新 PostEntity、PostStatusEnum 与索引映射 src/main/java/com/example/blog/entity/PostEntity.java
5
6## Phase 2: Schedule management (BFR-001..004)
7- [ ] BT004 [P] 编写未来时间、过去时间、非 draft、offset 校验测试 src/test/java/com/example/blog/PostSchedulingTest.java
8- [ ] BT005 实现设置/改期/取消调度 PostServiceImpl.schedule/cancelSchedule
9- [ ] BT006 实现 /api/post/schedule 与 /api/post/cancelSchedule controller/PostController.java
10- [ ] BT007 验证 scheduled 到点前公开 404、作者预览可用 src/test/java/com/example/blog/PostSchedulingTest.java
11
12## Phase 3: Due publishing (BFR-005..007)
13- [ ] BT008 [P] 编写停机补发与失败重试测试 src/test/java/com/example/blog/PublishDuePostsJobTest.java
14- [ ] BT009 编写双 worker 原子状态迁移测试 src/test/java/com/example/blog/PublishDuePostsJobTest.java
15- [ ] BT010 实现并注册 publishDuePosts 任务 job/PublishDuePostsJob.java + PostMapper.xml 条件 UPDATE
16
17## Phase 4: Compatibility (BFR-008)
18- [ ] BT011 更新立即发布以支持 scheduled 并清空 scheduled_at service/impl/PostServiceImpl.java
19- [ ] BT012 运行立即发布、预览、权限、slug、公开 URL 回归测试
20- [ ] BT013 执行 quickstart、DDL 回滚与完整测试(-DskipTests=false)重新验证任务
运行 /speckit.analyze 后,检查:
- BFR-001..008 是否都有任务;
- BT004 与 BT007、BT008 与 BT009 修改同一文件,不能标成彼此并行;
- BT010 依赖 BT001 和 BT009;
- BT011 必须在 BT012 之前;
- 没有周期发布、通知、审批或新队列任务。
6. 需求变化:不要直接改 Tasks
开发中产品提出:“定时发布前必须由编辑审核。”这不是 BT014 的小补充,它引入角色、审批状态、权限和失败路径。
先分类变化
| 变化 | 处理 |
|---|---|
| 调整错误文案,不改变契约 | 更新任务/代码,保留证据 |
scheduled_at <= now 改为 < now | 修改 spec,再同步 plan/tasks/tests |
| 增加发布审批 | 新 feature,保持当前 Out of Scope |
| 发现 xxl-job 无法多实例幂等 | 更新 research/plan,再调整 tasks |
控制需求膨胀的回复模板
1“发布审批”改变了角色、权限和状态机,不属于已批准的 BFR-001..008。
2当前 feature 继续交付定时发布;另建下一 feature 描述审批用户故事、状态迁移、
3权限和验收。两者通过现有 published 状态衔接,不在本 PR 顺手实现。新建还是修改现有规格
【官方能力】Spec Kit 不强制一种维护策略:
- Flow-forward:新建
003-publish-approval,最适合审计清晰的团队; - Living spec:先改现有 spec,再生成/修订下游工件;
- Flow-back:发现可以从任意工件进入,但必须重新对齐所有工件。
选择由团队写入 Constitution 或贡献指南。
7. Implement 与回归验收
分阶段提示词
1/speckit.implement
2只执行 BT001-BT003。运行 DDL 执行/回滚和实体相关测试,不修改发布逻辑。1/speckit.implement
2执行 BT004-BT007。沿用现有 controller/request/vo 和 ResultDTO 错误响应模式;
3运行 PostSchedulingTest 和原有 preview 测试。不要实现后台发布任务。1/speckit.implement
2执行 BT008-BT013。双 worker 测试必须验证只有一次状态迁移;运行完整
3posts/job 测试、现有立即发布/预览/权限/URL 回归和 DDL 回滚。报告未执行项。【AI 产出】到点发布任务的核心代码可能类似(xxl-job 1.x 处理器风格):
1@Lazy
2@Component
3@JobHander("publishDuePostsJob")
4public class PublishDuePostsJob extends IJobHandler {
5
6 @Resource
7 private PostMapper postMapper;
8
9 @Override
10 public ReturnT<String> execute(String... strings) throws Exception {
11 // 条件 UPDATE 把“到期”与“迁移”绑在同一条 SQL 上:
12 // status 仍是 SCHEDULED 才会被更新,天然幂等,双 worker 并发时后者影响 0 行
13 int published = postMapper.publishDuePosts(new Date());
14 XxlJobLogger.log("[publishDuePostsJob] --- 本轮定时发布文章数: [{0}]", published);
15 return ReturnT.SUCCESS;
16 }
17}1<update id="publishDuePosts">
2 UPDATE `personal-blog`.`t_post`
3 SET `status` = 2,
4 `scheduled_at` = NULL,
5 `update_time` = now()
6 WHERE `status` = 1
7 AND `scheduled_at` <= #{now}
8 AND `is_delete` = 0
9</update>(枚举值以 PostStatusEnum 实际定义为准:此处假设 DRAFT=0、SCHEDULED=1、PUBLISHED=2。)
设置调度的校验片段沿用 Request 注解 + service 业务检查的分层:
1private void validateScheduleTimeOrThrow(PostEntity post, Date scheduledAt) {
2 if (PostStatusEnum.PUBLISHED.equals(post.getStatus())) {
3 throw new FailedException(CodeEnum.POST_NOT_DRAFT.getCode());
4 }
5 if (!scheduledAt.after(new Date())) {
6 throw new FailedException(CodeEnum.SCHEDULE_TIME_MUST_BE_FUTURE.getCode());
7 }
8}人工审查至少要问:停机补发依赖 scheduled_at <= now 的宽松条件,确认没有场景把“未来但已错过审批”的文章误发;publishNow 与任务并发时状态迁移是否仍然单向;任务失败重试会不会重复发布。
验收矩阵
| 验收 | 需求 | 任务 |
|---|---|---|
| 仅 draft 可设置未来时间 | BFR-001、BFR-002 | BT004-BT006 |
| 到点前不公开但可预览 | BFR-003 | BT007 |
| 改期与取消 | BFR-004 | BT004-BT006 |
| 到点一次发布、失败重试 | BFR-005 | BT008、BT010 |
| 停机补发 | BFR-006 | BT008、BT010 |
| 双 worker 幂等 | BFR-007 | BT009、BT010 |
| 旧能力兼容 | BFR-008 | BT011-BT013 |
执行后状态
1personal-blog/
2├── specs/002-scheduled-publishing/
3├── src/main/resources/db/ddl/V042__schedule_post.sql
4├── src/main/java/com/example/blog/
5│ ├── controller/PostController.java # 新增 schedule/cancelSchedule
6│ ├── request/PostScheduleRequest.java
7│ ├── service/impl/PostServiceImpl.java # schedule/cancelSchedule/publishNow 调整
8│ ├── job/PublishDuePostsJob.java
9│ └── enums/PostStatusEnum.java # 增加 SCHEDULED
10├── src/main/resources/mapper/PostMapper.xml # publishDuePosts 条件 UPDATE
11└── src/test/java/com/example/blog/
12 ├── PostSchedulingTest.java
13 └── PublishDuePostsJobTest.java完成检查清单
- BFR-001..008 与 BT001..013 可双向追溯。
- 立即发布、预览、权限、slug 和 URL 回归通过。
- 没有引入新队列、第二套调度或 PostService。
- 停机补发、失败重试、双 worker 是真实测试。
- 新提出的审批需求没有混入当前 feature。
-
/speckit.converge最终为 Converged;这仍不替代部署验收。
8. 对照:这次增量用 OpenSpec 管理会是什么样
博客此刻还没引入 OpenSpec(第 5 章试点收尾接入,并在同一章跑第一个日常增量),这里做一次纸面对照,看清两种工作流在"同一次增量"上的形态差异。定时发布在 OpenSpec 中会是一个变更提案:
1openspec/changes/add-scheduled-publishing/
2├── proposal.md # Intent:作者希望设定未来时间自动公开;
3│ # Scope:不含周期发布/通知/审批/时区选择
4├── specs/post-publishing/spec.md
5│ # delta:## Purpose + ## ADDED Requirements
6│ # (BFR-001..008 的行为版,Scenario 用 GIVEN/WHEN/THEN)
7├── design.md # 复用 xxl-job、条件 UPDATE 状态迁移(本章 Plan 的关键决策)
8└── tasks.md # BT001..BT013 的分组编号复选清单对照着看本章做过的事:/speckit.specify 生成的 spec.md 对应 delta 里的 ADDED Requirements;Clarify 的五个澄清决策会写进 delta 的 Scenario 边界;/speckit.plan 的技术边界落到 design.md;BT 任务表换成分组编号清单。
差异在顺序与归宿。Spec Kit 先为增量建 feature 目录、生成全套工件,验收之后目录定格成历史快照;OpenSpec 从现行规格出发只写差异,落地归档时 delta 合并回 specs,规格永远只有一份现行版。至于收敛需求的手法——BFR 编号、兼容边界、变更分类——两条路线上完全复用。
第 6 章的三种维护模型讲的就是这两条路线的取舍;第 5 章会在博客上把 OpenSpec 的循环真正跑一遍。