第 5 章 写出真正可执行的 Feature Spec

第 4 章给整个项目立了宪法,那套规则管的是无论做什么都不能越过的边界。回到单个功能,第一份产物是 spec.md,它管的是这一次究竟要做什么、怎样才算完成。这一章继续用短链接服务,跟着 /speckit.specify 走一遍完整流程,最后得到一份可以直接交给后面命令使用的规格。

很多团队第一次写规格,交出来的东西读起来像 PRD 的缩写版,或者像一份提前做好的技术方案,更常见的是把两者混在同一份文件里。第 3 章已经从概念上把三者拆开了,这一章要解决的是动手问题。一份规格算不算"可执行",判据其实很具体,Agent 拿着它无法再替你决定过期行为,测试拿着它知道每条断言该写什么,评审的人不用问作者就能判断需求有没有被满足。下面按 spec 模板的章节顺序走一遍,每一节说明它在要求什么、为什么这样要求,以及短链接项目在这里具体写了什么。

一、用 /speckit.specify 生成第一份规格

第 2 章已经说过,这个命令接收一句自然语言描述,产出 specs/NNN-名称/spec.md。落到短链接项目,输入就是第 1 章那句一次性 Prompt,可以稍微补一点范围,但完全不必写成正式文档,因为把这句话整理清楚,正是这条命令接下来要做的事。

text 复制代码
/speckit.specify

短链接服务。任何拿到长网址的人都可以提交网址,得到一个可以直接分享的
短链接;访问者打开短链接,到达原来的页面。创建时可以选择设置有效期。
创建者可以查看并管理自己创建过的短链接。

输入里有两处是故意含糊的。过期之后链接到底还能不能跳,以及"创建者"凭什么被识别,这两个问题都没有在这句话里给出答案。命令也没有在生成时把它们默默补上,后面会看到它们变成规格里两条待澄清的需求。

命令启动后先做的是取名和建目录。它从描述里提炼出两到四个词的短名,这里得到 short-url,接着在仓库根目录建立 specs/001-short-url,把当前生效的 spec-template 复制成 spec.md,再把功能目录写进 .specify/feature.json 这个本机指针,方便后面的命令找到"当前在做哪个功能"。取名和建目录都是机械步骤,真正体现规范的是它接下来读什么、写什么。

它读的第一份上下文是 .specify/memory/constitution.md。第 4 章立的六条原则在这里第一次进入功能流程,安全边界要求管理操作必须经过身份验证,映射正确性要求短码一经建立不得被静默改写,这两条都会在生成的规格里留下痕迹。它写的部分则严格限制在 What 和 Why,不选语言,不定存储,不设计接口。

这里还有一个容易忽略的约束,即使你在输入里顺手加一句"再把删除接口写了",这条命令也不会去改应用代码,它会把这类实现意图列为后续动作,交给别的命令处理。一次命令只负责一件事,specify 只写规格。

产物落下来是三个位置。

text 复制代码
specs/
└── 001-short-url/
    ├── spec.md
    └── checklists/
        └── requirements.md

spec.md 是主角,checklists/requirements.md 是它自带的质量清单。命令写完规格后会立刻拿这份清单自查一遍,清单分成内容质量、需求完整性、功能就绪三组,一共十六项,检查规格里有没有混进实现细节、需求是否可测、成功标准是否可量化、边界是否覆盖。短链接这一版的十六项里有十五项通过,唯一没有过的是"没有遗留 [NEEDS CLARIFICATION] 标记",因为规格里确实还留着两个问题。

这两个问题为什么不直接猜一个答案填上?规格模板对标记有硬性限制,最多三条,只有影响范围大、存在多种合理解释、而且没有合理默认值的问题才配得上它。过期后的行为属于产品决定,短链接创建者的身份界定属于安全边界,两者都落在"必须由人决定"的区间里。其余想不清楚的地方,命令会先用行业惯例兜底,但必须写进假设清单,把默认值摆到纸面上,而不是藏在生成过程里。

