Spec Kit 实战 05 · 老项目接入 Spec Kit:先做一次低风险试点
预计阅读 19 分钟

Spec Kit 实战 05 · 老项目接入 Spec Kit:先做一次低风险试点

上一章:个人博客增量需求实战 | 返回索引 | 下一章:团队协作与规格维护

阅读指南 前面几章讲的是“怎么用一个工具”,这一章讲“怎么把工具接进旧仓库”:先只读盘点,再立 Constitution,然后用标签筛选做一次低风险试点。试点收尾后,博客常驻进 OpenSpec——存量回填、第一个日常变更提案都在这里完成。全书 OpenSpec 的操作,这一章是主场。

1. 接入前调查

场景与目标

已有项目最危险的输入是“请 AI 改一下”。 它会把陌生代码当成空项目,重新发明目录、状态和接口。这一轮先不追求交付功能,把项目边界写成能核对的事实。

开始前的项目状态

TEXT
1personal-blog/
2├── pom.xml                        # Spring Boot 父子结构、插件与 profile
3├── src/main/java/com/example/blog/
4│   ├── controller/                # PostController、TagController、CommentController
5│   ├── request/
6│   ├── vo/
7│   ├── service/ + service/impl/
8│   ├── mapper/
9│   ├── entity/                    # PostEntity、TagEntity、PostTagEntity
10│   ├── enums/
11│   └── advice/                    # GlobalExceptionAdvice、GlobalResultAdvice
12├── src/main/resources/
13│   ├── application.yml + application-local.yml
14│   ├── mapper/PostMapper.xml、TagMapper.xml
15│   └── db/ddl/                    # 现有表结构脚本
16├── src/test/java/com/example/blog/
17├── Dockerfile
18└── README.md

调查提示词

TEXT
1请只读分析当前 personal-blog 仓库,不修改任何文件。
2
3输出一份 brownfield inventory,必须包含:
41. pom.xml 中的 Java 版本、Spring Boot/MyBatis-Plus 版本、构建插件、
5   profile 和测试开关(surefire 是否 skipTests);
62. src/main/java 下的 controller、service、mapper、job 和核心模块;
73. entity 与 db/ddl 中与文章、标签、评论相关的表、字段、索引和关联关系;
84. 已有接口路径、请求/响应对象、ResultDTO 与 CodeEnum 错误处理方式;
95. application*.yml、Dockerfile 和外部服务依赖(数据库、xxl-job、Redis 等);
106. 测试目录、测试框架、可运行命令和当前已知缺口;
117. README、CI、部署脚本中的约束;
128. 不能破坏的既有行为;
139. 需要开发者回答的不确定点。
14
15输出只写观察到的事实,未知内容标记 NEEDS CLARIFICATION,
16结尾列出本次试点预计修改的文件,仍不要编辑文件。

AI 产出的 inventory 示例

Markdown
1## Runtime
2- Java 8,Maven 单模块 Spring Boot 2 工程,测试命令 `mvn test`3  注意:项目 pom 未显式关闭 skipTests,但公司父 POM 默认 true,
4  实际执行测试需 `mvn test -DskipTests=false`5- 生产由 Docker 部署,MySQL 单库;未发现独立搜索服务。
6
7## Existing behavior
8- `PostController` 提供 `POST /api/post/queryPage`,PageDTO 分页,
9  当前实现默认按 `create_time DESC` 返回 published 文章。
10- `TagMapper.selectUsedTags` 返回已使用标签。
11- 列表查询当前无 `tag` 过滤参数。
12- 未确认:无过滤时的默认页大小(代码里是 20,与 PageDTO 默认一致)、
13  不存在 tag 时的返回(推测是空列表,未见测试)。
14
15## Data model(来自 db/ddl 脚本与 entity 注解的交叉核对)
16- `t_post`:id、title、slug、status(0=draft、2=published)、
17  create_time、is_delete。
18- `t_tag`:id、name(列 collation 为 utf8mb4_general_ci,天然大小写不敏感)。
19- `t_post_tag`:post_id + tag_id 关联表,无额外字段。
20- 关联查询目前只用于详情页的标签回显。
21
22## Do-not-break
23- `GET /api/post/queryBySlug`(对外渲染为 /posts/{slug})公开 URL 不变。
24- draft 不出现在公开列表。
25- 现有作者权限检查和评论审核流程不变。
26
27## Candidate files
28- `src/main/java/com/example/blog/request/PostQueryRequest.java`
29- `src/main/resources/mapper/PostMapper.xml`
30- `src/test/java/com/example/blog/PostListTest.java`
31- `README.md`

人工决策

