Files
bid-doctor/docs/architecture/api-design.md
YANG JIANKUAN be62440531 docs: 工程骨架落地记录(架构 §13)与 AGENTS.md 工程一节
- 架构 §13:SK1–SK8,SK1(事件流用 Valkey stream)、SK2(测试不用 testcontainers)待确认
- api-design §10:接口实现进度
- openapi.yaml:ChecklistItem 的必填字段在同一层声明,生成的类型不再是可选
- AGENTS.md:目录结构、常用命令、编码约定、当前进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00

196 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 接口契约 v0.1
> 依据:架构 v0.3(§3.4 任务、§3.11 会话、§4.2 前端、§5.2 桌面约束)、`data-model.md`、03 功能需求、MVP 原型 v0.2 的 13 个页面。
> 契约文件:`openapi.yaml`(OpenAPI 3.1,64 个路径、81 个操作、81 个数据结构)。
> 范围:用户端(网页版 / 桌面版)的 MVP 主流程。运营后台是独立应用,接口另行定义。
> API1–API5 已于 2026-11-03 全部按建议确认(`docs/decisions.md`),见 §9。
## 0. 结论速览
- **一份契约,两端同时开发**:前端用 openapi-typescript 从 `openapi.yaml` 生成类型,用 mock 服务器先跑;后端按它写 Pydantic 模型。后端骨架搭好后,改为以 FastAPI 生成的文档为准(API1)。
- **两种鉴权,同一套接口**:网页版用 cookie,并加 CSRF 头;桌面版用 Bearer 令牌(架构 §5.2 第 3 条)。
- **错误统一用 RFC 9457 格式**,带一个稳定的 `code`,前端按 `code` 判断,不解析文案(§5)。
- **会扣点的接口都要带「预期扣点」**:前端把告诉用户的扣点数传上来,服务端算的和它不一致就拒绝,不扣点(API4)。这样用户看到的扣点说明和实际扣点永远一致(红线 9)。
- **进度用事件流推送,轮询兜底**:事件只是「有变化」的提示,以资源的 GET 结果为准。事件流用 fetch 读取,不用浏览器的 EventSource,因为 EventSource 带不了 Bearer 头(API2)。
- **大文件分片上传、断点续传**,和平台无关(架构 §5.2 第 7 条)。格式、加密、页数在上传完成时就拦截,不建任务、不扣点(FR-M2-01)。
## 1. 基本约定
| 事项 | 约定 |
|---|---|
| 地址 | `{服务器}/api/v1`。网页版同源;桌面版用用户选择的服务器地址 |
| 格式 | JSON,字段名用 snake_case(和数据库、Python 一致);生成的 TypeScript 类型也是 snake_case,不做转换 |
| 编号 | UUID 字符串 |
| 时间 | RFC 3339,带时区。显示格式(「11月20日 09:30」)由前端处理(Q10) |
| 金额 | 字符串,如 `"1280000.00"`,单位元,避免浮点误差 |
| 枚举 | 英文小写加下划线,值和数据库的 CHECK 约束一致;中文显示名由前端维护 |
| 列表 | 一个项目内的列表(清单项、问题、文档、分包)一次返回全部,最多几百条;跨项目、会一直增长的列表(项目、额度流水、通知)用游标分页:`?cursor=&limit=`,返回 `next_cursor` |
| 动作 | 不是增删改的操作,用 `POST /资源/{id}/动作`,例如 `/findings/{id}/resolve`、`/parse-jobs/{id}/cancel` |
| 版本 | 路径里的 `v1`。同一版本内只加字段、不删不改;前端忽略不认识的字段和枚举值 |
## 2. 鉴权与会话
| | 网页版 | 桌面版 |
|---|---|---|
| 登录后 | 服务端设置 cookie `bd_session`(HttpOnly、Secure、SameSite=Lax)和 `bd_csrf`(前端可读) | 响应体里返回 `token`,只返回这一次;存进系统钥匙串 |
| 每个请求 | 自动带 cookie;写操作(POST、PUT、PATCH、DELETE)还要带请求头 `X-CSRF-Token`,值等于 `bd_csrf` | `Authorization: Bearer <token>` |
| 退出 | `POST /auth/logout` 吊销会话 | 同左 |
- 登录、注册都用 `client_kind` 声明是哪一端。
- **异地多设备并发登录**(FR-M6-04):同一账号已有其他设备在线时,登录返回 409 `concurrent_session`,附在线设备列表(设备、IP 归属地、最近活跃时间);用户确认后带 `force: true` 重发,其他设备被挤下线(API3)。被挤下线的设备下一个请求收到 401 `session_revoked`,`reason: concurrent_login`,前端提示「你的账号在其他设备登录」。
- **发验证码不泄露注册状态**:号码有没有注册,`POST /auth/sms-codes` 都返回 202。验证码校验通过后,登录接口才会告诉用户「这个号码还没注册」。
- **权限点**:`GET /me` 返回当前用户的权限点列表(AR12),前端只用它决定按钮显示与否,服务端每个接口照样检查。MVP 只有所有者,拥有全部权限点。
## 3. 主流程和接口的对应
| 步骤(原型页面) | 接口 |
|---|---|
| 注册 / 登录(login) | `POST /auth/sms-codes` → `POST /auth/register` 或 `POST /auth/login` → `GET /me` |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度 |
| 新建项目、上传招标文件(project-new) | `POST /projects` → `POST /uploads` → `PUT /uploads/{id}/parts/{n}` × N → `POST /uploads/{id}/complete` → `POST /projects/{id}/documents` → `POST /documents/{id}/parse-jobs`(预扣 1 次) |
| 解析进度(parsing) | `GET /events` 收 `job.progress`;断线时轮询 `GET /parse-jobs/{id}`;`POST /parse-jobs/{id}/cancel`;失败后重试再调一次 `POST /documents/{id}/parse-jobs` |
| 选包(lots) | `GET /projects/{id}/lots` → `PUT /projects/{id}/bidding-lots`;手工增删改分包 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单 |
| 上传投标文件、发起检查(bid-check) | 上传同上,`role = bid_volume`;`GET /projects/{id}/check-preview` 给出扣点说明 → `POST /projects/{id}/check-runs` |
| 查标(results) | `GET /check-runs/{id}`、`GET /check-runs/{id}/findings` → `POST /findings/{id}/resolve`、`/reopen`(U 键)、`POST /check-runs/{id}/findings/bulk-resolve`;需人工核对用 `GET/PUT /projects/{id}/manual-checks`;`POST /feedback` |
| 报告(report) | `POST /check-runs/{id}/reports` → 事件 `report.ready` → `GET /reports/{id}/file` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`、`GET /projects/{id}/check-runs`、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 额度(quota) | `GET /quota`、`GET /quota/entries`、`GET /plans`、`POST /activation-requests` |
| 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` |
## 4. 几个关键设计
### 4.1 扣点:预览 + 预期扣点
- 发起检查前,前端调用 `GET /projects/{id}/check-preview`。服务端按 01 §4 和 P1 算出这次的计费方式(扣点 / 24 小时内免扣 / 免费版)、扣几次、免扣窗口到什么时候,以及能不能开始(没有招标文件、额度用完等)。按钮旁的扣点说明就用这份数据。
- 发起时带 `expected_charge`。如果服务端当下算的和它不一致(比如用户停在页面上时,24 小时窗口刚好过了),就返回 409 `billing_changed`,附最新的预览,**不扣点**。前端刷新说明,让用户再点一次。
- 解析同理:`POST /documents/{id}/parse-jobs` 也接受 `expected_charge`。
- 重复点击不会重复扣点:同一份文档、同一个项目同时只能有一个在跑的任务(数据库部分唯一索引),重复发起返回 409 `job_already_running`,附正在跑的任务编号。
### 4.2 任务与事件
- 解析任务、检查批次、报告生成都是后台任务:发起接口立刻返回任务对象(`status = queued`)。
- `GET /events` 是当前用户的事件流:`job.progress`、`job.finished`、`report.ready`、`notification.created`、`quota.changed`、`project.changed`。
- 每条事件带 `id`。重连时带上 `Last-Event-ID`,服务端尽量补发;补不了(Valkey 里的缓冲已过期)就发 `resync`,前端重新拉取当前页面的数据。
- 事件可能丢、可能重复。前端收到后只做一件事:让 TanStack Query 里对应的查询失效、重新请求。
- 断线兜底:页面上有进行中的任务时,每 5 秒轮询一次任务的 GET。
- 失败原因 `error` 只给用户看的文字和 `refunded`(是否已退回额度),不含文档内容和内部堆栈。
### 4.3 上传
1. `POST /uploads`:传文件名、大小和用途(项目文件或资质附件),可选传 sha256。超出套餐的单文件大小上限,直接返回 409,`detail` 写明上限值。
2. `PUT /uploads/{id}/parts/{n}`:逐片上传,片大小由服务端决定。重复上传同一片会覆盖,所以断点续传时可以先 `GET /uploads/{id}` 看收到了哪些片。
3. `POST /uploads/{id}/complete`:合并分片,按文件头识别真实格式,读取页数。不支持的格式、加密、损坏、电子投标平台的加密格式、页数超限,都在这里返回 422。扩展名和真实格式不一致时只做提示(`extension_mismatch`,07 F2)。
4. 挂到项目上:`POST /projects/{id}/documents`。投标分册重新上传时填 `replaces_document_id`,生成新版本。
上传会话 24 小时后过期,没挂到项目上的文件自动清理。分片经过 api 写入对象存储;本地磁盘和 S3 用同一套接口,私有化环境也一样。
### 4.4 原文和出处
- 原文用 pdf.js 渲染:`GET /documents/{id}/file?variant=normalized` 返回短时有效的下载地址,pdf.js 直接加载,不需要带鉴权头(架构 §5.2 第 5 条)。
- 出处对象自带原句、页码和坐标框,阅读器直接画高亮,不用再取文字层。`normalized_sha256` 和文档的哈希不一致时不画(data-model §5.1)。
- 人工补出处:在阅读器里选中句子(`GET /documents/{id}/pages/{n}/sentences`),再 `POST /projects/{id}/citations` 建一条出处,最后把它挂到清单项上(采纳或替换出处)。
### 4.5 修改清单和处理问题
- **并发修改**:清单项和项目字段的 GET 返回 `ETag`。修改时带上 `If-Match`,版本不一致返回 412。MVP 只有一个人,这个头是选填的;团队版上线后改为必填。
- **改清单项**:`PATCH` 只传要改的字段。服务端把改过的字段记进 `edited_fields`,AI 原值保留在 `ai_values` 里。已确认的清单被修改后回到草稿(FR-M2-08)。
- **处理问题**:
- `resolve` 返回问题本身和项目最新的就绪状态(`open_fatal_count`),项目头直接更新,不用再请求一次;
- 忽略必须写原因,致命问题「确认无误」也必须写说明(U4),否则返回 422 `note_required`;
- 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`;
- 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。
- **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。
### 4.6 不在接口里出现的东西
对照产品红线:
- 没有任何接口返回或接收「建议报价」。报价相关的字段只有招标文件写明的限价,以及从投标文件里读到的金额(红线 6)。
- 问题、报告的字段里没有「通过 / 合格」这类结论字段;就绪状态只用于提示,不阻止导出和递交(红线 3、4)。
- 没有用户可以填的模型地址或模型选择(T1)。
## 5. 错误
所有错误都用 `application/problem+json`:
```json
{
"type": "about:blank",
"title": "额度不足",
"status": 409,
"code": "quota_exhausted",
"detail": "本期解析额度已用完。专业版续费或加量请联系客服。",
"request_id": "req_01J…"
}
```
- `title` 和 `detail` 是给用户看的中文;前端按 `code` 决定怎么处理,不解析文案。
- 字段校验错误:422 `validation_failed`,`errors` 里逐个字段列出 `field`(如 `spec.tech_param.value`)、`code`、`message`。
- 访问别的企业的资源一律返回 404,不返回 403(架构 §3.3)。
- `request_id` 也放在响应头 `X-Request-ID` 里,用户反馈问题时可以报这个编号。
| HTTP | code | 含义 |
|---|---|---|
| 401 | `unauthenticated` `session_expired` `session_revoked` | 没登录、会话过期、被吊销(含被挤下线) |
| 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 |
| 404 | `not_found` `phone_not_registered` | |
| 409 | `concurrent_session` | 已有其他设备在线(API3) |
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` | 额度、计费、套餐限制 |
| 409 | `job_already_running` `job_finished` | 任务状态冲突 |
| 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 |
| 412 | `precondition_failed` | If-Match 版本不一致 |
| 422 | `validation_failed` `note_required` `citation_required` `bulk_only_hint` | 输入不合法 |
| 422 | `file_format_unsupported` `file_encrypted` `file_corrupted` `bid_file_platform_encrypted` `upload_checksum_mismatch` | 上传的文件有问题,不扣点 |
| 429 | `rate_limited` | 验证码、登录太频繁;响应头带 Retry-After |
| 500 / 503 | `internal_error` `service_unavailable` | 不扣点;任务类的会自动退回 |
完整的错误码清单在每个接口的响应说明里;写后端时收进 `core/errors.py`,作为唯一的定义来源。
## 6. 契约里没写死、留给实现的
- 分片大小(建议 8MB)、上传会话的过期时间、下载地址的有效期(建议 5 分钟);
- 列表的默认排序以外的排序方式;
- 事件缓冲多长时间(建议 10 分钟);
- `next_action` 的具体推导规则,写在 project 模块里,和原型工作台的「下一步」一致。
## 7. 以后的版本怎么接
| 以后要加的 | 接口上的准备 |
|---|---|
| 团队版:项目成员、角色 | `ProjectDetail.members` 已经有了;以后加 `/projects/{id}/members` 的增删改,`If-Match` 改为必填 |
| 一人多企业、切换企业 | `POST /auth/switch-tenant` 换一个绑定到另一个成员身份的会话(§3.14) |
| 飞书、企业微信、SSO 登录 | `/auth/{provider}/start` 和 `/auth/{provider}/callback`,登录结果的格式不变 |
| 线上支付 | `/orders` 一组新接口,不改额度接口 |
| 标书生成(M4)、查重(M5) | 新的任务类型,复用任务、事件、扣点预览这套模式 |
## 8. 怎么用这份契约
- **前端**:`packages/api-client` 用 openapi-typescript 从 `openapi.yaml` 生成类型,用 openapi-fetch 调用。后端还没好时,用基于契约的 mock 服务器(如 Prism)开发页面。
- **后端**:按契约写 Pydantic 模型和路由。集成测试里比对 FastAPI 生成的文档和契约,有差异就失败。
- **契约检查**:现在只做了结构检查(引用都能解析、operationId 不重复、路径参数都声明了、必填字段都存在)。工程骨架里在 CI 加上 Redocly 或 Spectral 的完整校验。
## 9. 决策(已确认)
| # | 事项 | 结论 |
|---|---|---|
| API1 | 契约以谁为准 | **现在以 `openapi.yaml` 为准**,前后端据此同时开发。后端骨架搭好后,改为以 FastAPI 生成的文档为准:把生成结果存成 `openapi.json` 提交到仓库,CI 检查它是不是最新的;契约有改动时在合并请求里就能看到。之后 `openapi.yaml` 不再维护,和 `schema-draft.sql` 的处理方式一样 |
| API2 | 事件流怎么读 | 用 fetch 读 SSE 流,不用 EventSource。理由:EventSource 带不了 `Authorization` 头,桌面版用不了 |
| API3 | 异地多设备并发登录怎么「提示并限制」 | **新设备登录时提示「账号已在其他设备登录」,用户确认后挤掉旧设备**。另一种做法是旧设备不退出就不让新设备登录,但用户换了电脑、忘了退出时就登不上了 |
| API4 | 扣点说明和实际扣点不一致时 | **拒绝并刷新说明,不扣点**(§4.1)。用户看到「不扣点」,结果却扣了,这种情况必须杜绝 |
| API5 | 报告怎么生成 | **后台生成**,完成后推送事件。用 Chromium 生成 PDF 要几秒到十几秒,不放在请求里同步做(架构:api 只做短事务) |
## 10. 实现进度(工程骨架)
骨架阶段只实现了「上传 → 解析 → 显示清单」这条最小链路要用的接口。`server/tests/test_contract.py` 保证已实现的接口都在契约里;契约里还没实现的,按模块列在这里,实现一个划掉一个。
| 模块 | 已实现 | 还没实现 |
|---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 |
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 |
| 项目 | 列表(不含筛选和分页)、新建、详情 | 基本信息字段修改与补遗确认、状态变更、删除、分包的增删改和选包 |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消 | 删除文档、人工建出处 |
| 清单 | 清单概况、清单项列表(只按区域筛选) | 修改、采纳、删除恢复、替换出处、补遗更新确认、修改记录、确认清单、版本 |
| 检查、报告、资质 | — | 全部(查标阶段) |
| 通知与事件 | 事件流、通知列表 | 标为已读、反馈 |