规格的骨架就是模板给的几节,用户场景与用户故事、功能需求与边界条件、关键实体、成功标准、假设。下面几节按这个顺序走下去。

二、用户故事写的是用户旅程,不是功能模块

模板的第一个强制章节是 User Scenarios & Testing,规格的其余部分都从这里长出来。写用户故事最容易走偏的地方,是把它写成模块或者任务,"实现 links 表"和"编写跳转接口"读起来像工作项,那属于 tasks.md。规格里的故事要回答的是谁为了什么目的做了什么事,一句话就能让不写代码的人听懂。

短链接服务的 P1 只有一条故事,任何拿到长网址的人,都可以提交网址得到一个短链接,再把链接分享出去,访问者打开短链接到达原来的页面。这句话把角色、动作和目的都交代清楚了,而且它本身就是一个完整的使用闭环,不需要别的故事配合。

模板给每条故事定了三个必填项,描述之外还有 Why this priority 和 Independent Test。Why this priority 要说明这条故事为什么排在这个位置,缺了它哪些东西会失去附着对象。短链接 P1 的理由很直白,没有创建和跳转,过期和管理都无从谈起。Independent Test 要求写清楚如何单独验证这条故事,模板的原话是每条故事都要能独立开发、独立测试、独立部署、独立演示,这四件事是判断一条故事切片是否合理的实际标准。

"独立"这两个字值得单独说清楚,它不是要求每条故事互不相关,而是要求实现一条故事时不必先把别的故事做完。短链接 P1 可以完全脱离过期和管理,先跑通提交、拿到短链、打开短链到达原页。P2 的过期要在 P1 的跳转上加一个判断,但它不影响 P1 单独成立。如果两条故事互相等着对方才能验证,模板会认为切片切错了,读者也该回头重新划边界。

模板不把验收场景单独列成一节,而是钉在每条故事下面,这是有意的。故事是主干,优先级、独立测试、场景、需求条目都是从这个主干上长出来的枝叶。主干如果歪了,后面挂上去的东西也很难长正。一条故事边界不清,挂上去的场景就会跟着含糊,测试不知道该断言什么,评审也说不清这条场景到底在验证谁的需求。

反过来看,写场景的过程也在回头检查故事切得对不对。短链接 P1 能顺手写出三个清晰的场景,是因为提交、跳转、拒绝非法输入都围着同一个闭环转。如果一条故事写着写着,发现要挂的场景属于另一条故事,或者场景里冒出了这条故事从没提过的角色和动作,那多半是切片出了问题,该调整的是故事本身,而不是硬把场景凑上去。

后面几节会一直回到这根主干上,规格先让故事和它的优先级定下来,再往外补场景和需求,顺序不能倒,因为每一层的内容都要从上一层长出来。

三、P1、P2、P3 是交付顺序,不是重要程度排名

模板要求给每条故事标一个优先级,P1 最关键,往后依次是 P2、P3。很多人会把优先级理解成"哪个更重要",它其实更接近"哪个先做"。规格里排在前面的故事会被先计划、先拆任务、先实现,所以优先级直接决定了第一批可交付的东西是什么。

判断 P1 有个简单的标准,把它单独做出来,这个功能是不是就已经能提供价值。短链接的 P1 是创建并访问,只做这一条,用户就能提交网址、拿到短链、打开短链到达原页,服务的最小闭环已经成立。P2 是链接过期,P3 是链接管理,两者都建立在 P1 之上,先做它们没有意义。三条故事在短链接项目里的排布是这样。

故事 优先级 独立交付的价值
创建并访问短链接 P1 提交网址、拿到短链、打开短链到达原页,服务的最小闭环
链接过期 P2 可以为临时用途创建会自动失效的链接
链接管理 P3 创建者能查看并删除自己的链接,服务可以长期使用

优先级还会影响后面的步骤,第 8 章的 /speckit.tasks 会按故事组织任务,P1 故事的任务单独组成一个可以交付的 MVP 切片。如果规格里把管理排在 P1,任务清单就会优先去做删除和授权,而这些恰恰是风险最高、也最依赖澄清结论的部分,第一版反而迟迟出不来。