开发者要核对“观察到的事实”和“AI 推测”是否分开。至少确认:

  • 启动/测试命令真的能运行(尤其 skipTests 开关);
  • 数据模型不是只看 entity 注解,还要看 DDL 和生产 schema(字段类型、索引、collation);
  • “不能破坏”来自真实调用方、测试或产品约定,而不是 AI 的礼貌猜测;
  • 已知技术债务记录为风险,不在这次标签试点顺便清理。

执行后的项目状态

TEXT
1docs/spec-kit/brownfield-inventory.md  # 人工修订后提交

这份 inventory 是 Plan 的输入,不是旧系统的完整规格,也不要求把每个模块都文档化。

常见错误、原因与修正

  • 错误: 让 AI 一上来“理解整个项目并优化”。原因是范围没有终点。修正为只读盘点和候选文件。
  • 错误: 只读 README。原因是 README 可能落后。修正为同时读 pom、DDL、mapper XML、测试和部署文件。
  • 错误: 把技术债务直接变成任务。原因是试点被污染。先登记,再由独立 feature 决定。

本节检查清单

  • 技术栈、启动、测试和部署命令已实际核对。
  • 核心模块、数据实体、接口和外部依赖有路径证据。
  • 旧行为和已知债务分开记录。
  • 未确认项明确标注,没有靠猜测补齐。

2. 建立老项目 Constitution

当前场景与目标

inventory 说明项目怎么工作,但没有告诉后续 agent 哪些规则必须遵守。Constitution 只提炼长期约束,不能复制整份 inventory。

命令与完整提示词

TEXT
1/speckit.constitution
2根据 docs/spec-kit/brownfield-inventory.md、README.md、CI 配置和现有测试,
3为 personal-blog 建立项目原则。只纳入有仓库证据或已获团队批准的规则:
4
5- 沿用现有 Java 8 / Spring Boot 2 / MyBatis-Plus / Maven 技术栈,不因单个 feature 换框架;
6- 公开文章接口和 slug URL 保持向后兼容,ResultDTO/CodeEnum 响应约定不变;
7- 数据库变更必须有向前 DDL、回滚脚本和备份/恢复步骤;
8- 新增行为先补自动化测试,保留现有测试命令(含 skipTests 开关约定);
9- 认证、作者权限、评论审核规则不能被筛选功能绕过;
10- 日志不得包含 token、密码或文章私密内容;错误响应沿用现有 CodeEnum;
11- 每个 PR 说明影响文件、兼容性、测试结果和未完成项。
12
13输出 constitution 草案并列出需要人工确认的条款,不要修改业务代码。

生成结果

【AI 产出,人工批准后写入】.specify/memory/constitution.md

Markdown
1## Existing-Architecture First
2新功能 MUST 复用已存在的 controller/service/mapper 和测试模式;偏离必须记录理由。
3
4## API Compatibility
5现有公开字段、CodeEnum 错误码、slug URL 和权限行为 MUST 保持兼容,
6除非 feature spec 明确批准破坏性变化。
7
8## Migration Safety
9数据库变更 MUST 有向前/回滚脚本;生产操作 MUST 先备份并验证恢复路径。
10
11## Test and Observability
12新行为 MUST 有自动化测试;日志 MUST 不含凭据和私密正文。
13
14## Review Governance
15Planning artifacts 与实现 diff 一起评审;测试通过不等于产品验收或部署完成。

人工决策

  • “API 兼容”是否包括新增查询参数对缓存键的影响;
  • 回滚脚本在生产数据已有新字段值时如何处理(MySQL 对含数据的列回滚是危险操作);
  • 日志脱敏规则和审计保留期;
  • PR 是否必须拆成 planning/implementation 两个阶段。

执行后的项目状态

TEXT
1.specify/memory/constitution.md
2docs/spec-kit/brownfield-inventory.md

常见错误、原因与修正

  • 错误: 凭空加入“所有模块必须 100% 覆盖”。原因是把愿望当原则。改成现有测试策略和本 feature 的最低覆盖。
  • 错误: 写“不得改任何旧代码”。原因是增量功能必然需要改入口。改成“只改必要文件并保持契约”。
  • 错误: 把外部系统 SLA 当博客自身规则。除非已签约并能验证,否则标记版本相关或待确认。

本节检查清单

  • 原则能追溯到 inventory、代码或团队决定。
  • API、数据库、安全、日志和评审规则均有明确动作。
  • Constitution 没有复制项目全量细节。

3. 把模糊的文章搜索变成增量规格

当前场景与目标

业务只说“给博客增加文章搜索”。需要把它缩成一次低风险试点:标签筛选先于全文搜索,原因是已有 t_tag/t_post_tag 关系和索引,影响面更小。

