读标操作(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>
18 KiB
18 KiB
接口契约 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)。被挤下线的设备下一个请求收到 401session_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 小时窗口刚好过了),就返回 409billing_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 上传
POST /uploads:传文件名、大小和用途(项目文件或资质附件),可选传 sha256。超出套餐的单文件大小上限,直接返回 409,detail写明上限值。PUT /uploads/{id}/parts/{n}:逐片上传,片大小由服务端决定。重复上传同一片会覆盖,所以断点续传时可以先GET /uploads/{id}看收到了哪些片。POST /uploads/{id}/complete:合并分片,按文件头识别真实格式,读取页数。不支持的格式、加密、损坏、电子投标平台的加密格式、页数超限,都在这里返回 422。扩展名和真实格式不一致时只做提示(extension_mismatch,07 F2)。- 挂到项目上:
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)不被这次解析的出处覆盖。 - 确认清单:只收正式清单里未删除的条目;内容哈希和上一个确认版本一样时复用上一版,不新增版本号;已确认又重复点确认不写审计。