# 需求拆解与方案设计

## 从功能描述到可验收需求

“做一个 AI 助手”不是实现规格。它没有说明用户是谁、输入是什么、成功是什么、失败时怎么办。作为偏前端的全栈工程师，你需要把交互、数据、接口和执行状态连接起来。目标是 L2：能把一句需求拆成一个可实现、可验证的纵向功能，而不是先列一大串技术名词。

前置是系统边界和接口契约。本章以“上传制度文档后回答员工问题”为例，说明产品决策如何影响代码。即使 AI 协助写实现，这些决策仍必须显式记录，否则它会补出看似合理但彼此矛盾的行为。

## 先定义用户任务和错误成本

员工想知道报销需要哪些材料，产品目标是让他获得有出处且适用当前制度的说明。这里的成功不是“生成了流畅文字”，而是引用正确文件、没有遗漏关键条件、权限正确、答复时间可接受。错误成本决定是否需要人工复核或限制自动执行。

先写三类样例：正常问题、无资料的问题、权限不允许的问题。再明确哪些需求使用普通代码解决，例如精确查制度版本；哪些需要模型处理，例如自然语言归纳。模型并不是每条路径的必选组件。

## 把一条需求映射到四层

| 层次 | 需要确定的内容 | 示例 |
| --- | --- | --- |
| 用户界面 | 用户能看到和操作什么 | 上传、解析状态、回答、引用、重试 |
| 接口契约 | 请求、响应、身份和错误 | 返回 documentId 与 processing 状态 |
| 数据模型 | 要长期保存哪些事实 | 所有人、版本、解析状态、索引版本 |
| 执行流程 | 谁执行，何时完成，失败怎么办 | 后台解析、发布索引、失败可重试 |

如果 UI 需要显示“上次解析失败原因”，后端就必须保存可展示的失败类别。如果需要查看旧回答的出处，就需要记录当时使用的文档版本。原型中的一个小标签可能意味着真实的数据生命周期，而不是只加一个枚举。

## 用状态表消除含糊

下面的状态设计是讨论模板，不是任何框架的内置格式。状态描述的是知识资料是否可使用，不能把上传进度百分比混进业务枚举。

```text
uploaded    原文件已保存，等待安排解析
processing  正在解析、分块或建立索引
ready       当前版本已发布，可用于检索
failed      当前处理失败，可查看原因并重试
deleted     已撤销访问，相关索引进入清理流程
```

每个转移都要定义触发者与前提。客户端可以申请重试，但不能直接把文档改为 ready；后台任务完成后由服务端更新。删除时应先撤销访问，再清理存储，避免异步清理过程中仍能检索到已删除资料。

版本更新还需要明确：新版本处理中，旧版本是否继续可用？如果继续，应把“文档版本”与“当前发布版本”分开。这个选择影响用户体验、一致性、存储和回滚，不能等代码写完再补。

## 用验收场景约束实现

下面是一组可复制到需求文件中的验收文本，不需要运行。它帮助测试覆盖用户行为，而不是覆盖某个实现细节。

```gherkin
Feature: 使用有权限的资料回答问题

Scenario: 当前资料包含答案
  Given 用户 Alice 可以阅读 ready 状态的文档 A
  When Alice 提问文档 A 中明确解释的问题
  Then 回答应附带文档 A 的可打开引用
  And 引用应指向回答所使用的文档版本

Scenario: 用户没有资料权限
  Given 用户 Bob 无权阅读文档 A
  When Bob 提问与文档 A 相关的问题
  Then 文档 A 不应进入发送给模型的上下文
  And 回答与日志不应泄露文档 A 的私人内容

Scenario: 请求中断
  Given 回答已经产生部分文本
  When 客户端连接断开
  Then 已保存文本应标记为不完整
  And 页面应允许发起一次明确的新尝试
```

Given 定义条件，When 定义触发，Then 定义可观察结果。这里没有指定必须使用哪个向量库或 Agent 框架，因为验收需要约束业务，而不是把技术选择当成功标准。涉及模型时，某些标准需要人工评分规则，不能全部化成精确字符串比较。