完整 Specify 提示词

TEXT
1/speckit.specify
2在已有 personal-blog 中为公开文章列表增加标签筛选,先用它做文章搜索的低风险试点。
3
4用户目标:读者在文章列表页选择一个标签,只看到包含该标签且已 published 的文章。
5
6必须保留:
7- 对外公开 URL /posts/{slug} 与 GET /api/post/queryBySlug 行为;
8- draft 不可出现在公开列表;
9- 无 tag 参数时的排序和响应字段;
10- 作者权限和评论审核流程。
11
12范围:POST /api/post/queryPage 的请求体增加可选 tag 与分页参数(沿用现有
13PageDTO 约定:page 从 0 开始,count 默认 20、允许 1..50);
14按 tag 精确匹配(大小写不敏感、去除首尾空格);结果按现有 create_time DESC;
15响应沿用 PageDTO<PostVO> 结构。
16
17边界:tag 为空白等同于未传;不存在的 tag 返回 200 空列表;
18page 为负或 count 超出范围返回现有参数错误码(ResultDTO code,非 HTTP 4xx);
19草稿和未公开文章必须过滤掉。
20
21性能:在本地样例数据 10,000 篇文章上,带 tag 的列表查询 P95 目标不超过 200ms;
22若现有数据库没有合适索引,记录为 Plan 风险,不在本 feature 引入搜索引擎。
23
24不做:全文搜索、模糊 tag、标签管理 UI、搜索建议、相关性排序、缓存和新数据库。
25请生成用户故事、SFR 编号、验收场景、成功标准、假设与 Out of Scope,
26不要决定实现代码结构。

规格关键片段

Markdown
1### User Story 1 - Filter published posts by tag (Priority: P1)
2**Independent Test**: 准备 3 篇 published、1 篇 draft 和 2 个 tag,
3请求体携带 tag=java、page=0、count=20,只返回匹配的 published 文章。
4
5**Acceptance Scenarios**:
61. Given tag=java 存在 2 篇 published,When 查询,Then 返回 success、total=2、按 create_time DESC。
72. Given tag 不存在,When 查询,Then 返回 success 和空 items,不创建新 tag。
83. Given tag="  Java  ",When 查询,Then 与 `java` 结果相同。
94. Given page=-1 或 count=51,When 查询,Then 返回现有参数错误码。
105. Given 某文章是 draft,即使拥有目标 tag,When 查询,Then 不返回该文章。
11
12### Requirements
13- SFR-001: `tag` 可选,空白 tag 按未传处理。
14- SFR-002: tag 精确匹配且大小写不敏感(依赖列 collation,Plan 中确认)。
15- SFR-003: 只返回 published 文章,保留默认排序与响应字段。
16- SFR-004: page 从 0 开始(PageDTO 约定),count 默认 20,范围 1..50。
17- SFR-005: 无效分页返回现有参数错误码(HTTP 200 + ResultDTO.code)。
18- SFR-006: 不存在 tag 返回空列表。
19- SFR-007: 在 10,000 篇本地样例上 P95 <= 200ms,测量方法写入 quickstart。
20- SFR-008: 不引入全文搜索、缓存或新数据库。

有两处和常见 REST 教学约定不一样,要在 spec 里写死,免得 agent 自作主张。分页页码从 0 开始,沿用项目 PageDTO 约定;参数错误不走 HTTP 400,而是 HTTP 200 加 ResultDTO.code,沿用全局异常处理器的约定。注意这两条只是“沿用现状”的兼容决定,别当成新设计去发挥。

人工决策

  • “精确匹配”是否依赖数据库 collation:MySQL 默认 utf8mb4_general_ci 本身大小写不敏感,需确认目标库与列的实际 collation,_bin 列会改变行为;
  • 旧响应能否新增 items 内容字段,客户端是否会拒绝未知字段;
  • P95 的机器、数据量、冷/热缓存和测量命令;
  • 性能目标达不到时是加索引还是降低范围,不能让 agent 自行引入 Elasticsearch。

常见错误、原因与修正

  • 错误: 直接写“支持搜索”。原因是没有匹配、分页和空结果语义。修正为可执行参数和 Given/When/Then。
  • 错误: 把模糊匹配、全文搜索一起塞入 P1。原因是边界失控。先做标签筛选,全文搜索另建 feature。
  • 错误: 性能目标没有测量方法。修正为固定数据规模、环境和 P95 命令。

4. 让 AI 先读项目再 Plan

完整提示词

