# 系统边界与全栈架构

## 为什么前端工程师需要重新理解系统边界

在前端工作中，你通常从接口契约开始，把数据变成界面。成为全栈工程师后，你还要决定这些数据从哪里来、谁有权读取、失败后能否重试，以及哪些状态必须在服务重启后保留。AI 又增加了一层：模型可以提出答案和动作，但它的输出本身不能成为可信授权依据。

本章目标是 L2 的系统理解能力。你不必一开始设计微服务，但应当能画出一次请求经过的组件，标出身份、数据和副作用。前置知识是浏览器请求与 Promise；数据库和模型的细节将在后续章节学习。

## 一条完整请求经过哪些地方

```text
浏览器：收集输入、展示状态、允许取消
  ↓ HTTPS 请求，带登录凭据和请求标识
应用后端：认证 → 参数校验 → 权限检查 → 业务规则
  ├─ PostgreSQL：用户、会话、文档元信息、任务记录
  ├─ 文件存储：原文件和可下载产物
  ├─ 检索组件：在有权访问的资料范围内找证据
  ├─ 模型适配层：发送上下文、接收文本或工具请求
  └─ 后台任务：解析文件、批量入库、长时间运行的操作
  ↓ 经过验证的结果或流式事件
浏览器：解释当前阶段、展示引用、提示下一步
```

浏览器可以发任何请求，隐藏按钮不等于权限控制。后端是业务规则的执行者，模型服务是一个外部依赖，数据库是持久化事实的主要来源。缓存改善访问速度，但通常不应成为你唯一保存任务结果的地方。队列保存“还有什么要做”，数据库记录“已经发生了什么”；这两者必须协调，不能假设任何一个调用都永远只发生一次。

## 三种状态不要混在一起

**界面状态**包括输入框、正在打开的面板和滚动位置，通常留在前端。**业务状态**包括文档归属、任务是否提交、审批是否通过，应由服务端持久保存。**执行状态**描述一次过程，例如“检索中、模型生成中、工具调用中”，可能短暂存在，也可能需要持久化以支持恢复。

例如用户关掉页面，界面状态消失是正常的；已经创建的业务任务不应消失。一次流式回答中断时，部分文本可以作为不完整草稿保留，但不能把它标记为完整答案。把这三类状态放进一个 `loading` 变量，会让取消、刷新和恢复都变得含糊。

## 用一个可运行例子理解分层

下面是单文件 Node 演示，使用模拟的“可信会话结果”和内存仓库，没有 HTTP 和真实登录。它的用途是展示各层职责，不能直接部署为认证系统。保存为 `boundaries.mjs`，运行 `node boundaries.mjs`，无需依赖。

```js
// 仓库只负责查数据；实际项目可以替换成参数化 SQL。
const documents = new Map([
  ['doc-1', { id: 'doc-1', ownerId: 'alice', text: '报销需附发票。' }],
]);

// 适配器只负责把业务输入转换为模型结果。
// 本例返回固定摘要，目的是离线验证权限与调用顺序。
async function summarizeWithMock(text) {
  return { summary: `资料摘要：${text}`, provider: 'offline-mock' };
}

async function summarizeDocument({ actor, documentId }) {
  // actor 应来自服务端验证后的会话，不能来自请求 body.userId。
  if (!actor) throw new Error('UNAUTHENTICATED');
  if (typeof documentId !== 'string' || !documentId) {
    throw new Error('INVALID_DOCUMENT_ID');
  }

  const document = documents.get(documentId);
  // 统一“没有”和“无权限”，避免泄露其他用户的文档是否存在。
  if (!document || document.ownerId !== actor.id) {
    throw new Error('DOCUMENT_NOT_FOUND');
  }

  // 权限确认后才把内容发送给依赖，避免先泄露再拒绝。
  const result = await summarizeWithMock(document.text);
  return { documentId: document.id, summary: result.summary };
}

console.log(await summarizeDocument({
  actor: { id: 'alice' }, documentId: 'doc-1',
}));
try {
  await summarizeDocument({ actor: { id: 'bob' }, documentId: 'doc-1' });
} catch (error) {
  console.log(error.message); // DOCUMENT_NOT_FOUND
}
```

第一次调用返回摘要对象，第二次输出 `DOCUMENT_NOT_FOUND`。业务服务组合权限和外部能力；仓库不负责猜测用户身份；模型适配器不负责决定是否有权访问文档。把接口换成真实 HTTP 时，再由传输层把错误映射成状态码，例如未登录映射为 401，参数不合法映射为 400。