## 拆成可以交付的纵向切片

第一个切片可以只支持一种文本文件：上传、保存归属、后台处理、状态查询、页面展示。第二个切片增加对单文档提问与引用。第三个切片增加多文档检索和版本更新。每个切片都包含必要的权限、失败状态和日志，形成可演示的闭环。

不要按“先写所有表、再写所有 API、最后写所有页面”把风险延后。纵向切片能尽早发现接口不匹配、状态无法展示、检索证据不够等问题。UI 可以先使用契约一致的模拟数据，但要明确模拟服务与真实服务的切换点。

## 方案记录与范围控制

方案至少写清选项、选择理由和代价。例如第一版使用关系数据库和简单关键词检索，是因为资料少且需要先验证交互；以后加入向量检索的触发条件是同义问题无法召回，而不是“AI 项目都应该有向量数据库”。

把没有决定的事情单列出来：账号体系、文档权限、保留期限、模型供应商、允许的自动操作。影响安全或数据一致性的未知项需要确认；按钮间距等可逆实现细节可以由工程师按已有规范决定。不要把所有小问题都抛回用户，也不要擅自决定真正的产品语义。

## 常见遗漏

只设计成功页，导致首次进入、无资料、解析失败和模型拒答时不知道显示什么。只定义创建，不定义更新和删除，导致旧知识长期残留。只验收正确答案，不验收“不知道”，导致系统在证据不足时仍自信编造。只考虑一个用户，导致权限和并发在后期大面积返工。

需求可以逐步增加，但每一步的状态与边界必须自洽。个人项目也应该写最小需求记录，它能帮助你和 AI 在数天之后恢复上下文。

## 从一句模糊需求推导出可以开发的规格

下面完整推导一个案例。它是用于学习的假设产品，不代表某家公司的真实规定。假设需求方说：“把公司的制度上传，让员工问 AI；制度有更新时替换一下，回答要准确。”先不要问用哪个模型，也不要马上建 `documents` 表。第一步是找出这句话包含的业务承诺。

“公司的制度”可能包含公开制度和仅人事可见的文件。“员工”可能分正式员工、外包和管理员。“替换”可能意味着立即隐藏旧版，也可能意味着新版处理完才替换。“准确”可能意味着回答符合原文，也可能还要符合生效日期与员工所属地区。每个词都会改变查询条件和界面行为。

### 第一步：识别真正会改变实现的未知项

不要把所有未知都当成阻塞问题。问题是否需要确认，取决于答案会不会改变业务结果、权限、数据保存或不可逆操作。主题色深浅通常可按现有规范决定；员工能否阅读历史版本则必须在实现前讲清楚。

| 原始说法 | 隐藏的问题 | 本案例采用的决定 | 对实现的直接影响 |
| --- | --- | --- | --- |
| 员工都能问 | 是否包含所有文件 | 普通员工只读公开制度 | 检索前过滤资料权限 |
| 管理员上传 | 谁是管理员 | 角色来自登录后的服务端会话 | 不信任请求体中的角色 |
| 替换制度 | 新版处理时旧版怎么办 | 旧版继续服务，新版就绪后切换 | 分开保存当前发布版本与候选版本 |
| 删除制度 | 已生成的回答怎么办 | 撤销资料访问，历史回答保留但引用不可再打开 | 删除影响检索和引用访问，不假装能收回已读信息 |
| 回答准确 | 什么算有依据 | 结论必须被当前有权访问的片段支持 | 记录检索证据并建立人工评分集 |
| 上传成功 | 文件存好还是可问答 | 分成“已上传”和“可检索”两个结果 | 前端分别展示存储状态与解析状态 |
| 文件较大 | 多大、超限怎么办 | 第一版仅支持不超过明确上限的文本文件 | 前后端限制一致，服务端最终执行限制 |

你不必在第一版设计所有未来情形，但必须让选定情形自洽。例如暂不支持历史版本浏览是可以的，仍要记录回答引用了哪个版本，否则更新后无法解释旧回答。暂不支持多租户也是可以的，但不能声称单用户 demo 已验证企业租户隔离。

### 第二步：把“好用”转成可观察行为