TEXT
1/speckit.plan
2先阅读并引用以下文件的真实内容:
3docs/spec-kit/brownfield-inventory.md
4.specify/memory/constitution.md
5src/main/java/com/example/blog/controller/PostController.java
6src/main/java/com/example/blog/request/PostQueryRequest.java
7src/main/resources/mapper/PostMapper.xml
8src/main/resources/db/ddl/(文章与标签相关脚本)
9src/test/java/com/example/blog/PostListTest.java
10pom.xml
11
12在写 Plan 前先输出:现状、影响范围、不确定点、预计修改文件和不改文件。
13不要凭空假设项目使用的 ORM、分页约定、错误格式或缓存。
14
15方案必须:
161. 复用现有 PostMapper 查询与 t_post_tag 关联,不新建标签服务;
172. 保持无 tag 请求的 SQL、排序和响应兼容;
183. 说明分页换算(PageDTO 从 0 起,MyBatis-Plus 分页从 1 起)、
19   大小写(列 collation)、空结果、草稿过滤和参数错误码;
204. 说明是否需要索引(EXPLAIN 验证)及如何测量 P95;
215. 给出回滚方式和测试分层;
226. 不修改无关模块,不引入全文搜索、缓存或新数据库。
23
24在得到我对技术选择、影响文件和风险的确认前,不生成代码、不执行 DDL。

AI 产出应包含

Markdown
1## Current behavior
2- 无 tag 时复用 `PostMapper.selectPublishedPage`,排序 `create_time DESC`3  MyBatis-Plus 分页(入参 PageDTO 页码 +1 换算)。
4- t_post_tag 关联查询当前只用于详情页标签回显,列表查询未用到。
5
6## Proposed changes
7- `PostQueryRequest` 增加可选 tag 字段(@Size 上限 + 空白语义在 service 处理)。
8- `PostMapper.xml` 的列表查询增加 tag 关联与过滤(tag 非空时 join t_post_tag/t_tag)。
9- `PostServiceImpl` 做空白归一化与 collation 说明,分页沿用 PageDTO。
10- `PostListTest` 增加 SFR-001..007 场景。
11- `README.md` 补充接口查询示例。
12
13## Not changed
14- 评论模块、发布任务、queryBySlug 详情接口。
15
16## Risks
17- 列 collation 若为 _bin,大小写不敏感假设失效,需在目标库确认。
18- P95 目标需要基准数据,不用单元测试结果冒充。

人工决策

看到预计修改文件后,逐项批准。若出现 search/ 新包、Redis、全文索引或重写 PostService,要求 AI 说明证据;没有证据就缩回现有查询层。

5. 低风险试点与实施

试点目标

只验证一件事:增加可选 tag 过滤后,默认列表和既有页面不变,标签筛选有明确空结果和分页行为。

预计修改文件

TEXT
1src/main/java/com/example/blog/request/PostQueryRequest.java
2src/main/java/com/example/blog/service/impl/PostServiceImpl.java
3src/main/resources/mapper/PostMapper.xml
4src/test/java/com/example/blog/PostListTest.java
5src/test/java/com/example/blog/TagFilterBench.java          # 基准与 P95 测量
6README.md

任务拆分

Markdown
1- [ ] ST001 运行现有文章列表、权限和详情测试(-DskipTests=false),记录基线命令与结果
2- [ ] ST002 [P] 编写 SFR-001..006 的 tag、分页、空结果和 draft 过滤测试 src/test/java/com/example/blog/PostListTest.java
3- [ ] ST003 编写 SFR-007 的 10,000 篇基准数据 SQL 与 P95 测量脚本(EXPLAIN + 计时采样)
4- [ ] ST004 实现可选 tag 过滤查询 src/main/resources/mapper/PostMapper.xml
5- [ ] ST005 实现参数解析、空白归一化与分页换算 src/main/java/com/example/blog/service/impl/PostServiceImpl.java
6- [ ] ST006 运行 ST001 回归、P95 测量、完整测试
7- [ ] ST007 更新 specs/003-post-tag-filter/、brownfield inventory 与 README.md

ST004 依赖 ST002,ST005 依赖 ST004,ST006 依赖 ST003–ST005,ST007 只记录实际完成后的结果。[P] 只表示 ST002 可与不修改同文件的前置工作并行。

【AI 产出】列表查询的关键改动可能类似(tag 过滤落到 XML,其余沿用 MyBatis-Plus 分页):

XML
1<select id="selectPublishedPageWithTag" resultType="com.example.blog.entity.PostEntity">
2    SELECT p.*
3    FROM `personal-blog`.`t_post` p
4    <if test="tag != null and tag != ''">
5        INNER JOIN `personal-blog`.`t_post_tag` pt ON pt.`post_id` = p.`id`
6        INNER JOIN `personal-blog`.`t_tag` t ON t.`id` = pt.`tag_id`
7        WHERE t.`name` = #{tag}
8    </if>
9    <if test="tag == null or tag == ''">
10        WHERE 1 = 1
11    </if>
12      AND p.`status` = 2
13      AND p.`is_delete` = 0
14    ORDER BY p.`create_time` DESC
15</select>

