Files
bid-doctor/docs/architecture/api-design.md
YANG JIANKUAN e3f234ca0b feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:32:15 +08:00

18 KiB
Raw Blame History

接口契约 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:

{
  "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)不被这次解析的出处覆盖。
  • 确认清单:只收正式清单里未删除的条目;内容哈希和上一个确认版本一样时复用上一版,不新增版本号;已确认又重复点确认不写审计。