“员工可以方便地查询制度”无法直接测试。把它改成：“已登录员工输入一个问题后，能看到处理阶段、回答正文和对应资料的文件名；点击引用可以打开当时使用且当前仍有权访问的原文位置。”这时，界面、数据和权限要求就都有了观察点。

还有一些行为不是功能按钮，却属于产品承诺。例如用户关掉页面后文件解析继续执行；同一次上传重试不会生成两个候选版本；没有可支持答案的资料时显示依据不足；模型故障不能显示为“没有相关制度”。这些区别影响用户采取什么行动，不能用一个通用错误气泡替代。

### 第三步：为第一版写清不包含什么

范围排除应该解释边界，而不是逃避必要的失败处理。本案例第一版不支持扫描 PDF 的 OCR，不支持自动创建审批单，不支持跨组织共享；但仍然支持上传失败、解析失败、无权限和重复请求。后一组属于已有功能的完整性，不属于可随意推迟的“高级功能”。

好的范围说明能帮助你拒绝无关扩张。例如有人提出“顺便让 AI 自动发送审批邮件”，你可以指出它新增了外部副作用、收件人校验、确认、幂等和审计，不只是添加一个工具函数。把新增成本写清楚，再决定是否进入下一版本。

## 逐字段建立前端到数据库的映射

字段映射的用途是防止前端、接口和存储各自使用相似但不等价的词。下面的表可以作为实际开发前的起点。字段名是本案例的约定，不是行业强制标准。

| 页面字段或行为 | 接口字段 | 持久化事实 | 校验与空值规则 |
| --- | --- | --- | --- |
| 文档名称 | `title` | documents.title | trim 后非空；不把空字符串变成“未命名”而不告知用户 |
| 原始文件名 | `originalFileName` | versions.original_file_name | 用于展示，不作为服务器磁盘路径 |
| 文件类型 | `mediaType` | versions.media_type | 由服务端检查，不能只相信扩展名 |
| 文件大小 | `byteSize` | versions.byte_size | 非负整数；与实际接收字节数一致 |
| 上传人 | `createdBy` | versions.created_by | 来自会话，不由表单填写 |
| 可见范围 | `visibility` | documents.visibility | 仅允许约定枚举；修改必须鉴权 |
| 当前可检索版本 | `activeVersionId` | documents.active_version_id | 首次处理未完成时可为 null |
| 新版处理状态 | `candidate.status` | versions.status | 与当前发布版相互独立 |
| 失败原因 | `candidate.errorCode` | versions.error_code | 稳定业务码；不返回内部堆栈 |
| 可否重试 | `candidate.retryable` | 由状态与错误规则计算 | 不是客户端自行推断的常量 |
| 引用页码或位置 | `citations[].location` | chunks.source_location | 格式因文件类型不同，需注明单位 |
| 回答完成状态 | `completionState` | messages.completion_state | complete、partial 等枚举，不能由正文是否非空推断 |
| 一次生成尝试 | `attemptId` | attempts.id | 每次重新生成都有新标识 |
| 追踪请求 | `requestId` | 日志关联字段 | 不等同于会话 ID 或业务幂等键 |

注意表里有些字段不需要直接存储。例如 retryable 可以由失败码和当前版本状态计算。冗余保存会引入同步问题：状态已经恢复就绪，旧 retryable 仍为 true。另一些字段必须存下来，例如产生旧回答时使用的版本 ID，不能事后用当前发布版补填。

### 什么叫“同名但不同义”

前端的 `status` 可能指上传控件状态，后端的 `status` 指解析任务状态，数据库的 `status` 指文档是否删除。如果直接复用一个字段，上传完成就可能误显示“资料可用”。给不同领域对象各自命名和定义，即使字段名都叫 status，也必须通过对象边界区分。

还要明确时间、金额和计数的单位。`duration: 5` 是秒还是毫秒？`size: 100` 是字节还是 KiB？`updatedAt` 是用户修改时间，还是后台最后一次处理时间？这些细节不会被 TypeScript 自动识别，却会直接造成错误展示和错误排序。

## 把状态图写成可以执行检查的转移规则