service 侧做空白归一化和分页换算:

Java
1Page<PostEntity> pageParam = new Page<>(pageDTO.getPage() + 1, pageDTO.getCount());
2String normalizedTag = StringUtils.trimToNull(request.getTag());

t.name = #{tag} 的大小写不敏感依赖列 collation,属于 SFR-002 必须在目标库确认的前提,不是 SQL 本身能保证的。)

已知风险与回滚

风险监测回滚
默认列表 SQL 变慢无 tag 回归测试、P95 对比回退 mapper/service commit
大小写结果依赖 collation目标库 EXPLAIN 与集成测试改为规范化 tag 存储列,再重新 plan
新增过滤参数破坏旧客户端契约检查、客户端 smoke test保留旧请求体兼容,新参数单独评估
AI 改到评论/发布模块diff 文件清单丢弃无关 diff,不修改用户已有改动

分阶段 Implement 提示词

TEXT
1/speckit.implement
2只执行标签筛选试点 ST001-ST007:先更新/创建 SFR-001..007 对应测试,确认失败,
3再修改 PostQueryRequest、PostServiceImpl 和 PostMapper.xml。保持无 tag 的现有
4响应和排序。不要改评论、发布任务、详情接口、数据库 schema 或引入依赖。
5每个阶段报告修改文件、测试命令、退出码和未完成项。

测试与验收

Bash
1mvn test -DskipTests=false -Dtest=PostListTest
2mvn test -DskipTests=false

验收至少包括:匹配 tag、大小写和空格、空结果、非法分页、draft 过滤、无 tag 回归、权限不变;P95 使用 quickstart 约定数据集单独测量。

完成后更新的文档

TEXT
1specs/003-post-tag-filter/spec.md
2specs/003-post-tag-filter/plan.md
3specs/003-post-tag-filter/tasks.md
4docs/spec-kit/brownfield-inventory.md       # 补充新入口和行为
5README.md                                   # 补充接口示例

6. 试点之后:把老项目常驻进 OpenSpec

场景与目标

试点验收通过,说明 Spec Kit 的接入动作成立。但老项目的真实考验在试点之后。维护期的紧急修复、文案调整会不断到来,每次都开 feature 目录太重;事后靠人自觉同步 brownfield-inventory.md,又靠不住。第 6 章讲过三种维护模型,Living spec 失效的常见原因就是没有工具拦住"代码改了、规格没改"。

OpenSpec 的定位恰好是 brownfield:openspec/specs/ 维护一份现行能力规格(唯一事实来源),openspec/changes/ 承接日常增量的变更提案,归档时把需求差异合并回 specs。试点稳定后接入它,老项目从此有一份"始终现行"的规格和一条更轻的增量通道。

接入动作

CLI 已在第 2 章安装(图书系统当时初始化过)。博客是老项目,按第 2 章的约定推迟到现在——试点证明价值后再引入:

Bash
1cd personal-blog
2openspec init --tools codex
3git status --short        # 只应新增 openspec/ 与工具命令目录

初始化后与 Spec Kit 共存:

TEXT
1personal-blog/
2├── .specify/                     # Spec Kit 基础设施
3├── specs/                        # Spec Kit feature 目录(002、003),保留为历史
4├── openspec/
5│   ├── specs/                    # 现行能力规格(当前为空)
6│   ├── changes/                  # 变更提案(当前为空)
7│   └── config.yaml
8└── ...                           # 工具命令目录(/opsx:* 说明文件)

【人工决策】是否在 README 写明"新走 OpenSpec、旧 feature 目录只读",由团队决定,建议写。

存量回填:把已上线的查询行为写成现行规格

openspec/specs/ 还是空的,而查询行为早已上线。回填的来源就是本章的 SFR-001..008 与 specs/003-post-tag-filter/spec.md 的验收场景——登记的是"系统现在怎么表现",不是当时的 feature 快照。

【AI 产出,人工逐条核对】openspec/specs/post-query/spec.md

