# 接口契约 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 ` | | 退出 | `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` | 额度流水、开通申请 | | 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期) | 基本信息字段修改与补遗确认、状态变更、删除、分包的增删改和选包 | | 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 | | 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析);列表的 `lot_scope`(等选包) | | 检查、报告、资质 | — | 全部(查标阶段) | | 通知与事件 | 事件流、通知列表 | 标为已读、反馈 | 读标修改类接口的实现约定(2026-11-03): - **修改记录来自审计**:每次修改都写一条审计事件,`history` 直接读它们。解析不逐条写审计(一份文件几百条),所以 AI / 规则提取的那一刻由接口在最后补一条 `checklist_item.extract`,操作人是「系统」。 - **AI 原值**:人工第一次改某个字段时,把改前的值记在 `ai_values.original` 里,重新解析也保留,界面可以显示「AI 原为……」。 - **重新解析不推翻人工决定**:已删除(丢弃)的条目对上了就保持删除;采纳过的留在正式清单;人工补过或换过出处的(`edited_fields` 含 `citations`)不被这次解析的出处覆盖。 - **确认清单**:只收正式清单里未删除的条目;内容哈希和上一个确认版本一样时复用上一版,不新增版本号;已确认又重复点确认不写审计。