只有状态列表还不够，还需要事件、前置条件和产生的副作用。下面只描述一个候选版本的处理，不把文档发布指针混进同一个枚举。

| 当前状态 | 事件 | 下一状态 | 必须同时成立的条件 |
| --- | --- | --- | --- |
| uploaded | 开始处理 | processing | 已领取任务，版本未撤销 |
| processing | 处理成功 | ready | 解析与索引已经完整保存 |
| processing | 处理失败 | failed | 保存稳定错误码与尝试信息 |
| failed | 请求重试 | uploaded | 错误允许重试，版本仍有效 |
| uploaded / processing / failed / ready | 撤销版本 | deleted | 立即不再允许访问和发布 |
| ready | 发布为当前版 | ready | 另一个事务更新文档的 activeVersionId |

发布事件没有改变版本的 ready 状态，因为“准备好了”和“当前正在服务”是不同事实。一个历史版本仍然可以是 ready，但不再是当前发布版本。模型准确性排查经常需要知道这两个事实，不能只看到一行 `status=success`。

### 可运行实验：用状态契约阻止非法转移

保存为 `document-transition.mjs`，运行 `node document-transition.mjs`。这是零依赖 Node 示例，用于验证业务规则；它不包含数据库事务或真正的任务队列。

```js
import assert from 'node:assert/strict';

const rules = {
  uploaded: { start: 'processing', remove: 'deleted' },
  processing: { succeed: 'ready', fail: 'failed', remove: 'deleted' },
  failed: { retry: 'uploaded', remove: 'deleted' },
  ready: { remove: 'deleted' },
  deleted: {},
};

function transition(version, event) {
  // 真实服务端还要先确认调用者身份、资源权限和任务所有权。
  const next = rules[version.status]?.[event.type];
  if (!next) throw new Error(`ILLEGAL_TRANSITION:${version.status}:${event.type}`);
  if (event.type === 'retry' && !version.retryable) {
    throw new Error('NOT_RETRYABLE');
  }
  if (event.type === 'succeed' && !event.indexCommitted) {
    throw new Error('INDEX_NOT_READY');
  }
  if (event.type === 'fail' && !event.errorCode) {
    throw new Error('ERROR_CODE_REQUIRED');
  }
  return {
    ...version,
    status: next,
    revision: version.revision + 1,
    errorCode: event.type === 'fail' ? event.errorCode : null,
    retryable: event.type === 'fail' ? Boolean(event.retryable) : false,
  };
}

let version = { id: 'v2', status: 'uploaded', revision: 1,
  errorCode: null, retryable: false };
version = transition(version, { type: 'start' });
assert.equal(version.status, 'processing');
assert.throws(() => transition(version, { type: 'succeed' }), /INDEX_NOT_READY/);
version = transition(version, { type: 'fail', errorCode: 'TEMPORARY_STORAGE', retryable: true });
version = transition(version, { type: 'retry' });
version = transition(version, { type: 'remove' });
assert.equal(version.status, 'deleted');
assert.throws(() => transition(version, { type: 'start' }), /ILLEGAL_TRANSITION/);
console.log('通过：索引未提交不能就绪，删除后不能重新启动');
```

`revision` 表示状态版本，方便讨论并发更新。这个纯函数里增加数字并不会自动解决数据库竞争。真实数据库更新可以用“旧 revision 必须等于预期值”作为条件，影响行数为 0 时说明状态已被其他操作改变。你需要重新读取或拒绝过期操作，而不是覆盖更新。

这个例子也说明业务函数的测试价值：你可以不启动页面和数据库，先验证规则内部是否矛盾。接着再用集成测试确认数据库条件更新真正实现了这些规则。两个层次都要存在，不能让纯函数测试替代并发数据库实验。

## 一次版本更新如何跨越多个组件

假设文档 A 的 v1 正在服务，管理员上传 v2。服务端先把原文件与版本记录可靠保存，返回 v2 的标识和 uploaded 状态；后台任务领取后改为 processing，解析文本、切块并生成索引。只有所有必需部分准备好，v2 才能进入 ready。