Markdown
1# Post Query Specification
2
3## Purpose
4公开文章列表查询能力:读者按标签过滤已发布文章,作者预览草稿。
5本规格描述系统当前行为,是后续变更提案的对照基准。
6
7## Requirements
8
9### Requirement: Published post list
10系统 SHALL 返回已发布(published)文章的分页列表,默认按创建时间倒序,
11未传过滤条件时行为与历史版本保持一致。
12
13#### Scenario: 默认列表
14- **GIVEN** 存在 published 与 draft 文章
15- **WHEN** 请求列表且不带过滤条件
16- **THEN** 仅返回 published 文章,按 create_time DESC 排列
17
18### Requirement: Filter by tag
19系统 SHALL 支持按单个标签精确过滤(大小写不敏感、忽略首尾空白),
20分页沿用统一 PageDTO 约定(页码从 0 起,count 默认 20、范围 1..50)。
21
22#### Scenario: 命中标签
23- **GIVEN** 标签 java 关联 2 篇 published 文章
24- **WHEN** 请求 tag=java
25- **THEN** 返回 total=2 的该标签文章
26
27#### Scenario: 标签不存在
28- **WHEN** 请求不存在的 tag
29- **THEN** 返回空列表,不创建新标签
30
31### Requirement: Pagination validation
32非法分页参数 SHALL 返回统一参数错误码(HTTP 200 + ResultDTO.code),
33不使用独立的状态码语义。

写法约束:Requirement 正文用 SHALL/MUST 表达义务(RFC 2119 风格),Scenario 用 GIVEN/WHEN/THEN 列表;spec 是行为契约,不写实现——表名、mapper、XML 都不出现(实现属于变更提案的 design.md)。这条边界和第 3 章"spec 管 what、plan 管 how"是同一个原则。

校验并提交:

Bash
1openspec validate --specs
2git add openspec && git commit -m "20260911_personal-blog_seedPostQuerySpec"

【官方能力】validate 做结构校验(章节、Requirement/Scenario 层级等),--strict 启用更严格的规则;退出码非 0 即失败。

【版本相关】扩展 profile(openspec config profile 选择后 openspec update 生效)提供 /opsx:onboard 辅助存量回填,另含 /opsx:new/opsx:continue/opsx:verify/opsx:bulk-archive 等命令。日常使用 core profile 即可。

此后增量的分工

  • Spec Kit:新能力域的首次完整推演(需要 Constitution、Clarify、Analyze 这些质量 gate 时);
  • OpenSpec:已有能力上的日常增量——提案、差异评审、归档合并。

第一个日常增量就在下一节:多标签组合筛选,从提案一路走到归档。

7. 第一个日常变更提案:多标签组合筛选

接入与回填完成后,产品提出:"列表最好能同时按几个标签筛,比如同时看 java 和 spring 的文章。"这就是 OpenSpec 接管后的第一个真实增量。

需求与编号

ID需求
MFR-001请求的 tag 参数扩展为 tags 列表,多标签之间为 AND 语义(文章须同时具备全部所选标签)
MFR-002单标签或空列表行为与现有 Filter by tag 完全兼容,不破坏既有调用方
MFR-003tags 数量上限 5,超出返回现有参数错误码;忽略空白项
MFR-00410,000 篇样例数据下,3 标签组合查询 P95 不超过 300ms

提出提案

TEXT
1/opsx:propose add-multi-tag-filter
2在现有公开文章列表查询上增加多标签组合筛选(AND 语义):
3读者可同时选最多 5 个标签,只返回同时具备全部所选标签的 published 文章。
4单标签请求行为必须与现状完全一致。分页与错误响应沿用现有 PageDTO、
5ResultDTO 约定。不引入搜索引擎、缓存或新表;不改变标签管理功能。

【官方能力】/opsx:propose <change-id> 生成变更目录及四类工件;change 名必须是小写 kebab-case(如 add-multi-tag-filter)。也可以不经过 agent,用 openspec new change add-multi-tag-filter 建目录后手工编写,openspec status 会按工件依赖顺序提示下一个可写的工件。

TEXT
1openspec/changes/add-multi-tag-filter/
2├── proposal.md                    # 为什么改、改什么
3├── specs/post-query/spec.md       # 需求差异(delta)
4├── design.md                      # 技术方案(可选)
5└── tasks.md                       # 实施清单

四类工件

【AI 产出】proposal.md 只写动机、边界和方向:

Markdown
1# Proposal: Add Multi-Tag Filter
2
3## Intent
4读者当前只能按单一标签过滤,技术类文章常需要"java + spring"这类交集
5视角;运营侧已多次收到同类反馈。
6
7## Scope
8### In scope
9- 查询请求的标签参数从单个扩展为最多 5 个的列表(AND 语义)
10- 单标签行为保持现状
11### Out of scope
12- OR 语义组合、标签管理、搜索建议、缓存
13
14## Approach
15沿用现有列表查询链路,在 mapper 层按标签集合做交集过滤;
16不引入新表与新依赖,索引影响在 design.md 评估。