排优先级时有两类常见错误。一种是把最难的排在最前,理由是"先把硬骨头啃了",但难度和交付价值是两件事,先做难的不等于先拿出可用的东西。另一种是把互相依赖的故事排成同一条,比如让链接管理先于创建存在,实际上没有链接可管。规格里的顺序应当顺着用户能拿到价值的时间线走,而不是顺着实现的难易或者团队的偏好走。

四、独立实现、独立测试与增量交付

模板在每条故事下面都留了 Independent Test 一栏,它要写的不是一个测试步骤,而是一条能证明这条故事可以单独验证的路径。短链接 P1 的写法是,只实现这一条故事,用户就能完成提交网址、拿到短链、打开短链到达原页的完整闭环。P2 的写法更具体一些,在 P1 已经可用的基础上,创建一条带有效期的链接,让时间越过有效期,验证它的行为与创建时的约定一致,同时确认没有设置有效期的链接不受影响。

这些描述看起来像在重复验收场景,其实它们的读者不同。验收场景写给测试,说明该断言什么。Independent Test 写给排期和评审的人,说明这条故事能不能单独拿出来交付。模板把"独立"展开成四件事,可以独立开发、独立测试、独立部署、可以演示给用户看,一份规格如果在某条故事上做不到这四点,说明这条切片的边界还需要调整。

把优先级和独立性放在一起看,就得到 SDD 想要的那种增量交付。
#mermaid-svg-oObcXDA0EIZyRkEt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oObcXDA0EIZyRkEt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oObcXDA0EIZyRkEt .error-icon{fill:#552222;}#mermaid-svg-oObcXDA0EIZyRkEt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oObcXDA0EIZyRkEt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oObcXDA0EIZyRkEt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oObcXDA0EIZyRkEt .marker.cross{stroke:#333333;}#mermaid-svg-oObcXDA0EIZyRkEt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oObcXDA0EIZyRkEt p{margin:0;}#mermaid-svg-oObcXDA0EIZyRkEt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oObcXDA0EIZyRkEt .cluster-label text{fill:#333;}#mermaid-svg-oObcXDA0EIZyRkEt .cluster-label span{color:#333;}#mermaid-svg-oObcXDA0EIZyRkEt .cluster-label span p{background-color:transparent;}#mermaid-svg-oObcXDA0EIZyRkEt .label text,#mermaid-svg-oObcXDA0EIZyRkEt span{fill:#333;color:#333;}#mermaid-svg-oObcXDA0EIZyRkEt .node rect,#mermaid-svg-oObcXDA0EIZyRkEt .node circle,#mermaid-svg-oObcXDA0EIZyRkEt .node ellipse,#mermaid-svg-oObcXDA0EIZyRkEt .node polygon,#mermaid-svg-oObcXDA0EIZyRkEt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oObcXDA0EIZyRkEt .rough-node .label text,#mermaid-svg-oObcXDA0EIZyRkEt .node .label text,#mermaid-svg-oObcXDA0EIZyRkEt .image-shape .label,#mermaid-svg-oObcXDA0EIZyRkEt .icon-shape .label{text-anchor:middle;}#mermaid-svg-oObcXDA0EIZyRkEt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oObcXDA0EIZyRkEt .rough-node .label,#mermaid-svg-oObcXDA0EIZyRkEt .node .label,#mermaid-svg-oObcXDA0EIZyRkEt .image-shape .label,#mermaid-svg-oObcXDA0EIZyRkEt .icon-shape .label{text-align:center;}#mermaid-svg-oObcXDA0EIZyRkEt .node.clickable{cursor:pointer;}#mermaid-svg-oObcXDA0EIZyRkEt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oObcXDA0EIZyRkEt .arrowheadPath{fill:#333333;}#mermaid-svg-oObcXDA0EIZyRkEt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oObcXDA0EIZyRkEt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oObcXDA0EIZyRkEt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oObcXDA0EIZyRkEt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oObcXDA0EIZyRkEt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oObcXDA0EIZyRkEt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oObcXDA0EIZyRkEt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oObcXDA0EIZyRkEt .cluster text{fill:#333;}#mermaid-svg-oObcXDA0EIZyRkEt .cluster span{color:#333;}#mermaid-svg-oObcXDA0EIZyRkEt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oObcXDA0EIZyRkEt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oObcXDA0EIZyRkEt rect.text{fill:none;stroke-width:0;}#mermaid-svg-oObcXDA0EIZyRkEt .icon-shape,#mermaid-svg-oObcXDA0EIZyRkEt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oObcXDA0EIZyRkEt .icon-shape p,#mermaid-svg-oObcXDA0EIZyRkEt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oObcXDA0EIZyRkEt .icon-shape .label rect,#mermaid-svg-oObcXDA0EIZyRkEt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oObcXDA0EIZyRkEt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oObcXDA0EIZyRkEt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oObcXDA0EIZyRkEt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 只实现 P1 也能上线
P1 创建并访问短链接