切换发布指针时，服务端在事务中检查文档没有被删除、v2 仍属于文档 A、操作者仍有权限、当前版本没有被另一次更新改变。条件满足后，将 activeVersionId 从 v1 改为 v2。旧索引的清理可以异步进行，但新查询必须按发布指针选择版本，不能把所有 ready 版本混在一起检索。

### 为什么不能“解析一块就立刻对外可见”

假设新制度前半部分写“适用所有员工”，后半部分写“某地区除外”。如果索引只建立了一半就参与问答，模型可能给出缺少例外条件的结论。原子发布的目的是让一次查询看到一个一致的知识版本，而不只是让数据库字段好看。

### 删除与解析成功同时发生时怎么办

管理员撤销文档，后台任务此时刚好完成。如果任务只执行 `UPDATE status='ready'`，它可能把已删除版本重新激活。状态更新必须带上期望的前置条件，例如只有 processing 且未撤销时才能改 ready；发布指针也必须检查文档仍有效。这个竞争条件应写进需求验收，而不是靠一个开发者“记得注意”。

### 数据库写入成功，但任务没有入队怎么办

数据库事务和外部队列发送不是同一个原子操作。直接先写库再入队，中间进程崩溃会留下无人处理的记录；先入队再写库，消费者可能读取不到记录。一个常见解决方向是在同一数据库事务中写入待发布任务记录，由独立过程投递队列并标记结果，也就是 outbox 思路。

第一版也可以不用外部队列，直接让后台工作者从数据库领取任务，但仍需租约、重试和幂等。需求只要求可靠处理，不要求某个特定中间件。选型应服务于这个可靠性条件，而不是反过来。

## 把接口样例写到前端能直接接入的程度

以下 JSON 是完整合法对象，可以复制到 mock 数据中。它们是案例契约，不代表现有服务器已经实现。上传接口成功表示资源已登记，并不表示可检索。

```json
{
  "documentId": "doc-a",
  "activeVersionId": "v1",
  "candidate": {
    "versionId": "v2",
    "status": "uploaded",
    "errorCode": null,
    "retryable": false
  },
  "requestId": "req-upload-2"
}
```

前端收到这个对象后显示“新版已上传，正在等待处理；当前问答仍使用旧版”。如果 activeVersionId 为 null，则应显示“文件已上传，处理完成后可以提问”。同一个候选状态，因为是否存在旧版，用户可执行的操作会不同；这就是组合状态，不适合只靠一个字典映射文本。

查询失败时，定义稳定错误对象。例如用户能修正输入的错误可以包含字段名；内部依赖失败不要把原始堆栈传出去。下面的 error code 用于程序分支，message 用于用户说明，两者不要互相替代。

```json
{
  "error": {
    "code": "DOCUMENT_PROCESSING",
    "message": "资料还在处理中，请稍后再试。",
    "retryable": true
  },
  "requestId": "req-question-7"
}
```

同一个 retryable 在不同接口可能代表不同动作。它可能表示“稍后重新查询”，不表示“重新上传文件”。因此接口文档应写清允许重试的方法、是否复用幂等键，以及服务器是否提供重试时间。一个布尔值没有足够信息替你完成这些产品语义。

### 为什么接口文档需要说明失败时机

HTTP 响应头尚未发送时，可以返回正常错误码；开始流式回答后，只能按流协议发送错误事件或结束连接。前端要区分“请求没有开始”“得到部分内容后失败”和“成功完成但保存历史失败”。这些状态影响提示、重试和历史记录展示，不能统称为请求失败。

业务操作尤其需要说明“结果未知”。例如创建任务时网络断开，不能直接说创建失败，因为数据库可能已经提交。应查询 operationId 或用相同幂等键恢复结果。需求中承认“未知”状态，往往比强行二分成功与失败更准确。

## 把异常矩阵变成可执行验收计划

验收应包含测试数据、操作、预期结果和观察证据。下面的表不仅给出场景名称，还告诉你去哪里确认结果。它可以直接转成集成测试与端到端测试的任务列表。