delta 是最关键的约定——不要复制整份现行 spec,只声明对能力的增、改、删,与 §6 回填的三条 Requirement 对照:

Markdown
1# Delta for Post Query
2
3## ADDED Requirements
4
5### Requirement: Filter by multiple tags
6系统 SHALL 支持按 2..5 个标签的组合过滤(AND 语义):仅返回同时具备
7全部所选标签的 published 文章;标签项忽略首尾空白,空白项视为未传。
8
9#### Scenario: 组合过滤命中
10- **GIVEN** 文章 A 含标签 java、spring,文章 B 仅含 java
11- **WHEN** 请求 tags=[java, spring]
12- **THEN** 仅返回文章 A
13
14#### Scenario: 超过上限
15- **WHEN** 请求 tags 含 6 个有效标签
16- **THEN** 返回现有参数错误码,列表不变
17
18## MODIFIED Requirements
19
20### Requirement: Filter by tag
21系统 SHALL 支持按标签过滤:请求携带 1 个标签时行为与现有单标签过滤
22完全一致(精确匹配、大小写不敏感、忽略首尾空白);请求未携带标签时
23不过滤。分页沿用统一 PageDTO 约定(页码从 0 起,count 默认 20、范围
241..50)。
25(Previously: 仅描述单个标签的精确过滤。)
26
27#### Scenario: 命中标签
28- **GIVEN** 标签 java 关联 2 篇 published 文章
29- **WHEN** 请求 tags=[java]
30- **THEN** 返回 total=2 的该标签文章

约定要点:

  • ## ADDED Requirements:新行为,归档时追加到现行 spec;
  • ## MODIFIED Requirements:变更行为,重写完整需求正文(不是只写差异句),归档时按 ### Requirement: 同名整体替换(Previously: ...) 是给人看的备注;
  • ## REMOVED Requirements(本例没有):按原 Requirement 名引用并附弃用理由;若删掉的是该能力最后一条需求,需在 change 目录的 .openspec.yaml 里声明 retire_capabilities: true,否则归档会停下来要求确认。

design.md 只在确有技术取舍时写(纯文案或字段微调可以整个跳过):本次记录"子查询交集而非多次 JOIN"的决策与影响文件清单(PostQueryRequest、PostServiceImpl、PostMapper.xml、PostListTest)。

tasks.md 是分组编号的复选清单,粒度要求"单个任务一次会话能做完":

Markdown
1# Tasks
2
3## 1. 测试先行
4- [ ] 1.1 编写 MFR-001 组合命中/未命中场景测试 PostListTest.java
5- [ ] 1.2 编写 MFR-002 单标签回归与 MFR-003 上限/空白测试 PostListTest.java
6
7## 2. 查询实现
8- [ ] 2.1 PostQueryRequest 增加 tags 字段与校验,service 归一化
9- [ ] 2.2 PostMapper.xml 增加交集子查询,保持无标签路径 SQL 不变
10
11## 3. 验证与收尾
12- [ ] 3.1 运行 PostListTest 与全量回归(-DskipTests=false)
13- [ ] 3.2 基准数据上测量 3 标签组合 P95(MFR-004)

校验与人工评审

Bash
1openspec validate add-multi-tag-filter --strict
2openspec status
3openspec show add-multi-tag-filter --deltas-only
  • validate 做结构校验,并把 delta 里的 MODIFIED 需求与现行 specs 对照——引用了不存在的 Requirement 名会直接失败,这是防止"改了个寂寞"的机制;
  • status 按工件依赖顺序显示提案完成度;show --deltas-only 只打印差异,适合贴到评审里。

人工评审清单:

  • delta 只写差异;MODIFIED 重写了完整需求正文而不是残缺替换;
  • MFR-001..004 每条都能在 delta 的 Requirement/Scenario 里找到对应;
  • Out of Scope 没有混入 OR 语义、缓存之类未批准内容;
  • 对照第 6 章"变更进入哪个工件":本变更只动查询能力,无需回 Spec Kit 的 feature 目录。

落地执行

TEXT
1/opsx:apply
2执行 add-multi-tag-filter 的 tasks。先跑 1.x 测试确认失败,再实现 2.x;
3无标签与单标签请求的 SQL 路径必须保持不变;完成一项勾选一项。
43.1 之前报告测试命令与退出码,3.2 的 P95 用基准数据实测,不拿单测耗时冒充。

【AI 产出】交集子查询的关键改动可能类似(沿用本章 §5 的 mapper 风格):