本身就是可交付产品
P2 链接过期
P3 链接管理
第一版可发布

P1 做完就能上线,用户在真实环境里开始用它,反馈回来的问题比在需求评审里空谈可靠得多。P2 和 P3 各自是在一个已经可用的系统上做加法,任何一步停下来,前面的成果都还成立。这和"等全部功能做完再一起发"是两种交付节奏,规格里的优先级和独立测试,正是为了让团队有能力选择前一种。

增量的边界靠验收场景固化下来。每做完一步,就把到这一步为止所有故事的场景跑一遍,确认新的改动没有破坏旧的行为,这是第 4 章那条行为驱动验证原则在功能层面的落点。

五、Given--When--Then 把验收写成可执行的场景

模板给每条故事配一组 Acceptance Scenarios,统一用 Given、When、Then 三个部分,给定什么前置状态,触发什么动作,应当观察到什么结果。第 3 章已经说明这条形式的用途,到了完整规格里,它还要多承担一项职责,就是让"完成"有客观判据。

短链接 P1 的三个场景是这样写的。

markdown 复制代码
1. **Given** 一个合法网址,**When** 用户提交它,**Then** 系统返回一个只含字母和数字的短链接
2. **Given** 一个已创建的短链接,**When** 访问者打开它,**Then** 浏览器到达创建时提交的原始网址
3. **Given** 一个格式不合法的网址,**When** 用户提交它,**Then** 系统拒绝创建并说明原因

第一条走正常路径,第二条验证跳转闭环,第三条处理失败输入。三个场景里没有出现任何技术名词,但它们已经足够具体,任何一个测试拿到第一条,都知道要构造一个合法网址、提交、然后断言返回的短码只含字母和数字。

写场景时要特别注意 Then 这一半。它必须是外部可以观察的结果,而不是内部的某次调用或者某个字段被写成了什么值。"系统返回一个只含字母和数字的短链接"是可以观察的,"系统向 links 表插入了一行记录"不是,后者把实现绑进了验收条件,换个存储就要改规格。

一条故事通常需要覆盖三类路径。正常路径确认主要流程能走通,失败路径确认错误输入被正确拒绝,边界路径确认临界情况不会漏掉。P2 的三个场景就是按这个思路排的,有效期之内打开、有效期之后打开、没有设置有效期,最后一条专门防止"加了过期逻辑,顺手把永久链接也判过期了"这类回归。

场景的数量不必刻意凑,模板不要求每条故事写满三条,够覆盖主要路径和已知边界就行。真正要避免的是只有正常路径,第 1 章那条 slug 碰撞的路径之所以隐身了两周,就是因为它从来没有作为一条场景存在过。

六、功能需求和边界条件要有编号、可验证

用户故事和验收场景之后是 Requirements 一节,它的第一个子节是功能需求,每条一个编号,短链接项目从 FR-001 开始。编号不是为了好看,它是后面追踪的锚点,任务清单会引用它,分析报告会用它检查覆盖率,代码评审时会问这条需求由哪个测试守着。