| 场景 | 具体操作 | 必须观察到的结果 | 证据位置 |
| --- | --- | --- | --- |
| 首次上传 | 上传有效文本 | 有文档 ID，尚不可检索 | API、数据库与页面状态 |
| 新版处理中 | v1 就绪后上传 v2 | 问答仍使用完整 v1 | 本次检索版本记录 |
| 文件超限 | 发送超过上限的请求 | 及时拒绝，不继续消耗解析资源 | HTTP 结果与任务数量 |
| 越权读取 | Bob 请求 Alice 私有文档 | 不返回原文和摘要 | 响应与模型调用日志 |
| 越权检索 | Bob 提问私有资料内容 | 私有片段不进入上下文 | 受控测试追踪 |
| 重复上传请求 | 同一键同一内容提交两次 | 按约定返回同一结果 | 唯一约束与记录数量 |
| 同键不同内容 | 复用键但更改正文 | 明确冲突，不静默覆盖 | HTTP 错误码与原记录 |
| 处理中删除 | 任务未完成时撤销文档 | 后台完成也不能重新发布 | 状态与发布指针 |
| 无依据问题 | 提问资料不包含的内容 | 说明依据不足，不伪造引用 | 固定评测样例 |
| 模型服务失败 | 注入上游 503 | 显示服务失败，不说资料不存在 | 错误分类与请求次数 |
| 连接中断 | 回答一半断开 | 保留 partial 状态 | 页面与消息记录 |
| 新版发布后 | 同一问题重新提问 | 使用 v2，旧回答仍标 v1 | 版本与引用记录 |

表里的“模型调用日志”只应记录受控测试所需的标识、阶段和脱敏信息。生产环境不能为了证明权限而长期记录全部私人文档。如果需要检查上下文是否泄露，可以在测试中用合成的特征字符串和内存 mock 捕获请求，避免真实敏感内容离开边界。

### 主观质量怎么写验收

答案质量可以分成有依据、完整性、相关性、表达清晰等维度，并给每个维度明确评分例子。不要只写“正确率达到 95%”，却没有样本分布、判定规则与失败类别。一个只包含十个简单问题的高分，不能代表复杂问题也可靠。

保留无答案、冲突资料、过期制度、权限受限和用户含糊提问等样例。对同一修改，使用同一批问题比较，并记录模型、提示词和索引版本。这个项目的质量结论应限定在已评测的范围内，而不是扩展成“任何制度都能准确回答”。

## 需求变更时，如何判断影响范围

假设新增要求：“上传后的新版本必须由另一位管理员审核才能发布。”这不是把按钮文案改成“审核”就够了。版本需要新增等待审核阶段，数据需要保存提交人、审核人、审核时间和意见，权限规则需要禁止本人审核，发布流程需要验证审核仍适用于当前版本。

前端需要显示审核队列与拒绝原因，接口需要提交、批准、拒绝动作，数据库需要约束和审计字段，后台任务不能绕过审核直接更新发布指针。历史数据迁移也要决定已经 ready 的版本如何处理。分析一个变更时沿 UI、接口、数据、执行、权限、测试六条线检查，可以避免只修一个页面。

### 一份更完整的参考方案

把解析状态和审核状态分开：processing/ready/failed 描述处理，pending/approved/rejected 描述审批。发布条件是“解析 ready，审批 approved，文档未删除，审批对应版本未变，操作者有发布权限”。拒绝审核不会要求重新解析相同文件；修改文件会创建新版本，并使旧审批不能用于新内容。

练习时先写这条发布谓词，再补状态转移测试。不要先加两个布尔值 `isReady` 与 `isApproved`，然后在按钮上判断一次就认为控制完成。真正的发布入口必须在服务端重新执行同样的业务条件，客户端只负责提前解释为什么当前不能操作。

## 练习：实现发布条件，而不只写方案摘要

下面给出完整参考实现，保存为 `publish-policy.mjs`，运行 `node publish-policy.mjs`。它只检查案例发布策略，不执行数据库写入。实际发布应在事务中读取可信记录并原子更新，以防检查之后状态变化。