XML
1<if test="tags != null and tags.size() > 0">
2    INNER JOIN (
3        SELECT pt.`post_id`
4        FROM `personal-blog`.`t_post_tag` pt
5        INNER JOIN `personal-blog`.`t_tag` t ON t.`id` = pt.`tag_id`
6        WHERE t.`name` IN
7        <foreach collection="tags" item="tag" open="(" separator="," close=")">#{tag}</foreach>
8        GROUP BY pt.`post_id`
9        HAVING COUNT(DISTINCT t.`id`) = #{tags.size()}
10    ) matched ON matched.`post_id` = p.`id`
11</if>

大小写不敏感仍依赖列 collation(本章 §3 已确认的前提)。测试与回归命令不变:mvn test -DskipTests=false -Dtest=PostListTest 与全量回归。

【人工决策】"单值 tag 参数是否继续兼容"是接口契约问题:如果前端已切到 tags 列表,可以在同一 change 里声明移除单值语义(写进 MODIFIED 的 Scenario);如果还有旧调用方,就保留双字段过渡。这个决定影响 delta 措辞,必须在 apply 前定下来。

归档:变更合并回 specs

测试与人工验收通过后执行 /opsx:archive;等价的非交互命令(CI 或 agent 环境必须带 --yes):

Bash
1openspec archive add-multi-tag-filter --yes

【官方能力】archive 的动作序列:先 validate,再确认,然后声明归档目的地 → 把 delta 合并进 openspec/specs/ → 把 change 目录移入 openspec/changes/archive/(带日期前缀);中途失败会恢复 specs 并把 change 留在原路径。

本例归档后的效果:

TEXT
1openspec/
2├── specs/post-query/spec.md          # Filter by tag 已被替换为新正文;
3│                                     # Filter by multiple tags 已追加
4└── changes/archive/
5    └── 2026-09-12-add-multi-tag-filter/   # proposal/design/tasks/delta 完整保留

验证与收尾:

Bash
1openspec list --specs                 # 现行能力列表
2openspec validate --archived          # 校验归档项 tasks 全部勾选,适合放进 CI
3git add openspec && git commit -m "20260912_personal-blog_multiTagFilter"

validate --archived 有未勾选任务时非 0 退出,可以直接当 pre-commit 或 CI gate 用。这是 OpenSpec 比"约定式维护"实在的地方,不勾完任务、不合规格,变更就进不了历史。

归档后 specs/post-query/spec.md 就是最新行为。下一次产品再提"排除某些标签"时,流程原样再来:/opsx:explore 先让 agent 读着现行 specs 讨论方案(不落盘),成形后 /opsx:propose exclude-tag-filter。多个提案并行的规则见第 6 章的并行开发一节。

8. 不适合接入或不适合完整流程的情况

情况建议
一次性小任务直接 issue + 测试,避免维护多余工件
拼写或简单配置直接修复并做最小检查
需求还没有稳定目标先做产品/问题探索,不要让 Spec Kit 生成正式 spec
项目无法启动或测试(如父 POM 锁死 skipTests 且无人能改)先修复基础环境;无法验证的 Plan 只是猜测
团队不愿维护规格不要强行引入;先约定 owner 和更新责任
紧急故障处理先按事件响应和回滚处理,事后再补缺陷 spec
文档成本明显高于收益采用短路径或轻量 issue flow,保留必要的验收和回滚证据

本章完成检查清单

  • 接入前调查覆盖技术栈、启动、结构、数据、接口、依赖、测试、债务和不可破坏行为。
  • Constitution 来自事实或团队批准,不是空泛口号。
  • 增量 spec 写清用户目标、分页、空结果、异常、性能和 Out of Scope。
  • AI 被要求先读项目、列不确定点和影响文件,并等待关键决策。
  • 标签筛选试点有预计文件、风险、回滚、测试和验收标准。
  • 完成后 inventory、spec、plan、tasks 和项目 README 都同步更新。
  • SFR-001..008 与 ST001..ST007 可以双向追溯。
  • 博客已完成 openspec init 与查询能力回填,openspec validate --specs 通过。
  • MFR-001..004 与 add-multi-tag-filter 的 delta/tasks 可双向追溯;validate --strict 在提案后、归档前各跑过一次。
  • 归档后 specs/ 与代码行为一致,归档目录保留完整提案上下文。

老项目的接入到此闭环:Spec Kit 完成第一次有界试点,OpenSpec 在接入回填后立即接管了第一个日常增量——提案、评审、落地、归档一气呵成。第 6 章把这套做法放进团队协作与规格维护的语境:并行变更、两阶段 PR 与 CI 约束。

下一章:团队协作与规格维护

继续阅读

推荐阅读

教程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 实战 05 · 老项目接入 Spec Kit:先做一次低风险试点 | 博击长空