功能需求的写法有三个要求。用 MUST 这样的确定性措辞,不用"尽量"、"应该"。"系统必须为每个成功提交的合法网址生成唯一短链接"是一条需求,"系统应尽量保证短链接唯一"不是,后者无法判断是否被满足。每条需求要可测,测试能根据它构造出通过和失败两种情况。需求还要技术无关,不能出现框架、存储和接口路径。

功能需求从验收场景里提炼,而不是另起一炉。短链接 P1 的三个场景对应出 FR-001 到 FR-004 四条需求,生成唯一短链接、短码固定六位、打开链接到达原始网址、非法网址被拒绝。读完场景再读需求,可以一条条对回去,这个双向可追溯是规格质量的一部分,第 9 章的 /speckit.analyze 会专门检查它。

规格里还有一类内容需要单独处理,就是暂时没有答案的问题。模板允许用 [NEEDS CLARIFICATION] 标记出来,但限制总数不超过三条,而且必须挂在一条具体需求后面。短链接项目里有两处用到了它。

markdown 复制代码
- **FR-008**: 短链接超过有效期之后,必须不再把访问者带到原始网址
  [NEEDS CLARIFICATION: 过期访问是完全不跳转,还是跳转到一个说明页,两种行为对访问者的影响不同,需产品确认]
- **FR-012**: 查看管理信息和删除短链接的操作必须仅限于该链接的创建者
  [NEEDS CLARIFICATION: 创建短链不需要登录,创建者身份如何界定、凭什么证明,需产品确认]

FR-008 的标记说明需求本身已经确定,过期之后不再到达原始网址,未定的是用户看到什么。FR-012 的标记更靠上游一些,它暴露了一个之前没人注意到的前提,创建短链是匿名行为,那么"创建者"这个身份究竟靠什么成立。两个标记会在第 6 章被正式提出来,由人给出结论后写回规格。

需求之后是 Edge Cases 一节,它列的是那些正常演示永远走不到的路径,重复提交同一个网址怎么处理,两个请求撞上同一个短码怎么办,短码是否区分大小写,协议不是 http 或 https 的输入算不算合法,打开一个从未创建过的短码看到什么。这些问题不必都变成独立需求,有些可以用一条默认假设回答,有些会升级成澄清问题,但必须先被写下来,不写下来它们就不会被处理。

七、关键实体、假设和依赖只写概念,不写结构

Key Entities 一节描述这个功能涉及的数据概念。它和数据库设计是两回事,规格里写的是这个事物代表什么、有哪些关键属性、彼此什么关系,不写表名、字段类型和索引。短链接项目在这里只有两个实体,短链接和创建者。短链接包含原始网址、短码、创建时间和可选的有效期,并且明确写了一条约束,短码与原始网址的对应关系一旦建立就不得被静默改写。创建者是短链接的管理主体,查看管理信息和删除链接时,用它判断请求方是否有权操作。

这两段话都不涉及存储,但已经足以让计划阶段决定数据模型该长什么样。实体描述和需求条目之间有一处容易被当成重复的地方。短码不得被静默改写既出现在 FR-007,也出现在实体描述里,这不是重复,实体描述的是数据的长期不变量,需求描述的是这一版功能必须保证的行为。第 4 章的宪法要求短码映射正确,这条约束因此在两个层次上都出现了一次。

Assumptions 一节登记的是生成时替你做的默认选择。第 1 章说过,凡是没有明说的地方,模型都会自己补一个答案而且不会主动声明,模板用这一节把默认值逼到纸面上。短链接项目的假设包括这些,每次提交合法网址都生成新链接,短码区分大小写,只接受 http 和 https,未设置有效期的链接一直有效直到被删除,创建者身份的判定方式留到澄清阶段。

这份假设清单的价值在于它把"系统会怎么表现"变成了可以逐条检查的句子。评审的人看到"短码区分大小写",如果和预期不符,现在就能指出,而不是等到用户投诉 ABC123 打不开 abc123 的链接才发现。假设不是永久的,后面阶段发现它不成立,就应该回到规格修改,而不是在代码里悄悄绕开。