```js
import assert from 'node:assert/strict';

function canPublish({ actor, document, version, approval }) {
  if (!actor?.permissions?.includes('document:publish')) return { ok: false, reason: 'FORBIDDEN' };
  if (document.deleted) return { ok: false, reason: 'DOCUMENT_DELETED' };
  if (version.documentId !== document.id) return { ok: false, reason: 'WRONG_DOCUMENT' };
  if (version.status !== 'ready') return { ok: false, reason: 'NOT_READY' };
  if (!approval || approval.status !== 'approved') return { ok: false, reason: 'NOT_APPROVED' };
  if (approval.versionId !== version.id) return { ok: false, reason: 'STALE_APPROVAL' };
  if (approval.reviewerId === version.createdBy) return { ok: false, reason: 'SELF_REVIEW' };
  return { ok: true, versionId: version.id };
}

const input = {
  actor: { id: 'publisher', permissions: ['document:publish'] },
  document: { id: 'doc-a', deleted: false },
  version: { id: 'v2', documentId: 'doc-a', status: 'ready', createdBy: 'alice' },
  approval: { status: 'approved', versionId: 'v2', reviewerId: 'bob' },
};
assert.deepEqual(canPublish(input), { ok: true, versionId: 'v2' });
assert.equal(canPublish({ ...input, document: { ...input.document, deleted: true } }).reason, 'DOCUMENT_DELETED');
assert.equal(canPublish({ ...input, approval: { ...input.approval, versionId: 'v1' } }).reason, 'STALE_APPROVAL');
assert.equal(canPublish({ ...input, approval: { ...input.approval, reviewerId: 'alice' } }).reason, 'SELF_REVIEW');
assert.equal(canPublish({ ...input, actor: { id: 'guest', permissions: [] } }).reason, 'FORBIDDEN');
console.log('通过：发布成功、删除、过期审批、本人审核和无权限五种情况');
```

你应该先独立写出测试，再看参考实现。尝试添加新的失败条件，例如候选版本已经被后来的上传替代。说明这个条件应作用在版本记录、文档指针还是审批记录，写出一个会失败的测试，再修改实现。这比继续阅读更多术语更能证明你理解了需求对代码的影响。

## 把需求文档交给 AI 前，最后检查一次

确认 AI 能从文档中回答：用户是谁，能操作哪些资源，输入允许什么，成功表示什么，失败是否可重试，数据怎样更新与删除，怎么判断实现完成。任何一个答案需要猜测，都可能在生成代码时变成未经确认的产品决定。

你不必一次提供整个系统。可以把本章发布策略、字段映射与五个测试作为一个独立任务，要求先实现纯业务规则；随后再接数据库事务，最后接页面。每一步的输入输出稳定，AI 就能在可审查的范围内帮助你，而你始终掌握最终的业务含义。


## 练习

为“让助手帮我创建任务”补齐操作草稿、用户确认、提交中、成功和失败状态。考虑用户点击两次确认、确认后网络断开、任务已创建但响应丢失。

<details>
<summary>参考答案</summary>

先生成可编辑草稿，展示关键字段；确认后由服务端重新检查权限与输入，用幂等键创建任务。网络失败时先查询同一操作状态或使用相同幂等键重试，不能盲目创建新任务。已执行结果应包含任务 ID，页面刷新后可恢复。模型只提出草稿，最终执行由程序和用户授权共同约束。

</details>

## 验收与自测

- 能把一个 AI 需求映射到 UI、接口、数据和执行流程。
- 至少覆盖正常、无数据、无权限、依赖失败和重复请求。
- 能解释一个技术选择的收益、代价和后续升级条件。

**问：验收写“回答智能、准确”足够吗？** 答：不够，需要样例、评分规则和可观察的失败标准。

**问：原型没有错误页，是否可以忽略？** 答：应补充必要状态并明确产品行为。

**问：为什么在实现前讨论删除？** 答：删除影响文件、索引、缓存和历史引用，属于完整的数据生命周期。

## 官方参考

- [Cucumber：Gherkin 参考](https://cucumber.io/docs/gherkin/reference)
- [OWASP：授权实践](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [Vue：状态管理](https://cn.vuejs.org/guide/scaling-up/state-management)