`summarizeDocument` 接受一个包含 `actor` 与 `documentId` 的对象，成功返回 Promise 包装的摘要对象，失败抛出错误。真实应用应使用有明确错误码的错误类型，避免用英文错误消息作为长期接口协议。本例用字符串是为了让调用次序清楚，而不是推荐用散落的字符串建立大型错误系统。

## 什么该交给普通代码，什么适合模型

金额计算、权限判定、唯一约束、任务状态流转，应当使用确定的规则。自然语言提取、资料摘要、问题改写等可以交给模型，再由程序检查输出是否满足契约。模型适合处理语义不确定性，不应获得“决定自己权限”的权力。

产品需求也不等于必须使用 AI。固定字段转换可以直接写函数，精确查单号先查数据库。判断依据是任务的语义复杂度、错误成本与验收方法。如果一个简单查询已经足够，增加模型会额外增加延迟、费用和失败方式。

## 单体服务与模块边界

第一版可采用一个后端服务、一个数据库、一个文件存储。模块边界通过函数、目录、接口契约和权限规则建立，不需要先通过网络拆开。即使以后把文档解析移到独立 Worker，业务代码仍可依赖同样的“创建解析任务”契约。

只有当部署节奏、资源需求或故障隔离有明确理由时才拆服务。过早拆分会引入网络超时、分布式一致性、跨服务排障与更多运维工作。对个人产品，清晰的单体通常更便于理解和维护；它也可以拥有严格的接口与充分测试。

## 常见故障如何沿边界定位

界面没有内容时，先看请求是否发出，再看后端是否接收，之后检查权限、数据库查询、模型调用和响应解析。每一层记录同一个请求标识，配上阶段、耗时和错误类别。不要把完整提示词、凭据或原始私人文档作为默认日志。

另一个常见问题是“取消了页面，但任务还在执行”。取消浏览器等待只说明客户端不再接收；是否向上游传递取消、是否允许中止后台业务，由服务端契约决定。对已产生的数据库写入，关闭网络连接不会自动回滚。

## 从一句用户需求展开一条完整链路

用户说：“根据我上传的报销制度，告诉我哪些材料缺失。”前端先要知道文件是否已经上传、处理是否完成、资料属于谁、问题属于哪个会话。后端先确认身份和资料权限，再读取可用版本，检索证据，组织模型输入，校验答案并返回引用。模型负责理解描述，规则负责决定能否访问和执行。这些步骤不能因为你同时使用一个框架就自动成立。

把需求拆成三个阶段比较容易理解。第一阶段准备资料：文件保存、登记元信息、后台解析、发布索引。第二阶段回答问题：验证主体、筛选证据、生成、校验、保存结果。第三阶段执行业务：生成草稿、用户确认、重新鉴权、幂等执行、记录结果。每一阶段都应在失败后留下可解释状态，而不只是接口成功或异常。

### 请求、任务与事实的生命周期

HTTP 请求通常在秒级范围结束，后台解析任务可能跨越服务重启，文档与操作结果需要长期保存。不要把三者绑定在同一个 Promise 上。接口返回202和任务ID，意味着服务接受了任务，不意味着解析或索引已完成。页面可用该ID查询状态，后台通过持久化记录恢复执行。

队列消息也不应携带“永远有效的权限结论”。成员可能被移出组织，文档可能已删除，版本可能被更新。Worker开始处理时读取当前业务状态；检索发布前再次确认文档和版本仍有效。否则一个几分钟前合法的任务可能在权限撤销后继续发布资料。

| 状态保存在哪里 | 适合保存什么 | 不适合承担什么 |
| --- | --- | --- |
| Vue组件 | 输入草稿、面板、滚动、当前请求资源 | 已执行任务的唯一结果 |
| 客户端Store | 多页面共享状态与缓存 | 服务端授权事实 |
| 数据库 | 归属、版本、执行记录、幂等结果 | 大文件的任意无限正文 |
| 文件存储 | 原文件与生成文件 | 独立决定用户访问权限 |
| 缓存 | 可重建的热点数据 | 唯一不可丢业务事实 |
| 队列 | 待执行工作与重试调度 | 不加协调地取代业务状态 |
| 模型上下文 | 当前推理需要的信息 | 可靠、永久、受约束的数据库 |

这些是职责划分，不是产品列表。你可以用同一个数据库保存队列式任务，也可以先用磁盘目录存学习文件；关键在于清楚地定义恢复、一致性和访问方式。不要把“使用Redis”当作“已经有可靠任务系统”的同义词。

## 完整实验：通过调用轨迹验证边界次序

下面的程序把仓库、模型和结果保存都作为可注入依赖，使用数组记录调用轨迹。保存为 `orchestrate-answer.mjs` 后用 Node 运行。它展示授权发生在模型调用前、引用在保存前校验、依赖失败不会伪造成功。这里使用内存数据与固定模型结果，专门验证控制流，不包含真正登录或事务。