依赖通常和假设放在一起写,短链接功能没有需要复用的外部系统,唯一的需要是持久保存短码与网址的对应关系,规格只陈述这个需要,具体用什么满足交给计划阶段。

八、成功标准必须可量化,而且站在使用者一侧

Success Criteria 是模板的最后一个强制章节,它回答的是这个功能做好了没有,判据必须可量化、技术无关、站在使用者一侧。可量化意味着有数字和观察点,"体验良好"不算。技术无关意味着不能出现框架、存储和内部指标。站在使用者一侧意味着这些数字要描述使用者能观察到的结果,而不是系统的内部状态。

短链接项目的六条成功标准里,前两条和第 3 章给出的一致,95% 的首次使用者不看帮助就能在一分钟内完成第一个链接的创建,以及访问者点击短链接后 95% 的情况下 1 秒内开始到达目标页面。后四条覆盖了拒绝非法输入、管理操作、过期与删除之后的行为,以及跳转的正确性。

这里要区分成功标准和内部实现指标。接口 p99 延迟低于 200 毫秒、缓存命中率 90%、连接池使用率这些数字看起来精确,却都是使用者观察不到的,它们属于 plan.md 的性能考量,不属于规格的成功标准。第 4 章的宪法专门有一条可测量性能原则,要求这类指标必须同时给出负载、环境、百分位和目标,规格阶段给出的则是用户视角的结果。

数字必须有来源。"1 分钟内完成创建"来自真实的首次使用体验要求,"1 秒内开始跳转"来自用户对点击链接的容忍度。没有依据时,正确的位置是澄清问题或者计划的测量方案,不要在规格里编一个精确数字,精确的假象比形容词更危险,因为它看起来像经过了论证。

九、一份完整的 spec.md

把前面几节拼起来,短链接服务的第一份规格如下。文件落在 code/shortlink/specs/001-short-url/spec.md,和第 4 章那份 constitution.md 同属 code/shortlink 这个项目。

markdown 复制代码
# Feature Specification: ShortLink 短链接服务

**Feature Branch**: `001-short-url`

**Created**: 2026-10-02

**Status**: Draft

**Input**: User description: "短链接服务。任何拿到长网址的人都可以提交网址,得到一个可以直接分享的短链接;访问者打开短链接,到达原来的页面。创建时可以选择设置有效期。创建者可以查看并管理自己创建过的短链接。"

## User Scenarios & Testing *(mandatory)*

### User Story 1 - 创建并访问短链接 (Priority: P1)

任何拿到长网址的人,都可以提交网址得到一个短链接,再把链接分享出去。访问者打开短链接,到达原来的页面。

**Why this priority**: 这是服务存在的最小理由,没有它,过期、统计等功能都没有附着对象。

**Independent Test**: 只实现这一条故事,用户就能完成提交网址、拿到短链、打开短链到达原页的完整闭环。

**Acceptance Scenarios**:

1. **Given** 一个合法网址,**When** 用户提交它,**Then** 系统返回一个只含字母和数字的短链接
2. **Given** 一个已创建的短链接,**When** 访问者打开它,**Then** 浏览器到达创建时提交的原始网址
3. **Given** 一个格式不合法的网址,**When** 用户提交它,**Then** 系统拒绝创建并说明原因

---

### User Story 2 - 链接过期 (Priority: P2)

创建短链接时,创建者可以指定一个有效期。有效期用于临时分享,比如一场活动或者一份限时材料,活动结束之后,链接不应该继续把访问者带到目标页面。

**Why this priority**: 过期是创建之后最常被追加的能力,但它不改变提交和跳转这条基本闭环是否成立,因此排在 P1 之后。

**Independent Test**: 在 P1 已经可用的基础上,创建一条设置了有效期的短链接,模拟时间越过有效期,验证它的行为与创建时的约定一致,同时确认没有设置有效期的链接不受影响。

**Acceptance Scenarios**:

1. **Given** 创建时设置了有效期的短链接,**When** 访问者在有效期之内打开它,**Then** 访问者正常到达原始网址
2. **Given** 创建时设置了有效期的短链接,**When** 访问者在有效期之后打开它,**Then** 不再到达原始网址
3. **Given** 一条没有设置有效期的短链接,**When** 任何时候打开它,**Then** 访问者正常到达原始网址

---

### User Story 3 - 链接管理 (Priority: P3)

创建者可以查看自己创建过的短链接及其当前状态,也可以删除不再需要的链接。删除之后,这条链接不再可用。

**Why this priority**: 管理能力让服务可以长期使用,但它建立在创建和过期都已经成立的基础上,而且涉及身份与授权,风险更高,所以排在最后。

**Independent Test**: 只实现 P1 和 P3,创建者可以列出自己创建的链接,删除其中一条,并确认被删除的链接不再跳转,其余链接不受影响。

**Acceptance Scenarios**:

1. **Given** 已经创建过若干短链接的创建者,**When** 他查看自己的链接列表,**Then** 列表只包含他创建的链接
2. **Given** 一条属于当前创建者的短链接,**When** 创建者删除它,**Then** 这条链接不再把访问者带到原始网址
3. **Given** 一条不属于当前用户的短链接,**When** 他尝试删除它,**Then** 系统拒绝该操作

---

### Edge Cases

- 提交的目标网址和已有链接相同时,是复用已有短链接还是生成一条新的
- 两个请求恰好生成同一个短码时,系统如何处理,会不会覆盖已经存在的映射
- 短码在字母大小写上是否区分,`ABC123` 和 `abc123` 是不是同一条链接
- 提交的字符串不是合法网址,或者协议不是 `http`、`https` 时会发生什么
- 打开一个从未被创建过的短码时,访问者看到什么
- 有效期恰好等于当前时刻时,链接算有效还是已经过期
- 被删除的短链接被再次打开时,访问者看到什么

## Requirements *(mandatory)*

### Functional Requirements

- **FR-001**: 系统必须为每个成功提交的合法网址生成唯一短链接
- **FR-002**: 短链接长度固定为 6 位,只包含字母和数字
- **FR-003**: 打开短链接时,系统必须把访问者带到原始网址
- **FR-004**: 网址不合法时系统必须拒绝创建,并告知原因
- **FR-005**: 创建短链接时,必须允许创建者可选地指定有效期
- **FR-006**: 没有指定有效期的短链接在删除之前必须一直可用
- **FR-007**: 一条短链接一旦创建,其指向的原始网址不得被静默改写成另一个网址
- **FR-008**: 短链接超过有效期之后,必须不再把访问者带到原始网址 [NEEDS CLARIFICATION: 过期访问是完全不跳转,还是跳转到一个说明页,两种行为对访问者的影响不同,需产品确认]
- **FR-009**: 已删除、格式非法、以及从未被创建过的短码,必须产生彼此不同且对访问者可以理解的结果
- **FR-010**: 创建者必须能够查看自己创建过的短链接及其当前状态
- **FR-011**: 创建者必须能够删除自己创建的短链接
- **FR-012**: 查看管理信息和删除短链接的操作必须仅限于该链接的创建者 [NEEDS CLARIFICATION: 创建短链不需要登录,创建者身份如何界定、凭什么证明,需产品确认]

### Key Entities

- **短链接(Short Link)**: 一个原始网址及其对应的短码,包含原始网址、短码、创建时间和可选的有效期。短码与原始网址的对应关系一旦建立就不得被静默改写,每个短码在任一时刻最多对应一条有效链接。
- **创建者(Creator)**: 提交网址的一方,是短链接的管理主体。查看管理信息和删除链接时,用它判断请求方是否有权操作这条链接。

## Success Criteria *(mandatory)*

### Measurable Outcomes