```js orchestrate-answer.mjs
import assert from 'node:assert/strict';

function createAnswerService({ repository, model }) {
  return async function answer({ actor, documentId, question }) {
    if (!actor?.id) throw new Error('UNAUTHENTICATED');
    if (typeof question !== 'string' || !question.trim()) throw new Error('INVALID_QUESTION');
    // 查询本身就限定主体，禁止先读取所有资料再交给模型判断权限。
    const document = await repository.findVisible(actor.id, documentId);
    if (!document) throw new Error('DOCUMENT_NOT_FOUND');
    if (document.status !== 'ready') throw new Error('DOCUMENT_NOT_READY');
    const allowed = new Set(document.chunks.map(chunk => chunk.id));
    const result = await model.generate({ question: question.trim(), chunks: document.chunks });
    if (!result || typeof result.text !== 'string' || !Array.isArray(result.citations)
      || result.citations.some(id => !allowed.has(id))) throw new Error('INVALID_MODEL_OUTPUT');
    const record = { actorId: actor.id, documentId, versionId: document.versionId,
      text: result.text, citations: result.citations };
    // 保存失败会抛出，不能只打印一条日志然后告诉用户已保存。
    await repository.saveAnswer(record);
    return record;
  };
}

function setup({ wrongCitation = false, saveFails = false } = {}) {
  const trace = [];
  const service = createAnswerService({
    repository: {
      async findVisible(actorId, documentId) {
        trace.push('read-authorized');
        if (actorId !== 'alice' || documentId !== 'doc-1') return null;
        return { status: 'ready', versionId: 'v1', chunks: [{ id: 'c1', text: '报销需要发票。' }] };
      },
      async saveAnswer(record) {
        trace.push('save');
        if (saveFails) throw new Error('DATABASE_UNAVAILABLE');
        assert.equal(record.versionId, 'v1');
      },
    },
    model: {
      async generate({ chunks }) {
        trace.push('model'); assert.equal(chunks.length, 1);
        return { text: '需要提供发票。', citations: [wrongCitation ? 'hidden' : 'c1'] };
      },
    },
  });
  return { service, trace };
}
const input = { actor: { id: 'alice' }, documentId: 'doc-1', question: '需要什么材料？' };
const normal = setup(); await normal.service(input);
assert.deepEqual(normal.trace, ['read-authorized', 'model', 'save']);
const denied = setup();
await assert.rejects(() => denied.service({ ...input, actor: { id: 'bob' } }), /NOT_FOUND/);
assert.deepEqual(denied.trace, ['read-authorized']);
const invalid = setup({ wrongCitation: true });
await assert.rejects(() => invalid.service(input), /INVALID_MODEL_OUTPUT/);
assert.deepEqual(invalid.trace, ['read-authorized', 'model']);
const failed = setup({ saveFails: true });
await assert.rejects(() => failed.service(input), /DATABASE_UNAVAILABLE/);
console.log('通过：授权顺序、引用范围、保存失败与成功轨迹');
```

createAnswerService 接收能力接口，返回真正处理业务的函数。这种依赖注入不需要先上复杂框架，普通函数就足够。HTTP handler把会话和请求转换成输入，仓库实现可以换成SQL，模型实现可以换成供应商适配器；业务流程不需要知道它们的连接细节。更换依赖后，仍要做真实集成测试确认契约一致。

注意实验没有解决生成期间权限发生变化的竞态。产品需要明确“以请求开始时权限为准”还是“每次取资料与操作时检查当前权限”，以及撤销之后历史回答如何处理。对敏感资料可以在返回或保存之前再次核对版本与访问策略，但也不能承诺已经传给外部模型的内容能被取消连接自动撤回。边界设计必须包含时间维度。

## 哪些副作用可以放在数据库事务里

一个事务可以把同一数据库中的配额扣减和任务登记一起提交，却不能自动撤回已经发送的邮件或已经计费的模型调用。把外部网络请求放在长事务中等待，会持有连接或锁，并把上游延迟传递给数据库。更合理的设计是先记录执行意图，再由受控流程调用外部能力并记录结果。

如果“业务记录提交”和“入队”分别进行，进程可能在两者之间崩溃。业务已创建但永远没有任务，或者任务执行时查不到业务记录，都可能发生。outbox思路是在同一数据库事务里保存业务事实和待发送事件，再由后台分发。分发可能重复，所以消费端仍需幂等。知道这种故障窗口，比一开始接入很多中间件更有价值。