- **SC-001**: 95% 的首次使用者不看帮助,就能在 1 分钟内完成第一个短链接的创建
- **SC-002**: 访问者点击短链接后,95% 的情况下 1 秒内开始到达目标页面
- **SC-003**: 提交的非法网址 100% 被拒绝,创建者能立即看到拒绝原因
- **SC-004**: 创建者能在 30 秒内定位并删除自己创建的一条短链接
- **SC-005**: 已过期或者已删除的短链接被再次打开时,100% 不再把访问者带到原始网址
- **SC-006**: 任意一次成功跳转,都不会把访问者带到一个创建者没有指定的网址

## Assumptions

- 使用者具备基本的网络环境,通过浏览器或者命令行等常规方式访问
- 一条短链接只属于一个创建者,本功能不涉及多人共享或者团队管理
- 每次提交合法网址都会生成一条新的短链接,即使原始网址与已有链接相同
- 短码区分大小写,`ABC123` 与 `abc123` 视为两条不同的链接
- 目标网址只接受 `http` 和 `https`,其他协议按非法网址拒绝
- 未指定有效期的短链接一直有效,直到被创建者删除
- 创建者身份的判定方式将在澄清阶段确定,本规格暂不假设具体做法

读完这份规格,有三处和前面讲过的规则直接对得上。整份文件没有出现框架、数据库、缓存和接口路径,但 Agent 拿到它之后不会再替你决定过期行为,因为那个问题被标成了 FR-008 等待回答,也不会只做正常路径就宣布完成,因为非法输入和拒绝删除都有场景盯着。规格里的编号是可以被后面命令直接引用的锚点,FR-001 到 FR-012 会在任务清单和实现里被逐条对照,SC-001 到 SC-006 则给"这个功能做好了没有"一个不由生成方自己宣布的答案。

还有两个 [NEEDS CLARIFICATION] 标记留在文件里,规格的自查清单因此没有全绿。这不是缺陷,是模板刻意保留的出口。规格宁可带着明确标注的问题进入下一阶段,也不要带着一个自信的猜测,赌它恰好是产品想要的。

十、小结

这一章把短链接项目的第一份功能规格落到了纸面,文件在 code/shortlink/specs/001-short-url/spec.md。我们用 /speckit.specify 从一句自然语言描述出发,得到三条按 P1、P2、P3 排序的用户故事,每条故事可以独立开发和测试,只实现 P1 就是一个能上线的闭环。围绕故事写出的验收场景全部是 Given、When、Then 形式,它们既是验收的判据,也是后面测试用例的来源。功能需求、边界条件、关键实体、假设和可量化的成功标准把这层意图补完整,整份规格里没有出现任何技术选型。

命令还在文件里留下了两个 [NEEDS CLARIFICATION],一个关于过期之后的访问行为,一个关于匿名创建下创建者身份如何界定。它们都是产品决定或者安全边界,不该由模型在生成时静默填掉。下一章专门处理它们,用 /speckit.clarify 把这两处歧义变成正式的问题提出来,再把结论写回规格。

相关推荐
wespaul1 小时前
知识主题 + Milvus:把业务术语喂给模型的三步离线构建
人工智能·milvus
styshoo1 小时前
[snapshot]1-snapshot内部逻辑
人工智能
yi0111 小时前
DAY28: LeetCode 111. 二叉树的最小深度
人工智能·笔记·python·算法·leetcode·二叉树·递归
方方洛2 小时前
ai-agent教程-05-上下文工程
人工智能·llm·agent
阿里云大数据AI技术2 小时前
从原始Ego视频到训练就绪:用 EMR Serverless Spark 上的 Ray 提效具身数据生产管线
人工智能·spark
智圣新创012 小时前
全国高校第二课堂数字化建设供需适配指南:从政策要求到落地运营的全链路效能提升路径
人工智能
土星云SaturnCloud2 小时前
土星云边缘计算+AI视觉+视频存管:连锁汽修数字化升级实践
服务器·人工智能·ai·边缘计算
糖糖单片机设计2 小时前
基于 STM32 的配网变压器涌流抑制器设计与实现
人工智能·stm32·嵌入式硬件·51单片机·语音识别
倔强的小石头_2 小时前
从Go+SQLite编译到浏览器开局:landlord部署、AI对局与固定地址实战
人工智能·golang·sqlite