你不必在第一个学习程序里实现完整outbox，但要会画出每一步的提交点，并问“这里崩溃后怎样恢复”。内存队列在重启后丢失任务是否可以接受？定时扫描数据库能否找回未完成任务？哪种失败需要用户重试，哪种应该自动恢复？答案取决于产品要求，不能由技术名词替你决定。

## 模型适配层应该隔离什么

业务层需要稳定的输入和输出，例如“给定问题和证据，返回回答与引用”，而供应商可能提供不同的消息字段、流事件、工具描述和错误格式。适配层负责转换这些差异，保留必要的用量、请求ID和错误类别。不要把供应商原始响应直接传遍所有Vue组件，否则切换API版本会扩散成全项目修改。

适配层也不能过度抽象到看不见重要差异。若一个模型不支持结构约束、工具调用或某种模态，应明确能力限制，不能偷偷用普通文本模拟并宣称行为相同。统一接口需要定义最低共同语义，并让调用者知道哪些能力可用。先接一个供应商做清楚，再判断第二个供应商的真实差异。

提示词放在哪里同样是模块边界问题。固定系统规则、业务上下文、检索证据和用户问题应该有清晰来源；不要在前端字符串里拼业务权限，在后端再拼一遍规则，最后没人知道哪份提示词生效。提示词可以版本化并参与评测，但授权、金额和状态规则仍由程序执行。

## 缓存不能绕开权限与版本

问答缓存至少考虑问题、资料版本、可访问范围、模型与提示配置。只按问题文本缓存，会让两个用户得到彼此权限范围内的结果。即使把userId加进key，成员权限变更或文档删除后，旧缓存仍可能不再合法。需要权限版本、资料版本、失效机制或读取时再核对。

向量索引也不是业务事实的唯一来源。某文档删除后，索引清理可能异步完成，因此检索结果在进入模型前仍要确认业务可见性。设计“删除成功”时，先撤销访问，再进行物理清理，用户才不会在清理等待期间继续检索到被撤销内容。具体版本策略在需求、资料入库与检索章节展开。

## 单体的目录边界与未来拆分

开始时可以按领域划分documents、conversations、operations，再在每个领域内部组织HTTP入口、业务服务和数据访问。共享的模型适配、日志和配置放在明确的基础模块里。这个布局不是唯一正确答案，重点是一个变更能否沿接口清楚定位，而不是每层必须有多少文件。

当解析任务需要大量CPU或独立扩容时，先把它移动到Worker进程；当一个模块确实需要独立部署和故障隔离时，再考虑服务边界。跨进程之后要新增超时、重试、认证和消息兼容，不能只把函数调用换成HTTP然后认为拆分完成。对个人项目，能维护的复杂度本身就是选型条件。

## 架构评审时问这些具体问题

资料从哪里进入系统，谁拥有它，哪些地方保存副本，何时可以被检索，删除后哪些副本需要失效？用户身份从哪里建立，模型和工具分别能看到什么，哪一次检查阻止越权？操作在什么时刻算成功，网络中断后如何查回，重复提交由谁去重？一段结果对应哪份资料和配置，发生错误时用什么标识串起记录？

能回答这些问题，就达到本章L2所要求的系统理解。你不需要先背完分布式系统理论，但应能在纸上画出一次请求，标出持久化事实、外部数据发送、权限检查和故障恢复点。后续每个技术章节，都应能放回这张图中的某个位置。


## 练习与参考答案

为“上传文档并建立知识索引”写出前端、接口、文件存储、数据库、解析任务的职责，并回答：什么时候向用户显示“上传成功”，什么时候显示“可检索”？

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

上传成功意味着原始文件已可靠保存，并已记录归属；可检索意味着解析、分块、向量化和索引发布完成。接口返回文档 ID 与处理中状态，前端轮询或订阅状态变化。解析失败保留错误类别与重试入口，不把“文件保存成功”直接展示成“知识库已就绪”。旧版本索引可继续提供服务，新版本准备完成后再切换。

</details>

## 验收与自测

- 能给一次问答请求标注权限检查和外部数据发送的位置。
- 能区分前端状态、业务状态、执行状态。
- 能解释为什么一个单体服务也需要清晰的模块边界。

**问：模型说某用户是管理员，能据此授权吗？** 答：不能，身份和角色来自受信任的服务端记录。

**问：把模块拆成服务就有了清晰架构吗？** 答：不一定。职责不清时，网络边界只会放大问题。

**问：成功收到 HTTP 200 就证明业务任务完成吗？** 答：取决于契约，异步任务的响应可能只代表已接收。

## 官方参考

- [MDN：HTTP 概述](https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview)
- [OWASP：授权实践](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [Anthropic：从简单流程开始](https://www.anthropic.com/engineering/building-effective-agents)
