From 5747a523bbef3b660ba69034374b9f378ff7740d Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Tue, 3 Nov 2026 17:44:28 +0800 Subject: [PATCH] =?UTF-8?q?docs(architecture):=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20v0.1=EF=BC=8C=E7=A1=AE=E8=AE=A4=20API1?= =?UTF-8?q?=E2=80=93API5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openapi.yaml:用户端 MVP 主流程的 OpenAPI 3.1 契约(64 个路径、81 个操作) - api-design.md:约定、鉴权、页面与接口对应、扣点预览、任务与事件、上传、错误码 - data-model.md:清单项 spec 改为按类别分组 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- docs/architecture/api-design.md | 181 ++ docs/architecture/architecture.md | 4 +- docs/architecture/data-model.md | 4 +- docs/architecture/openapi.yaml | 2897 +++++++++++++++++++++++++++++ docs/decisions.md | 14 + 6 files changed, 3098 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/api-design.md create mode 100644 docs/architecture/openapi.yaml diff --git a/AGENTS.md b/AGENTS.md index 0faff45..8feb020 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,4 +156,5 @@ samples/ - 2026-11-03:技术架构升到 v0.3,新增 §3.14。团队版、企业版、飞书 / 企业微信登录都是以后的版本,早期不做;MVP 的表结构先按多人、多登录方式建:人 / 登录方式 / 成员身份分三层,项目成员表和统一的权限检查点先建好,以后接入不用重构(AR11、AR12)。 - 2026-11-03:VI 手册第 20 页报告页脚去掉「保证」字样,改为与设计规范 §7.17 一致的「辅助检查结果,仅供投标前自查参考」,PDF 已重出。 - 2026-11-03:物理数据模型 v0.1(`docs/architecture/data-model.md` + `schema-draft.sql`,40 张表,PostgreSQL 16 上 16 项行为测试通过)。DM1–DM7 全部按建议确认:句子放对象存储不进库、外键都带租户、每个项目一把数据密钥、手机号不存明文、规则问题可以只挂规则编号、清单新增「报价」类别。 -- 下一步:接口契约(核心接口的 OpenAPI 定义),然后搭工程骨架,见 `docs/architecture/architecture.md` 第 12 节。 +- 2026-11-03:接口契约 v0.1(`docs/architecture/api-design.md` + `openapi.yaml`,OpenAPI 3.1,64 个路径、81 个操作),覆盖 MVP 原型 13 页的主流程,运营后台另行定义。API1–API5 全部按建议确认:骨架搭好前以 `openapi.yaml` 为准,之后以 FastAPI 生成的快照为准;事件流用 fetch 读;异地登录确认后挤掉旧设备;扣点说明和实际不一致时拒绝、不扣点;报告后台生成。 +- 下一步:搭工程骨架(仓库结构、CI、Compose 一键启动、「上传 → 解析 → 显示清单」最小链路),见 `docs/architecture/architecture.md` 第 12 节。CI 里补上 OpenAPI 的完整校验(Redocly 或 Spectral)。 diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md new file mode 100644 index 0000000..7207513 --- /dev/null +++ b/docs/architecture/api-design.md @@ -0,0 +1,181 @@ +# 接口契约 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 只做短事务) | diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 61f0255..1c30bf0 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -1,7 +1,7 @@ # 技术架构设计 v0.3 > 依据:PRD v0.9.1 第 7 章(非功能需求)、`docs/analysis/`(功能需求、领域模型、样本结论)、`docs/decisions.md`(T1 及各阶段决策)、参考项目分析 `ref-bid-engine-analysis.md`。 -> 状态:v0.3。v0.2 的选型已全部确认;v0.3 新增 §3.14:MVP 的表结构先按多人、多登录方式建,给以后的团队版、企业版、飞书 / 企业微信登录留结构(AR11、AR12,已确认)。物理数据模型见 `data-model.md`(DM1–DM7,已确认)。 +> 状态:v0.3。v0.2 的选型已全部确认;v0.3 新增 §3.14:MVP 的表结构先按多人、多登录方式建,给以后的团队版、企业版、飞书 / 企业微信登录留结构(AR11、AR12,已确认)。物理数据模型见 `data-model.md`(DM1–DM7,已确认);接口契约见 `api-design.md` + `openapi.yaml`(API1–API5,已确认)。 > - 已确认(2026-11-03):T2–T5;AR1、AR3、AR5–AR9 按建议;AR2 改为 Vue 3 + Vite。 > - 已确认:AR4 修订后的建议(引入 Valkey,只存可以丢的数据,§6.1);AR10 不引入带样式的 UI 框架(§4.3)。至此选型全部确认。 > - 确认后写入 `docs/decisions.md`;构建、运行、测试命令等工程骨架搭好后回填到 AGENTS.md。 @@ -838,5 +838,5 @@ S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项 1. 做技术验证 S1–S4,结论写进本文件,并据此确定 FR-M2-05、FR-M2-06 的目标值。 2. ~~物理数据建模~~:✅ 已完成,见 `data-model.md`、`schema-draft.sql`(DM1–DM7 已确认)。 -3. 定接口契约:先写核心接口的 OpenAPI 定义,前后端据此并行开发。 +3. ~~定接口契约~~:✅ 已完成,见 `api-design.md`、`openapi.yaml`(API1–API5 已确认)。 4. 搭工程骨架:仓库结构、CI、Compose 一键启动、一个贯通「上传 → 解析 → 显示清单」的最小链路。 diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 63a50cc..28b5f40 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -189,7 +189,7 @@ erDiagram | `edited_fields` | 人工改过哪些字段。重新解析对齐时,这些字段不动 | - **类别**:`categories` 可以有多个标签。CHECK 约束保证 ★ 条款一定带「无效 / 废标」标签(FR-M2-05)。 -- **类别特有字段**(05 §3.3)放在 `spec` 里,按类别用 Pydantic 校验。例如技术参数是 `{"param": "内存", "op": ">=", "value": 32, "unit": "GB", "device": "服务器"}`。规则引擎要用的字段都放在这里,常用的再提出来建索引。 +- **类别特有字段**(05 §3.3)放在 `spec` 里,**按类别分组**:一条清单项有几个类别,就可以有几组,每组用对应的 Pydantic 模型校验。例如 ★ 技术参数是 `{"tech_param": {"param": "内存", "op": ">=", "value": "32", "unit": "GB", "device": "服务器"}, "invalidity": {"source": "substantive_marker"}}`。规则引擎要用的字段都放在这里,常用的再提出来建索引。完整定义见 `openapi.yaml` 的 `ItemSpec`。 - **重要性依据**:`importance_basis` 记下凭什么认定为 ★,可以是标注符号、法规列明或原文写明。`marking_rule_id` 指向文件自己的标注规则(07 F1)。 - **区域**:`zone` 分待确认区和正式清单。进待确认区的原因写在 `pending_reason`:没有出处、置信度低、校验没通过、标注句没被覆盖(规则兜底生成的)、重新解析时没再出现。待确认区的条目不参与检查(FR-M2-07)。 - **补遗**: @@ -207,7 +207,7 @@ erDiagram |---|---| | 总价限价 | `project_field`,字段 `ceiling_total` | | 采购包限价 | `lot.ceiling_amount` | -| 分项限价、单价上限 | 清单项,类别是「报价」(DM7),`spec = {"kind": "price_ceiling", "target": "<分项名>", "op": "<=", "value": "..."}` | +| 分项限价、单价上限 | 清单项,类别是「报价」(DM7),`spec = {"price": {"kind": "item_ceiling", "target": "<分项名>", "op": "<=", "value": "..."}}` | 报价形式检查读投标文件里的金额,和这三层比对。读到的金额写在问题的 `detail` 里,同时保留原文写法和出处。 diff --git a/docs/architecture/openapi.yaml b/docs/architecture/openapi.yaml new file mode 100644 index 0000000..8e92ba1 --- /dev/null +++ b/docs/architecture/openapi.yaml @@ -0,0 +1,2897 @@ +openapi: 3.1.0 +info: + title: 标博士用户端接口 + version: 0.1.0 + summary: MVP 用户端(网页版 / 桌面版)的核心接口契约草案 + description: | + 约定见 `docs/architecture/api-design.md`;数据模型见 `data-model.md`。 + 这是设计阶段的契约草案。后端骨架搭好后,以 FastAPI 生成的 OpenAPI 快照为准(api-design §9 API1),本文件不再维护。 + 不含运营后台接口(独立应用,另行定义)。 +servers: + - url: /api/v1 + description: 网页版同源;桌面版由用户选择服务器地址(架构 §5.2) +security: + - cookieSession: [] + - bearerToken: [] +tags: + - name: auth + description: 登录、注册、会话(identity) + - name: me + description: 当前用户与企业 + - name: billing + description: 套餐、额度、开通申请 + - name: projects + description: 项目、基本信息字段、分包 + - name: uploads + description: 分片上传 + - name: documents + description: 文档、页、句子、出处、解析任务 + - name: checklist + description: 项目检查清单 + - name: checks + description: 检查批次、问题、人工核对 + - name: reports + description: 检查报告 + - name: qualifications + description: 最小资质库 + - name: notifications + description: 站内通知、事件流、反馈 + +paths: + # ------------------------------------------------------------------ auth + /auth/sms-codes: + post: + tags: [auth] + operationId: sendSmsCode + summary: 发送短信验证码 + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [phone, purpose] + properties: + phone: { $ref: '#/components/schemas/Phone' } + purpose: { type: string, enum: [login, register, close_tenant] } + responses: + '202': + description: 已发送(号码是否注册都返回 202,不泄露注册状态) + content: + application/json: + schema: + type: object + required: [resend_after_s] + properties: + resend_after_s: { type: integer, description: 多少秒后可以重发 } + '422': { $ref: '#/components/responses/ValidationFailed' } + '429': { $ref: '#/components/responses/RateLimited' } + + /auth/register: + post: + tags: [auth] + operationId: register + summary: 手机号注册(同时创建企业主体和免费版订阅) + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RegisterRequest' } + responses: + '201': + description: 注册成功并登录。网页版设置会话 cookie;桌面版在响应体里拿到令牌(只返回这一次) + headers: + Set-Cookie: { $ref: '#/components/headers/SetSessionCookie' } + content: + application/json: + schema: { $ref: '#/components/schemas/LoginResult' } + '409': + description: '`phone_already_registered`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /auth/login: + post: + tags: [auth] + operationId: login + summary: 手机号 + 验证码登录 + description: | + 同一账号已有其他设备在线时返回 409 `concurrent_session`,附在线设备列表; + 用户确认后带 `force: true` 重发,其他会话被挤下线(API3)。 + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LoginRequest' } + responses: + '200': + description: 登录成功 + headers: + Set-Cookie: { $ref: '#/components/headers/SetSessionCookie' } + content: + application/json: + schema: { $ref: '#/components/schemas/LoginResult' } + '404': + description: '`phone_not_registered`(验证码校验通过后才会返回)' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '409': + description: '`concurrent_session`' + content: + application/problem+json: + schema: + allOf: + - $ref: '#/components/schemas/Problem' + - type: object + properties: + sessions: + type: array + items: { $ref: '#/components/schemas/SessionInfo' } + '422': { $ref: '#/components/responses/ValidationFailed' } + '429': { $ref: '#/components/responses/RateLimited' } + + /auth/logout: + post: + tags: [auth] + operationId: logout + summary: 退出登录(吊销当前会话) + responses: + '204': { description: 已退出 } + '401': { $ref: '#/components/responses/Unauthenticated' } + + # ------------------------------------------------------------------ me + /me: + get: + tags: [me] + operationId: getMe + summary: 当前用户、企业、套餐、权限点 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Me' } + '401': { $ref: '#/components/responses/Unauthenticated' } + patch: + tags: [me] + operationId: updateMe + summary: 修改显示名 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + display_name: { type: string, minLength: 1, maxLength: 40 } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Me' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /me/sessions: + get: + tags: [me] + operationId: listMySessions + summary: 我的在线会话 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/SessionInfo' } + + /me/sessions/{session_id}: + delete: + tags: [me] + operationId: revokeMySession + summary: 让某个设备下线 + parameters: + - $ref: '#/components/parameters/SessionId' + responses: + '204': { description: 已吊销 } + '404': { $ref: '#/components/responses/NotFound' } + + /tenant/close: + post: + tags: [me] + operationId: closeTenant + summary: 注销账号(删除企业下全部业务数据,不可恢复) + description: data-model §6.2。需要短信验证码和企业名称两次确认。成功后当前会话失效。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sms_code, confirm_tenant_name] + properties: + sms_code: { type: string, pattern: '^[0-9]{6}$' } + confirm_tenant_name: { type: string } + responses: + '202': { description: 已受理,后台删除文件 } + '422': { $ref: '#/components/responses/ValidationFailed' } + + # ------------------------------------------------------------------ billing + /plans: + get: + tags: [billing] + operationId: listPlans + summary: 套餐列表(含额度、文件大小和页数上限,购买页和上传页展示用) + security: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Plan' } + + /quota: + get: + tags: [billing] + operationId: getQuota + summary: 额度总览:本期已用、剩余、按功能分布 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/QuotaOverview' } + + /quota/entries: + get: + tags: [billing] + operationId: listQuotaEntries + summary: 额度流水 + parameters: + - name: unit + in: query + schema: { $ref: '#/components/schemas/QuotaUnit' } + - name: project_id + in: query + schema: { $ref: '#/components/schemas/Id' } + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: 按时间倒序 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/QuotaEntry' } + + /activation-requests: + get: + tags: [billing] + operationId: listActivationRequests + summary: 我的专业版开通申请 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/ActivationRequest' } + post: + tags: [billing] + operationId: createActivationRequest + summary: 提交专业版开通申请(之后对公转账,运营确认到账后开通,G8) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ActivationRequestCreate' } + responses: + '201': + description: 已提交 + content: + application/json: + schema: { $ref: '#/components/schemas/ActivationRequest' } + '409': + description: '`activation_pending`:已有一份待处理的申请' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': { $ref: '#/components/responses/ValidationFailed' } + + # ------------------------------------------------------------------ projects + /projects: + get: + tags: [projects] + operationId: listProjects + summary: 工作台项目列表(FR-M6-01) + parameters: + - name: status + in: query + style: form + explode: false + schema: + type: array + items: { $ref: '#/components/schemas/ProjectStatus' } + - name: deadline_from + in: query + schema: { type: string, format: date-time } + - name: deadline_to + in: query + schema: { type: string, format: date-time } + - name: q + in: query + description: 按项目名称、编号、采购人搜索 + schema: { type: string, maxLength: 100 } + - name: sort + in: query + schema: { type: string, enum: [deadline, -deadline, updated, -updated], default: deadline } + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/ProjectListItem' } + post: + tags: [projects] + operationId: createProject + summary: 新建项目 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 200 + description: 选填;不填时为「未命名项目」,解析后按招标文件自动填写 + responses: + '201': + description: 已创建;建项目的人自动成为负责人(AR12) + content: + application/json: + schema: { $ref: '#/components/schemas/ProjectDetail' } + '409': + description: '`plan_limit_exceeded`:免费版限 1 个项目' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /projects/{project_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [projects] + operationId: getProject + summary: 项目概览 + responses: + '200': + description: OK + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/ProjectDetail' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [projects] + operationId: deleteProject + summary: 删除项目(彻底删除业务内容,不可恢复;额度流水保留,G10) + responses: + '204': { description: 已删除;对象存储里的文件由后台清理 } + '404': { $ref: '#/components/responses/NotFound' } + + /projects/{project_id}/fields/{field_key}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: field_key + in: path + required: true + schema: { $ref: '#/components/schemas/ProjectFieldKey' } + put: + tags: [projects] + operationId: setProjectField + summary: 人工修改项目基本信息字段(FR-M2-03) + description: '`value` 为 null 表示撤销人工值,恢复为 AI 提取值。' + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [value] + properties: + value: { $ref: '#/components/schemas/FieldValue' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ProjectField' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '412': { $ref: '#/components/responses/PreconditionFailed' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /projects/{project_id}/fields/{field_key}/pending: + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: field_key + in: path + required: true + schema: { $ref: '#/components/schemas/ProjectFieldKey' } + post: + tags: [projects] + operationId: resolvePendingField + summary: 确认或驳回补遗带来的新值(FR-M2-10) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accept] + properties: + accept: { type: boolean } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ProjectField' } + '409': + description: '`no_pending_value` 或 `project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /projects/{project_id}/transitions: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: [projects] + operationId: transitionProject + summary: 手动变更项目状态(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02) + description: 致命问题未处理也可以递交(红线 4);响应里带当时的就绪状态,由前端提示。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action] + properties: + action: { type: string, enum: [submit, unsubmit, archive, unarchive] } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ProjectDetail' } + '409': + description: '`invalid_transition`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /projects/{project_id}/lots: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [projects] + operationId: listLots + summary: 分包列表 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Lot' } + post: + tags: [projects] + operationId: createLot + summary: 手工增加分包(FR-M2-04) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LotInput' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Lot' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /projects/{project_id}/bidding-lots: + parameters: + - $ref: '#/components/parameters/ProjectId' + put: + tags: [projects] + operationId: setBiddingLots + summary: 选择要投的包 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [lot_ids] + properties: + lot_ids: + type: array + minItems: 1 + items: { $ref: '#/components/schemas/Id' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Lot' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + + /lots/{lot_id}: + parameters: + - name: lot_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + patch: + tags: [projects] + operationId: updateLot + summary: 修改分包 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LotInput' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Lot' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '422': { $ref: '#/components/responses/ValidationFailed' } + delete: + tags: [projects] + operationId: deleteLot + summary: 删除分包(原属这个包的清单项变为各包通用) + responses: + '204': { description: 已删除 } + '409': + description: '`last_lot`:至少保留一个包;或 `project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + # ------------------------------------------------------------------ uploads + /uploads: + post: + tags: [uploads] + operationId: createUpload + summary: 开始一次分片上传(架构 §5.2 第 7 条) + description: 先按套餐校验文件大小;超出上限直接返回 409,不建上传(FR-M2-01)。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [filename, size_bytes, purpose] + properties: + filename: { type: string, maxLength: 255 } + size_bytes: { type: integer, minimum: 1 } + sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: 选填;提供时完成上传时校验 } + purpose: { type: string, enum: [project_document, qualification_file] } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Upload' } + '409': + description: '`plan_limit_exceeded`:超出单文件大小上限,`detail` 写明上限值' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /uploads/{upload_id}: + parameters: + - $ref: '#/components/parameters/UploadId' + get: + tags: [uploads] + operationId: getUpload + summary: 查询已收到的分片(断点续传用) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Upload' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [uploads] + operationId: abortUpload + summary: 放弃上传 + responses: + '204': { description: 已放弃 } + + /uploads/{upload_id}/parts/{part_no}: + parameters: + - $ref: '#/components/parameters/UploadId' + - name: part_no + in: path + required: true + schema: { type: integer, minimum: 1 } + put: + tags: [uploads] + operationId: putUploadPart + summary: 上传一个分片(重复上传同一分片会覆盖) + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary } + responses: + '204': { description: 已收到 } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /uploads/{upload_id}/complete: + parameters: + - $ref: '#/components/parameters/UploadId' + post: + tags: [uploads] + operationId: completeUpload + summary: 完成上传:合并分片、按文件头识别格式、读页数 + description: | + 格式、加密、损坏、页数超限都在这里拦截,不建任务、不扣点(FR-M2-01)。 + 识别出电子投标平台的加密格式时返回 `bid_file_platform_encrypted`(FR-M3-01)。 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/UploadCompleted' } + '409': + description: '`upload_incomplete`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': + description: | + `file_format_unsupported`、`file_encrypted`、`file_corrupted`、`bid_file_platform_encrypted`、 + `upload_checksum_mismatch`、`plan_limit_exceeded`(页数) + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + # ------------------------------------------------------------------ documents + /projects/{project_id}/documents: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [documents] + operationId: listDocuments + summary: 项目的文档(招标文件、补遗、澄清、投标分册的各个版本) + parameters: + - name: role + in: query + schema: { $ref: '#/components/schemas/DocumentRole' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Document' } + post: + tags: [documents] + operationId: createDocument + summary: 把上传好的文件挂到项目上(不扣点;招标侧文件要另外发起解析) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DocumentCreate' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /documents/{document_id}: + parameters: + - $ref: '#/components/parameters/DocumentId' + get: + tags: [documents] + operationId: getDocument + summary: 文档详情 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Document' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [documents] + operationId: deleteDocument + summary: 删除文档 + description: 已被清单项或检查批次引用的文档不能删除,返回 `document_in_use`。 + responses: + '204': { description: 已删除 } + '409': + description: '`document_in_use` 或 `project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /documents/{document_id}/file: + parameters: + - $ref: '#/components/parameters/DocumentId' + get: + tags: [documents] + operationId: getDocumentFileUrl + summary: 取文件的短时下载地址(pdf.js 直接加载,不需要带鉴权头) + parameters: + - name: variant + in: query + required: true + schema: { type: string, enum: [normalized, original] } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/DownloadUrl' } + '409': + description: '`document_not_ready`:规范化 PDF 还没生成' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /documents/{document_id}/pages: + parameters: + - $ref: '#/components/parameters/DocumentId' + get: + tags: [documents] + operationId: listDocumentPages + summary: 逐页摘要(阅读器的页尺寸、OCR 标记) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [normalized_sha256, items] + properties: + normalized_sha256: { type: string } + items: + type: array + items: { $ref: '#/components/schemas/DocumentPage' } + + /documents/{document_id}/pages/{page_no}/sentences: + parameters: + - $ref: '#/components/parameters/DocumentId' + - name: page_no + in: path + required: true + schema: { type: integer, minimum: 1 } + get: + tags: [documents] + operationId: listPageSentences + summary: 一页的句子(阅读器里选句补出处用;数据来自对象存储的文字层,DM1) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Sentence' } + '404': { $ref: '#/components/responses/NotFound' } + + /projects/{project_id}/citations: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: [documents] + operationId: createCitation + summary: 人工建一条出处(在阅读器里选中句子) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CitationCreate' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Citation' } + '422': + description: '`sentence_not_found`:句子编号不在这份文档里,或不在同一页' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /documents/{document_id}/parse-jobs: + parameters: + - $ref: '#/components/parameters/DocumentId' + post: + tags: [documents] + operationId: startParseJob + summary: 发起解析(预扣 1 次解析;失败、超时、取消都退回) + description: | + 只用于招标文件、补遗、澄清;投标分册的解析算在检查里(01 §4)。 + 失败后重试也调用这个接口,会建一条新任务并重新预扣,上一条已经退回(FR-M2-02)。 + requestBody: + content: + application/json: + schema: + type: object + properties: + expected_charge: + type: integer + enum: [1] + description: 前端提示用户的扣点数;和服务端算的不一致时返回 `billing_changed`(API4) + responses: + '201': + description: 已排队 + content: + application/json: + schema: { $ref: '#/components/schemas/ParseJob' } + '409': + description: '`job_already_running`(附 `job_id`)、`quota_exhausted`、`billing_changed`、`document_role_not_parsable`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /parse-jobs/{job_id}: + parameters: + - $ref: '#/components/parameters/JobId' + get: + tags: [documents] + operationId: getParseJob + summary: 解析进度(事件流断线时的轮询兜底) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ParseJob' } + '404': { $ref: '#/components/responses/NotFound' } + + /parse-jobs/{job_id}/cancel: + parameters: + - $ref: '#/components/parameters/JobId' + post: + tags: [documents] + operationId: cancelParseJob + summary: 取消解析(退回额度) + responses: + '202': + description: 已受理;worker 在下一个检查点停下 + content: + application/json: + schema: { $ref: '#/components/schemas/ParseJob' } + '409': + description: '`job_finished`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + # ------------------------------------------------------------------ checklist + /projects/{project_id}/checklist: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checklist] + operationId: getChecklist + summary: 清单概况:状态、版本、各类数量、标注规则、数量核对提示 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Checklist' } + '404': { $ref: '#/components/responses/NotFound' } + + /projects/{project_id}/checklist/items: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checklist] + operationId: listChecklistItems + summary: 清单项(一个项目几百条,一次返回全部,不分页) + parameters: + - name: zone + in: query + schema: { $ref: '#/components/schemas/Zone' } + - name: category + in: query + schema: { $ref: '#/components/schemas/Category' } + - name: lot_scope + in: query + description: '`bidding` = 通用 + 所投的包(默认,FR-M2-04);`all` = 全部' + schema: { type: string, enum: [bidding, all], default: bidding } + - name: include_deleted + in: query + schema: { type: boolean, default: false } + responses: + '200': + description: 按原文顺序 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/ChecklistItem' } + post: + tags: [checklist] + operationId: createChecklistItem + summary: 人工新增清单项(标注「人工添加」,可以没有出处) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItemCreate' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /checklist-items/{item_id}: + parameters: + - $ref: '#/components/parameters/ItemId' + get: + tags: [checklist] + operationId: getChecklistItem + summary: 清单项详情 + responses: + '200': + description: OK + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '404': { $ref: '#/components/responses/NotFound' } + patch: + tags: [checklist] + operationId: updateChecklistItem + summary: 修改清单项(改过的字段记入 edited_fields;已确认的清单回到草稿) + parameters: + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItemUpdate' } + responses: + '200': + description: OK + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '412': { $ref: '#/components/responses/PreconditionFailed' } + '422': { $ref: '#/components/responses/ValidationFailed' } + delete: + tags: [checklist] + operationId: deleteChecklistItem + summary: 删除(软删除,可以恢复;待确认区的条目就是「丢弃」) + responses: + '204': { description: 已删除 } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + + /checklist-items/{item_id}/restore: + parameters: + - $ref: '#/components/parameters/ItemId' + post: + tags: [checklist] + operationId: restoreChecklistItem + summary: 恢复已删除的条目 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + + /checklist-items/{item_id}/accept: + parameters: + - $ref: '#/components/parameters/ItemId' + post: + tags: [checklist] + operationId: acceptPendingItem + summary: 从待确认区采纳进正式清单(FR-M2-07) + description: 必须补上出处,或者声明「人工确认、无出处」;可以同时修改字段(修改后采纳)。 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + citation_ids: + type: array + items: { $ref: '#/components/schemas/Id' } + no_citation_ack: { type: boolean, default: false } + changes: { $ref: '#/components/schemas/ChecklistItemUpdate' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '409': + description: '`item_not_pending` 或 `project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': + description: '`citation_required`:既没有出处也没有声明无出处' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /checklist-items/{item_id}/citations: + parameters: + - $ref: '#/components/parameters/ItemId' + put: + tags: [checklist] + operationId: setItemCitations + summary: 替换清单项的出处列表(按顺序) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [citation_ids] + properties: + citation_ids: + type: array + items: { $ref: '#/components/schemas/Id' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + + /checklist-items/{item_id}/supersession: + parameters: + - $ref: '#/components/parameters/ItemId' + post: + tags: [checklist] + operationId: resolveSupersession + summary: 确认或驳回「已被补遗 N 更新」(FR-M2-10) + description: 接受时旧条目软删除,新条目保留;驳回时解除关联,两条都保留,由用户自己处理。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [accept] + properties: + accept: { type: boolean } + responses: + '200': + description: 返回旧条目 + content: + application/json: + schema: { $ref: '#/components/schemas/ChecklistItem' } + '409': + description: '`not_superseded`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /checklist-items/{item_id}/history: + parameters: + - $ref: '#/components/parameters/ItemId' + get: + tags: [checklist] + operationId: getItemHistory + summary: 修改记录(谁、什么时候、改前、改后;来自审计事件) + responses: + '200': + description: 按时间倒序 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/HistoryEntry' } + + /projects/{project_id}/checklist/confirm: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: [checklist] + operationId: confirmChecklist + summary: 确认清单(生成不可变版本) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Checklist' } + '409': + description: '`checklist_empty` 或 `project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /projects/{project_id}/checklist/versions: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checklist] + operationId: listChecklistVersions + summary: 清单版本 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/ChecklistVersion' } + + # ------------------------------------------------------------------ checks + /projects/{project_id}/check-preview: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checks] + operationId: getCheckPreview + summary: 发起检查前的预览:这次扣不扣点、用哪些文件、有什么提示 + description: 「开始检查」按钮旁的扣点说明来自这里(红线 9)。 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/CheckPreview' } + + /projects/{project_id}/check-runs: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checks] + operationId: listCheckRuns + summary: 检查记录 + responses: + '200': + description: 按时间倒序 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/CheckRun' } + post: + tags: [checks] + operationId: startCheckRun + summary: 发起检查(FR-M3-02) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CheckRunCreate' } + responses: + '201': + description: 已排队 + content: + application/json: + schema: { $ref: '#/components/schemas/CheckRun' } + '409': + description: | + `job_already_running`(附 `job_id`)、`quota_exhausted`、`billing_changed`(附最新的 `preview`)、 + `no_parsed_tender`、`no_bid_documents`、`project_read_only` + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /check-runs/{run_id}: + parameters: + - $ref: '#/components/parameters/RunId' + get: + tags: [checks] + operationId: getCheckRun + summary: 检查批次(进度、覆盖范围、各级别数量) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/CheckRun' } + '404': { $ref: '#/components/responses/NotFound' } + + /check-runs/{run_id}/cancel: + parameters: + - $ref: '#/components/parameters/RunId' + post: + tags: [checks] + operationId: cancelCheckRun + summary: 取消检查(退回额度) + responses: + '202': + description: 已受理 + content: + application/json: + schema: { $ref: '#/components/schemas/CheckRun' } + '409': + description: '`job_finished`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /check-runs/{run_id}/findings: + parameters: + - $ref: '#/components/parameters/RunId' + get: + tags: [checks] + operationId: listFindings + summary: 本批次的全部问题(含「本次已解决」,一次返回全部) + parameters: + - name: severity + in: query + schema: { $ref: '#/components/schemas/Severity' } + - name: status + in: query + schema: { $ref: '#/components/schemas/FindingStatus' } + - name: presence + in: query + schema: { $ref: '#/components/schemas/Presence' } + responses: + '200': + description: 按级别、再按原文顺序 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Finding' } + + /check-runs/{run_id}/findings/bulk-resolve: + parameters: + - $ref: '#/components/parameters/RunId' + post: + tags: [checks] + operationId: bulkResolveFindings + summary: 批量处理(只限「提示」级,FR-M3-07) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [finding_ids, status] + properties: + finding_ids: + type: array + minItems: 1 + maxItems: 500 + items: { $ref: '#/components/schemas/Id' } + status: { type: string, enum: [confirmed_ok, ignored] } + note: { type: string, maxLength: 500 } + responses: + '200': + description: 全部成功才提交,否则一条都不改 + content: + application/json: + schema: + type: object + required: [items, project] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Finding' } + project: { $ref: '#/components/schemas/ProjectReadiness' } + '409': + description: '`run_superseded`、`project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': + description: '`bulk_only_hint`(附不符合的问题编号)、`note_required`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /findings/{finding_id}/resolve: + parameters: + - $ref: '#/components/parameters/FindingId' + post: + tags: [checks] + operationId: resolveFinding + summary: 处理一个问题 + description: | + 忽略必须写原因;致命问题选「确认无误」也必须写说明,说明同时进入误报反馈(U4)。 + 只有最近一次检查的问题可以处理,历史批次只读。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { type: string, enum: [confirmed_ok, modified, ignored] } + note: { type: string, maxLength: 500 } + responses: + '200': + description: 返回问题和项目最新的就绪状态 + content: + application/json: + schema: + type: object + required: [finding, project] + properties: + finding: { $ref: '#/components/schemas/Finding' } + project: { $ref: '#/components/schemas/ProjectReadiness' } + '409': + description: '`run_superseded`、`finding_resolved_this_run`、`project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + '422': + description: '`note_required`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /findings/{finding_id}/reopen: + parameters: + - $ref: '#/components/parameters/FindingId' + post: + tags: [checks] + operationId: reopenFinding + summary: 撤销处理,回到待处理(U 键) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [finding, project] + properties: + finding: { $ref: '#/components/schemas/Finding' } + project: { $ref: '#/components/schemas/ProjectReadiness' } + '409': + description: '`run_superseded`、`project_read_only`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /projects/{project_id}/manual-checks: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [checks] + operationId: listManualChecks + summary: 需人工核对的条目和勾选结果(按项目存,P2) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/ManualCheck' } + put: + tags: [checks] + operationId: setManualCheck + summary: 勾选或取消勾选一条 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [check_key, checked] + properties: + check_key: { type: string, description: 'item:<清单项编号> 或 rule:<规则编号>' } + checked: { type: boolean } + note: { type: string, maxLength: 500 } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ManualCheck' } + '409': { $ref: '#/components/responses/ProjectReadOnly' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + # ------------------------------------------------------------------ reports + /check-runs/{run_id}/reports: + parameters: + - $ref: '#/components/parameters/RunId' + post: + tags: [reports] + operationId: generateReport + summary: 生成检查报告 PDF(后台生成,完成后推送事件) + description: 致命问题未处理也可以导出(红线 4)。每次生成都是一份新报告。 + responses: + '202': + description: 已排队 + content: + application/json: + schema: { $ref: '#/components/schemas/ReportJob' } + '409': + description: '`run_not_succeeded`' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + /report-jobs/{job_id}: + parameters: + - $ref: '#/components/parameters/JobId' + get: + tags: [reports] + operationId: getReportJob + summary: 报告生成进度(轮询兜底) + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/ReportJob' } + '404': { $ref: '#/components/responses/NotFound' } + + /projects/{project_id}/reports: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: [reports] + operationId: listReports + summary: 项目的报告(随项目归档,可以重复下载) + responses: + '200': + description: 按时间倒序 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Report' } + + /reports/{report_id}/file: + parameters: + - name: report_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + get: + tags: [reports] + operationId: getReportFileUrl + summary: 报告的短时下载地址 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/DownloadUrl' } + '404': { $ref: '#/components/responses/NotFound' } + + # ------------------------------------------------------------------ qualifications + /qualifications: + get: + tags: [qualifications] + operationId: listQualifications + summary: 资质库 + parameters: + - name: kind + in: query + schema: { $ref: '#/components/schemas/QualificationKind' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/Qualification' } + post: + tags: [qualifications] + operationId: createQualification + summary: 新增资质 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/QualificationInput' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Qualification' } + '422': { $ref: '#/components/responses/ValidationFailed' } + + /qualifications/{qualification_id}: + parameters: + - $ref: '#/components/parameters/QualificationId' + put: + tags: [qualifications] + operationId: updateQualification + summary: 修改资质 + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/QualificationInput' } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Qualification' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationFailed' } + delete: + tags: [qualifications] + operationId: deleteQualification + summary: 删除资质(连同附件) + responses: + '204': { description: 已删除 } + + /qualifications/{qualification_id}/files: + parameters: + - $ref: '#/components/parameters/QualificationId' + post: + tags: [qualifications] + operationId: attachQualificationFile + summary: 挂上一个已上传的附件 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [upload_id] + properties: + upload_id: { $ref: '#/components/schemas/Id' } + responses: + '201': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Qualification' } + + /qualifications/{qualification_id}/files/{file_id}: + parameters: + - $ref: '#/components/parameters/QualificationId' + - name: file_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + get: + tags: [qualifications] + operationId: getQualificationFileUrl + summary: 附件的短时下载地址 + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/DownloadUrl' } + delete: + tags: [qualifications] + operationId: deleteQualificationFile + summary: 删除附件 + responses: + '204': { description: 已删除 } + + # ------------------------------------------------------------------ notifications / events / feedback + /notifications: + get: + tags: [notifications] + operationId: listNotifications + summary: 站内通知 + parameters: + - name: unread + in: query + schema: { type: boolean } + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: 按时间倒序 + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [items, unread_count] + properties: + unread_count: { type: integer } + items: + type: array + items: { $ref: '#/components/schemas/Notification' } + + /notifications/read: + post: + tags: [notifications] + operationId: markNotificationsRead + summary: 标为已读(不传编号就是全部已读) + requestBody: + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: { $ref: '#/components/schemas/Id' } + responses: + '204': { description: OK } + + /events: + get: + tags: [notifications] + operationId: streamEvents + summary: 事件流(SSE) + description: | + 当前用户在当前企业下的事件。前端用 fetch 读流(可以带 Bearer 头),不用浏览器的 EventSource(API2)。 + 每条事件带 `id`;断线重连时带 `Last-Event-ID`,服务端尽量补发,补不了时发 `resync`,前端重新拉取数据。 + 事件只是「有变化」的提示,权威状态以对应资源的 GET 为准。 + parameters: + - name: Last-Event-ID + in: header + schema: { type: string } + responses: + '200': + description: '`text/event-stream`;`event` 字段是 Event.type,`data` 是 Event 的 JSON' + content: + text/event-stream: + schema: { $ref: '#/components/schemas/Event' } + + /feedback: + post: + tags: [notifications] + operationId: createFeedback + summary: 反馈判断有误(误报 / 漏报,J5) + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FeedbackCreate' } + responses: + '201': { description: 已提交 } + '422': { $ref: '#/components/responses/ValidationFailed' } + +components: + securitySchemes: + cookieSession: + type: apiKey + in: cookie + name: bd_session + description: 网页版。HttpOnly、Secure、SameSite=Lax;写操作还要带 `X-CSRF-Token`(等于 cookie `bd_csrf` 的值) + bearerToken: + type: http + scheme: bearer + description: 桌面版。令牌存系统钥匙串;不需要 CSRF 头 + + headers: + SetSessionCookie: + description: 网页版登录时设置 `bd_session`(HttpOnly)和 `bd_csrf`(前端可读) + schema: { type: string } + ETag: + description: 资源版本;修改时放进 If-Match + schema: { type: string } + + parameters: + ProjectId: + name: project_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + DocumentId: + name: document_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + UploadId: + name: upload_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + JobId: + name: job_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + RunId: + name: run_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + ItemId: + name: item_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + FindingId: + name: finding_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + QualificationId: + name: qualification_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + SessionId: + name: session_id + in: path + required: true + schema: { $ref: '#/components/schemas/Id' } + Cursor: + name: cursor + in: query + description: 上一页返回的 next_cursor + schema: { type: string } + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + IfMatch: + name: If-Match + in: header + description: 选填;带上时版本不一致返回 412(多人编辑时防止覆盖) + schema: { type: string } + + responses: + Unauthenticated: + description: '`unauthenticated`、`session_expired`、`session_revoked`(被其他设备挤下线时附 `reason: concurrent_login`)' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + NotFound: + description: '`not_found`;访问别的企业的资源也返回这个(架构 §3.3)' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + ValidationFailed: + description: '`validation_failed`;`errors` 列出每个字段的问题' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + RateLimited: + description: '`rate_limited`;响应头 Retry-After' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + ProjectReadOnly: + description: '`project_read_only`:项目已递交或已归档,要先撤销' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + PreconditionFailed: + description: '`precondition_failed`:资源已被修改,请刷新后再改' + content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } + + schemas: + # ---------- 基础 + Id: + type: string + format: uuid + Timestamp: + type: string + format: date-time + description: RFC 3339,带时区 + Money: + type: string + pattern: '^-?[0-9]+(\.[0-9]{1,2})?$' + description: 金额用字符串传,避免浮点误差;单位元 + Phone: + type: string + pattern: '^1[3-9][0-9]{9}$' + description: 中国大陆手机号,11 位 + Page: + type: object + required: [next_cursor] + properties: + next_cursor: + type: [string, 'null'] + description: 为 null 表示没有下一页 + Problem: + type: object + description: RFC 9457 problem details,`code` 是给前端判断用的稳定编码(api-design §5) + required: [type, title, status, code, request_id] + properties: + type: { type: string, description: '固定为 about:blank 或错误码文档地址' } + title: { type: string, description: 给用户看的一句话(中文) } + status: { type: integer } + code: { type: string, examples: [quota_exhausted] } + detail: { type: string } + request_id: { type: string } + errors: + type: array + items: + type: object + required: [field, code, message] + properties: + field: { type: string, description: '字段路径,如 spec.tech_param.value' } + code: { type: string } + message: { type: string } + additionalProperties: true + DownloadUrl: + type: object + required: [url, expires_at] + properties: + url: { type: string, format: uri } + expires_at: { $ref: '#/components/schemas/Timestamp' } + sha256: { type: string } + UserRef: + type: object + description: 操作人;人离职或注销后仍显示当时的姓名 + required: [id, name] + properties: + id: { $ref: '#/components/schemas/Id' } + name: { type: string } + + # ---------- 枚举(值与数据库一致) + ProjectStatus: { type: string, enum: [tracking, drafting, checked, submitted, archived] } + LegalRegime: { type: string, enum: [gov_procurement, tendering, unknown] } + DocumentRole: { type: string, enum: [tender, addendum, clarification, bid_volume] } + DetectedFormat: { type: string, enum: [pdf, docx, doc, wps, ofd, encrypted_bid, unknown] } + JobStatus: { type: string, enum: [queued, running, succeeded, failed, cancelled] } + Category: { type: string, enum: [qualification, invalidity, scoring, schedule, tech_param, format_doc, price] } + Importance: + type: string + enum: [substantive, important, normal] + description: 界面显示为 ★ / ▲ / 一般 + Zone: { type: string, enum: [pending, formal] } + PendingReason: { type: string, enum: [no_citation, low_confidence, verify_failed, marker_uncovered, absent_in_rerun] } + Origin: { type: string, enum: [ai, rule, human] } + FindingType: + type: string + enum: [unresponded, negative_deviation, suspected_unmet, formality, qualification, price_form, manual_review] + Severity: { type: string, enum: [fatal, important, hint] } + FindingStatus: { type: string, enum: [pending, confirmed_ok, modified, ignored] } + Presence: + type: string + enum: [new, carried, resolved] + description: 新增 / 沿用 / 本次已解决 + BillingMode: + type: string + enum: [charged, waived_recheck, free_plan] + QuotaUnit: { type: string, enum: [parse, check, generate, dedupe] } + QuotaEntryKind: { type: string, enum: [grant, hold, settle, release, expire, adjust, waive] } + PlanCode: { type: string, enum: [free, pro, enterprise] } + QualificationKind: { type: string, enum: [certificate, system_cert, software_copyright, other] } + ProjectFieldKey: + type: string + enum: [name, code, purchaser, agency, procurement_method, legal_regime, budget_total, ceiling_total, + eval_method, accept_consortium, accept_imported, sme_only, deadline_at] + NextAction: + type: string + description: 工作台「下一步」,由服务端按项目状态算出 + enum: [upload_tender, wait_parse, select_lots, confirm_checklist, upload_bid, run_check, + handle_fatal, export_report, archive, none] + Permission: + type: string + description: 权限点(AR12)。MVP 的所有者拥有全部;前端只用来决定按钮显不显示,服务端仍会检查 + enum: [project.create, project.edit, project.delete, checklist.edit, checklist.confirm, + check.run, finding.resolve, report.export, quota.view, billing.manage, qualification.edit, tenant.close] + + # ---------- auth / me + RegisterRequest: + type: object + required: [phone, sms_code, tenant_name, display_name, terms_version, client_kind] + properties: + phone: { $ref: '#/components/schemas/Phone' } + sms_code: { type: string, pattern: '^[0-9]{6}$' } + tenant_name: { type: string, minLength: 2, maxLength: 100, description: 企业名称(MVP 不做认证,G9) } + display_name: { type: string, minLength: 1, maxLength: 40 } + terms_version: { type: string, description: 同意的服务协议版本(含围串标禁止条款) } + client_kind: { type: string, enum: [web, desktop] } + LoginRequest: + type: object + required: [phone, sms_code, client_kind] + properties: + phone: { $ref: '#/components/schemas/Phone' } + sms_code: { type: string, pattern: '^[0-9]{6}$' } + client_kind: { type: string, enum: [web, desktop] } + force: + type: boolean + default: false + description: 已有其他设备在线时,确认挤下线后重发 + LoginResult: + type: object + required: [me] + properties: + me: { $ref: '#/components/schemas/Me' } + token: + type: string + description: 只有 client_kind = desktop 时返回,且只返回这一次 + expires_at: { $ref: '#/components/schemas/Timestamp' } + SessionInfo: + type: object + required: [id, client_kind, created_at, last_seen_at, current] + properties: + id: { $ref: '#/components/schemas/Id' } + client_kind: { type: string, enum: [web, desktop] } + device: { type: string, description: 从 User-Agent 归纳,如「Chrome · macOS」 } + ip_region: { type: string, description: 'IP 归属地,如「江苏南京」;不返回完整 IP' } + created_at: { $ref: '#/components/schemas/Timestamp' } + last_seen_at: { $ref: '#/components/schemas/Timestamp' } + current: { type: boolean } + Me: + type: object + required: [user, tenant, role, plan, permissions] + properties: + user: + type: object + required: [id, display_name, phone_hint] + properties: + id: { $ref: '#/components/schemas/Id' } + display_name: { type: string } + phone_hint: { type: string, examples: ['138****5678'] } + tenant: + type: object + required: [id, name] + properties: + id: { $ref: '#/components/schemas/Id' } + name: { type: string } + role: { type: string, enum: [owner, admin, member] } + plan: { $ref: '#/components/schemas/PlanSummary' } + permissions: + type: array + items: { $ref: '#/components/schemas/Permission' } + + # ---------- billing + PlanSummary: + type: object + required: [code, name, read_only] + properties: + code: { $ref: '#/components/schemas/PlanCode' } + name: { type: string } + starts_at: { $ref: '#/components/schemas/Timestamp' } + ends_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + read_only: + type: boolean + description: 套餐到期后数据只读(FR-M6-05) + Plan: + type: object + required: [code, version, name, quotas, limits] + properties: + code: { $ref: '#/components/schemas/PlanCode' } + version: { type: integer } + name: { type: string } + quotas: + type: object + description: 每个周期的额度;专业版的检查从解析额度扣 + additionalProperties: { type: integer } + limits: + type: object + properties: + max_file_bytes: { type: integer } + max_pages: { type: integer } + max_projects: { type: [integer, 'null'] } + check_rule: + type: string + description: 给用户看的检查计费说明,如「检查从解析额度扣 1 次;同一项目 24 小时内复查不扣」 + QuotaOverview: + type: object + required: [plan, accounts] + properties: + plan: { $ref: '#/components/schemas/PlanSummary' } + accounts: + type: array + items: + type: object + required: [unit, balance, period_granted, period_used] + properties: + unit: { $ref: '#/components/schemas/QuotaUnit' } + balance: { type: integer, description: 剩余,含正在预扣的 } + held: { type: integer, description: 正在预扣、还没结算的 } + period_granted: { type: integer } + period_used: { type: integer } + used_ratio: { type: number, description: '0~1;≥ 0.8 时界面变赭色(Q9)' } + by_feature: + type: array + description: 按功能分布,如解析、检查 + items: + type: object + required: [feature, used] + properties: + feature: { type: string, enum: [parse, check] } + used: { type: integer } + QuotaEntry: + type: object + required: [id, unit, kind, amount, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + unit: { $ref: '#/components/schemas/QuotaUnit' } + kind: { $ref: '#/components/schemas/QuotaEntryKind' } + amount: { type: integer, description: 对余额的影响;结算、免扣为 0 } + reason: { type: string, description: '调整原因;免扣时为规则说明,如「24 小时内复查」' } + project: + type: [object, 'null'] + properties: + id: { $ref: '#/components/schemas/Id' } + name: { type: [string, 'null'], description: '项目已删除时为 null,界面显示「(已删除的项目)」' } + deleted: { type: boolean } + ref: + type: [object, 'null'] + properties: + type: { type: string, enum: [parse_job, check_run] } + id: { $ref: '#/components/schemas/Id' } + label: { type: [string, 'null'], description: '如「招标文件解析」「第 3 次检查」' } + created_at: { $ref: '#/components/schemas/Timestamp' } + ActivationRequestCreate: + type: object + required: [plan_code, invoice_info] + properties: + plan_code: { type: string, enum: [pro] } + invoice_info: { $ref: '#/components/schemas/InvoiceInfo' } + InvoiceInfo: + type: object + required: [title, tax_no] + properties: + title: { type: string, description: 发票抬头 } + tax_no: { type: string, description: 纳税人识别号 } + address_phone: { type: string } + bank_account: { type: string } + invoice_type: { type: string, enum: [general, special] } + contact_name: { type: string } + contact_phone: { type: string } + ActivationRequest: + type: object + required: [id, plan_code, status, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + plan_code: { $ref: '#/components/schemas/PlanCode' } + status: { type: string, enum: [submitted, paid, activated, rejected, cancelled] } + invoice_info: { $ref: '#/components/schemas/InvoiceInfo' } + payment_instructions: { type: string, description: 对公转账账户和备注要求 } + ops_note: { type: string } + created_at: { $ref: '#/components/schemas/Timestamp' } + + # ---------- projects + ProjectReadiness: + type: object + description: 就绪状态只在 status = checked 时有意义(G7);未就绪也不阻止导出和递交(红线 4) + required: [status, open_fatal_count, ready] + properties: + status: { $ref: '#/components/schemas/ProjectStatus' } + open_fatal_count: { type: integer } + ready: { type: [boolean, 'null'], description: '不是 checked 状态时为 null' } + ProjectListItem: + type: object + required: [id, name, status, readiness, next_action, bidding_lots, updated_at] + properties: + id: { $ref: '#/components/schemas/Id' } + name: { type: string } + code: { type: [string, 'null'] } + purchaser: { type: [string, 'null'] } + deadline_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + status: { $ref: '#/components/schemas/ProjectStatus' } + readiness: { $ref: '#/components/schemas/ProjectReadiness' } + bidding_lots: + type: array + items: { type: string, description: 包号 } + pending_item_count: { type: integer, description: 待确认区条目数 } + active_job: + type: [object, 'null'] + description: 正在跑的解析或检查 + properties: + kind: { type: string, enum: [parse_job, check_run] } + id: { $ref: '#/components/schemas/Id' } + progress: { type: integer } + label: { type: string } + next_action: { $ref: '#/components/schemas/NextAction' } + updated_at: { $ref: '#/components/schemas/Timestamp' } + ProjectDetail: + allOf: + - $ref: '#/components/schemas/ProjectListItem' + - type: object + required: [legal_regime, fields, key_dates, created_by, created_at] + properties: + legal_regime: { $ref: '#/components/schemas/LegalRegime' } + agency: { type: [string, 'null'] } + procurement_method: { type: [string, 'null'] } + fields: + type: array + description: 基本信息字段(FR-M2-03),每个都带出处 + items: { $ref: '#/components/schemas/ProjectField' } + key_dates: + type: array + description: 关键日期时间线(FR-M2-09),来自「日期与递交」类清单项 + items: + type: object + required: [item_id, label, at] + properties: + item_id: { $ref: '#/components/schemas/Id' } + label: { type: string, examples: [投标截止] } + at: { $ref: '#/components/schemas/Timestamp' } + superseded_pending: { type: boolean, description: 补遗带来了新值、等用户确认 } + latest_check_run_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + members: + type: array + items: + type: object + properties: + user: { $ref: '#/components/schemas/UserRef' } + role: { type: string, enum: [lead, editor, viewer] } + created_by: { $ref: '#/components/schemas/UserRef' } + created_at: { $ref: '#/components/schemas/Timestamp' } + FieldValue: + description: 按字段:文本、布尔、金额(Money 字符串)或时间 + type: [string, boolean, 'null'] + ProjectField: + type: object + required: [key, value, source] + properties: + key: { $ref: '#/components/schemas/ProjectFieldKey' } + value: { $ref: '#/components/schemas/FieldValue' } + source: { type: string, enum: [ai, human, none] } + ai_value: { $ref: '#/components/schemas/FieldValue' } + citation: + oneOf: + - $ref: '#/components/schemas/Citation' + - type: 'null' + updated_by: + oneOf: + - $ref: '#/components/schemas/UserRef' + - type: 'null' + pending: + type: [object, 'null'] + description: 补遗带来的新值(FR-M2-10) + properties: + value: { $ref: '#/components/schemas/FieldValue' } + document_label: { type: string, examples: [补遗 1] } + citation: { $ref: '#/components/schemas/Citation' } + LotInput: + type: object + properties: + lot_no: { type: string, maxLength: 40 } + name: { type: string, maxLength: 200 } + budget_amount: + oneOf: + - $ref: '#/components/schemas/Money' + - type: 'null' + ceiling_amount: + oneOf: + - $ref: '#/components/schemas/Money' + - type: 'null' + Lot: + type: object + required: [id, lot_no, is_default, is_bidding, origin, edited_fields] + properties: + id: { $ref: '#/components/schemas/Id' } + lot_no: { type: string } + name: { type: [string, 'null'] } + budget_amount: + oneOf: + - $ref: '#/components/schemas/Money' + - type: 'null' + ceiling_amount: + oneOf: + - $ref: '#/components/schemas/Money' + - type: 'null' + is_default: { type: boolean, description: 没有分包时自动建的默认包,界面跳过选包 } + is_bidding: { type: boolean } + origin: { $ref: '#/components/schemas/Origin' } + edited_fields: + type: array + items: { type: string } + + # ---------- uploads / documents + Upload: + type: object + required: [id, part_size, part_count, received_parts, expires_at] + properties: + id: { $ref: '#/components/schemas/Id' } + part_size: { type: integer, description: 字节,最后一片可以更小 } + part_count: { type: integer } + received_parts: + type: array + items: { type: integer } + expires_at: { $ref: '#/components/schemas/Timestamp' } + UploadCompleted: + type: object + required: [id, filename, size_bytes, detected_format, sha256] + properties: + id: { $ref: '#/components/schemas/Id' } + filename: { type: string } + size_bytes: { type: integer } + sha256: { type: string } + detected_format: { $ref: '#/components/schemas/DetectedFormat' } + extension_mismatch: + type: boolean + description: 扩展名和真实格式不一致(07 F2),界面只做提示 + page_count: + type: [integer, 'null'] + description: PDF 能直接读出;Word 要转换后才知道,这里为 null + DocumentCreate: + type: object + required: [upload_id, role] + properties: + upload_id: { $ref: '#/components/schemas/Id' } + role: { $ref: '#/components/schemas/DocumentRole' } + volume_name: { type: string, maxLength: 40, description: '投标分册名:商务 / 技术 / 报价 / 资格证明' } + replaces_document_id: + $ref: '#/components/schemas/Id' + description: 投标分册重新上传时填上一版的编号,生成新版本(FR-M3-01) + Document: + type: object + required: [id, role, label, original_filename, detected_format, size_bytes, status, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + role: { $ref: '#/components/schemas/DocumentRole' } + label: { type: string, description: '如「招标文件」「补遗 1」「技术分册 v2」' } + seq_no: { type: integer } + volume_name: { type: [string, 'null'] } + lineage_id: { $ref: '#/components/schemas/Id' } + is_latest_version: { type: boolean } + original_filename: { type: string } + detected_format: { $ref: '#/components/schemas/DetectedFormat' } + size_bytes: { type: integer } + page_count: { type: [integer, 'null'] } + normalized_sha256: { type: [string, 'null'] } + status: { type: string, enum: [uploaded, normalizing, ready, failed] } + latest_parse_job: + oneOf: + - $ref: '#/components/schemas/ParseJob' + - type: 'null' + uploaded_by: { $ref: '#/components/schemas/UserRef' } + created_at: { $ref: '#/components/schemas/Timestamp' } + DocumentPage: + type: object + required: [page_no, width_pt, height_pt, rotation, text_source] + properties: + page_no: { type: integer } + width_pt: { type: number } + height_pt: { type: number } + rotation: { type: integer, enum: [0, 90, 180, 270] } + text_source: { type: string, enum: [text, ocr, mixed, empty] } + Quad: + type: array + description: 一个矩形 [x0, y0, x1, y1],单位 pt,原点在页面左上角 + items: { type: number } + minItems: 4 + maxItems: 4 + Sentence: + type: object + required: [key, text, quads, location] + properties: + key: { type: string, examples: [':p38:s4'] } + text: { type: string } + quads: + type: array + items: { $ref: '#/components/schemas/Quad' } + location: { type: string, enum: [body, table, header_footer] } + format_runs: + type: array + description: 文字格式片段 [起, 止, 格式],格式取 b / i / u 的组合 + items: + type: array + prefixItems: + - { type: integer } + - { type: integer } + - { type: string } + section_path: { type: string, description: '所在章节,如「第三章 采购需求 > 3.2 技术参数」' } + CitationCreate: + type: object + required: [document_id, sentence_keys] + properties: + document_id: { $ref: '#/components/schemas/Id' } + sentence_keys: + type: array + minItems: 1 + description: 同一页上连续的句子 + items: { type: string } + char_start: { type: integer, minimum: 0 } + char_end: { type: integer, minimum: 1 } + Citation: + type: object + description: 出处,不可变(data-model §5.1) + required: [id, document_id, document_label, page_no, sentence_keys, quote, quads, normalized_sha256, created_by_kind] + properties: + id: { $ref: '#/components/schemas/Id' } + document_id: { $ref: '#/components/schemas/Id' } + document_label: { type: string, examples: [招标文件] } + normalized_sha256: { type: string, description: 和文档当前哈希不一致时前端不画高亮 } + page_no: { type: integer } + sentence_keys: + type: array + items: { type: string } + char_start: { type: [integer, 'null'] } + char_end: { type: [integer, 'null'] } + quote: { type: string, description: 原句 } + quads: + type: array + items: { $ref: '#/components/schemas/Quad' } + created_by_kind: { $ref: '#/components/schemas/Origin' } + JobError: + type: object + description: 给用户看的失败原因;不含文档内容 + required: [code, message, refunded] + properties: + code: { type: string, examples: [model_timeout, converter_crashed] } + message: { type: string } + refunded: { type: boolean, description: 额度是否已退回 } + ParseJob: + type: object + required: [id, document_id, attempt_no, status, progress, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + document_id: { $ref: '#/components/schemas/Id' } + attempt_no: { type: integer } + status: { $ref: '#/components/schemas/JobStatus' } + stage: + type: [string, 'null'] + enum: [detect, convert, text_layer, ocr, structure, extract, verify, merge, null] + progress: { type: integer, minimum: 0, maximum: 100 } + cancel_requested: { type: boolean } + error: + oneOf: + - $ref: '#/components/schemas/JobError' + - type: 'null' + result: + type: [object, 'null'] + description: 成功后的摘要 + properties: + items_added: { type: integer } + items_updated: { type: integer } + items_pending: { type: integer } + items_absent: { type: integer, description: 重新解析时没再出现的旧条目 } + lots_found: { type: integer } + created_at: { $ref: '#/components/schemas/Timestamp' } + finished_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + + # ---------- checklist + MarkingRule: + type: object + required: [id, marker, meaning, citation] + properties: + id: { $ref: '#/components/schemas/Id' } + marker: { type: string, enum: [star, asterisk, triangle, italic_underline, bold, underline, keyword] } + marker_text: { type: [string, 'null'] } + meaning: { type: string, enum: [substantive, important] } + declared_count: { type: [integer, 'null'] } + found_count: { type: integer, description: 实际抽到的条数 } + citation: { $ref: '#/components/schemas/Citation' } + Checklist: + type: object + required: [status, version_no, counts, marking_rules] + properties: + status: { type: string, enum: [draft, confirmed] } + version_no: { type: integer, description: 最近一次确认的版本号;没确认过为 0 } + confirmed_by: + oneOf: + - $ref: '#/components/schemas/UserRef' + - type: 'null' + confirmed_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + counts: + type: object + required: [formal, pending, deleted] + properties: + formal: { type: integer } + pending: { type: integer } + deleted: { type: integer } + superseded_pending: { type: integer } + by_category: + type: object + additionalProperties: { type: integer } + marking_rules: + type: array + items: { $ref: '#/components/schemas/MarkingRule' } + warnings: + type: array + description: '如「文件写明 ★ 项共 14 条,实际抽到 13 条」' + items: + type: object + required: [code, message] + properties: + code: { type: string, enum: [declared_count_mismatch, marker_rule_not_found, legal_regime_unknown] } + message: { type: string } + ItemSpec: + type: object + description: 类别特有字段(05 §3.3),按类别分组;一条清单项有几个类别就可以有几组 + properties: + qualification: + type: object + properties: + kind: { type: string, enum: [general, specific, credit] } + validity_requirement: { type: string, examples: [近 3 个月社保缴纳证明] } + invalidity: + type: object + properties: + source: { type: string, enum: [statute, tender_text, substantive_marker] } + scoring: + type: object + properties: + factor: { type: string } + points: { type: string, description: '分值,可能是「0~5」这样的区间,所以用字符串' } + criteria: { type: string } + part: { type: string, enum: [price, technical, commercial] } + schedule: + type: object + properties: + date_kind: { type: string, enum: [question_deadline, bid_deadline, opening, deposit_deadline, other] } + at: { $ref: '#/components/schemas/Timestamp' } + deposit_amount: { $ref: '#/components/schemas/Money' } + submission_mode: { type: string, enum: [electronic, paper, ca_encrypted, mixed] } + copies: { type: string } + tech_param: + type: object + properties: + param: { type: string } + op: { type: string, enum: ['>=', '<=', '=', contains, supports] } + value: { type: string } + unit: { type: string } + device: { type: string } + format_doc: + type: object + properties: + doc_name: { type: string, examples: [投标函] } + modifiable: { type: boolean } + price: + type: object + description: 分项限价、单价上限(DM7);只存招标文件写明的限价,系统不给任何报价建议(红线 6) + properties: + kind: { type: string, enum: [item_ceiling, unit_price_ceiling] } + target: { type: string } + op: { type: string, enum: ['<='] } + value: { $ref: '#/components/schemas/Money' } + ChecklistItemFields: + type: object + properties: + categories: + type: array + minItems: 1 + uniqueItems: true + items: { $ref: '#/components/schemas/Category' } + title: { type: string, minLength: 1, maxLength: 200 } + requirement_text: { type: [string, 'null'] } + importance: { $ref: '#/components/schemas/Importance' } + lot_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + evidence_required: { type: [string, 'null'] } + spec: { $ref: '#/components/schemas/ItemSpec' } + ChecklistItemCreate: + allOf: + - $ref: '#/components/schemas/ChecklistItemFields' + - type: object + required: [categories, title] + properties: + citation_ids: + type: array + items: { $ref: '#/components/schemas/Id' } + ChecklistItemUpdate: + $ref: '#/components/schemas/ChecklistItemFields' + ChecklistItem: + allOf: + - $ref: '#/components/schemas/ChecklistItemFields' + - type: object + required: [id, categories, title, importance, origin, zone, citations, edited_fields, updated_at] + properties: + id: { $ref: '#/components/schemas/Id' } + summary: + type: [string, 'null'] + description: AI 摘要,界面标注 AI 生成 + importance_basis: { type: [string, 'null'], enum: [marker, statute, tender_text, human, null] } + marking_rule_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + lot_no: { type: [string, 'null'], description: 空表示各包通用 } + origin: { $ref: '#/components/schemas/Origin' } + origin_label: { type: string, description: '如「AI」「人工添加」「补遗 1」' } + zone: { $ref: '#/components/schemas/Zone' } + pending_reason: + oneOf: + - $ref: '#/components/schemas/PendingReason' + - type: 'null' + no_citation_ack: { type: boolean, description: 人工确认、无出处 } + citations: + type: array + items: { $ref: '#/components/schemas/Citation' } + ai_values: + type: [object, 'null'] + description: AI 原值;和当前值不同的字段界面可以显示「AI 原为……」 + edited_fields: + type: array + items: { type: string } + confidence: { type: [number, 'null'] } + superseded_by: + type: [object, 'null'] + description: 已被补遗更新,等用户确认 + properties: + item_id: { $ref: '#/components/schemas/Id' } + document_label: { type: string } + confirmed: { type: boolean } + accepted_by: + oneOf: + - $ref: '#/components/schemas/UserRef' + - type: 'null' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + updated_at: { $ref: '#/components/schemas/Timestamp' } + ChecklistVersion: + type: object + required: [id, version_no, kind, item_count, created_by, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + version_no: { type: integer } + kind: { type: string, enum: [confirmed, check] } + item_count: { type: integer } + created_by: { $ref: '#/components/schemas/UserRef' } + created_at: { $ref: '#/components/schemas/Timestamp' } + HistoryEntry: + type: object + required: [action, actor, at] + properties: + action: { type: string, examples: [checklist_item.update, checklist_item.accept, checklist_item.delete] } + actor: { $ref: '#/components/schemas/UserRef' } + at: { $ref: '#/components/schemas/Timestamp' } + changes: + type: array + items: + type: object + required: [field] + properties: + field: { type: string } + before: {} + after: {} + + # ---------- checks + CheckPreview: + type: object + required: [can_start, billing, charge, checklist_confirmed, bid_documents, lot_ids] + properties: + can_start: { type: boolean } + blockers: + type: array + description: can_start 为 false 的原因 + items: + type: string + enum: [no_parsed_tender, no_bid_documents, job_already_running, quota_exhausted, project_read_only] + billing: { $ref: '#/components/schemas/BillingMode' } + charge: { type: integer, enum: [0, 1], description: 这次检查扣几次 } + charge_unit: { $ref: '#/components/schemas/QuotaUnit' } + waive_until: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + description: 24 小时免扣窗口的结束时间(从上一次扣点的检查算起,P1) + checklist_confirmed: { type: boolean, description: 为 false 时提示「清单未确认,检查结果以当前清单为准」 } + pending_item_count: { type: integer, description: 待确认区条目不参与检查 } + bid_documents: + type: array + description: 默认用每个分册的最新版本 + items: { $ref: '#/components/schemas/Document' } + lot_ids: + type: array + items: { $ref: '#/components/schemas/Id' } + CheckRunCreate: + type: object + required: [document_ids, expected_charge] + properties: + document_ids: + type: array + minItems: 1 + description: 本次检查的投标文件版本 + items: { $ref: '#/components/schemas/Id' } + expected_charge: + type: integer + enum: [0, 1] + description: 前端按 check-preview 提示用户的扣点数;和服务端当下算的不一致时返回 `billing_changed`,不扣点(API4) + CheckRun: + type: object + required: [id, run_no, status, progress, billing, checklist_version_no, checklist_confirmed, rule_set_version, created_by, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + run_no: { type: integer } + status: { $ref: '#/components/schemas/JobStatus' } + stage: + type: [string, 'null'] + enum: [prepare_bid, match, rules, verify, carry_over, summarize, null] + progress: { type: integer, minimum: 0, maximum: 100 } + cancel_requested: { type: boolean } + error: + oneOf: + - $ref: '#/components/schemas/JobError' + - type: 'null' + billing: { $ref: '#/components/schemas/BillingMode' } + is_latest: { type: boolean, description: 只有最近一次检查的问题可以处理 } + checklist_version_no: { type: integer } + checklist_confirmed: { type: boolean } + rule_set_version: { type: string } + legal_regime: { $ref: '#/components/schemas/LegalRegime' } + documents: + type: array + items: { $ref: '#/components/schemas/Document' } + coverage: + type: [object, 'null'] + description: 查了什么、没查什么(报告首页,FR-M3-09) + properties: + checked: + type: array + items: { type: string } + not_checked: + type: array + items: { type: string } + counts: + type: [object, 'null'] + properties: + fatal: { type: integer } + important: { type: integer } + hint: { type: integer } + open_fatal: { type: integer } + resolved_this_run: { type: integer } + new: { type: integer } + created_by: { $ref: '#/components/schemas/UserRef' } + created_at: { $ref: '#/components/schemas/Timestamp' } + finished_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + Finding: + type: object + required: [id, type, severity, title, explanation, detector, presence, status, tender_citations, bid_citations] + properties: + id: { $ref: '#/components/schemas/Id' } + type: { $ref: '#/components/schemas/FindingType' } + severity: { $ref: '#/components/schemas/Severity' } + title: { type: string } + explanation: { type: string } + detector: { type: string, enum: [ai, rule] } + checklist_item: + type: [object, 'null'] + description: AI 判断的问题必有;规则问题没有对应条目时为 null,显示 rule(DM6) + properties: + id: { $ref: '#/components/schemas/Id' } + title: { type: string } + importance: { $ref: '#/components/schemas/Importance' } + categories: + type: array + items: { $ref: '#/components/schemas/Category' } + rule: + type: [object, 'null'] + properties: + code: { type: string, examples: [PRICE.CAPITAL_MISMATCH] } + description: { type: string } + detail: + type: object + description: 按类型的补充信息:参数的要求值和响应值、资质预警种类(expired / insufficient_level / not_covered)、从投标文件读到的金额 + tender_citations: + type: array + items: { $ref: '#/components/schemas/Citation' } + bid_citations: + type: array + items: { $ref: '#/components/schemas/Citation' } + search_scope: + type: [object, 'null'] + description: 「未找到响应」时找过哪些分册、哪些章节(FR-M3-03) + properties: + documents: + type: array + items: { type: string } + sections: + type: array + items: { type: string } + presence: { $ref: '#/components/schemas/Presence' } + carried_status: + oneOf: + - $ref: '#/components/schemas/FindingStatus' + - type: 'null' + description: 沿用时,上一次的处理状态(上次「已修改」的这次回到待处理) + status: { $ref: '#/components/schemas/FindingStatus' } + resolution_note: { type: [string, 'null'] } + resolved_by: + oneOf: + - $ref: '#/components/schemas/UserRef' + - type: 'null' + resolved_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + ManualCheck: + type: object + required: [check_key, label, checked] + properties: + check_key: { type: string } + label: { type: string, examples: [正副本份数与装订] } + requirement_text: { type: [string, 'null'] } + citation: + oneOf: + - $ref: '#/components/schemas/Citation' + - type: 'null' + checked: { type: boolean } + note: { type: [string, 'null'] } + checked_by: + oneOf: + - $ref: '#/components/schemas/UserRef' + - type: 'null' + checked_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + + # ---------- reports + ReportJob: + type: object + required: [id, check_run_id, status] + properties: + id: { $ref: '#/components/schemas/Id' } + check_run_id: { $ref: '#/components/schemas/Id' } + status: { $ref: '#/components/schemas/JobStatus' } + report_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + error: + oneOf: + - $ref: '#/components/schemas/JobError' + - type: 'null' + Report: + type: object + required: [id, check_run_id, run_no, page_count, template_version, generated_by, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + check_run_id: { $ref: '#/components/schemas/Id' } + run_no: { type: integer } + page_count: { type: integer } + sha256: { type: string } + template_version: { type: string } + open_fatal_at_export: { type: integer, description: 导出时还没处理的致命问题数 } + generated_by: { $ref: '#/components/schemas/UserRef' } + created_at: { $ref: '#/components/schemas/Timestamp' } + + # ---------- qualifications + QualificationInput: + type: object + required: [name, kind] + properties: + name: { type: string, minLength: 1, maxLength: 200 } + kind: { $ref: '#/components/schemas/QualificationKind' } + level: { type: [string, 'null'] } + cert_no: { type: [string, 'null'] } + issuer: { type: [string, 'null'] } + valid_from: { type: [string, 'null'], format: date } + valid_to: { type: [string, 'null'], format: date, description: 空表示长期有效 } + Qualification: + allOf: + - $ref: '#/components/schemas/QualificationInput' + - type: object + required: [id, files, created_at, updated_at] + properties: + id: { $ref: '#/components/schemas/Id' } + files: + type: array + items: + type: object + required: [id, filename, size_bytes] + properties: + id: { $ref: '#/components/schemas/Id' } + filename: { type: string } + size_bytes: { type: integer } + created_at: { $ref: '#/components/schemas/Timestamp' } + updated_at: { $ref: '#/components/schemas/Timestamp' } + + # ---------- notifications / events / feedback + Notification: + type: object + required: [id, kind, title, created_at] + properties: + id: { $ref: '#/components/schemas/Id' } + kind: + type: string + enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, quota_80, quota_100, + subscription_activated, subscription_expiring] + title: { type: string } + project_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + link: { type: [string, 'null'], description: '前端路由,如 /projects//results' } + read_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + created_at: { $ref: '#/components/schemas/Timestamp' } + Event: + type: object + required: [type, at] + properties: + type: + type: string + enum: [job.progress, job.finished, report.ready, notification.created, quota.changed, project.changed, resync] + at: { $ref: '#/components/schemas/Timestamp' } + project_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + job: + type: [object, 'null'] + properties: + kind: { type: string, enum: [parse_job, check_run, report_job] } + id: { $ref: '#/components/schemas/Id' } + status: { $ref: '#/components/schemas/JobStatus' } + stage: { type: [string, 'null'] } + progress: { type: integer } + notification_id: + oneOf: + - $ref: '#/components/schemas/Id' + - type: 'null' + FeedbackCreate: + type: object + required: [project_id, object_type, kind] + properties: + project_id: { $ref: '#/components/schemas/Id' } + object_type: { type: string, enum: [finding, checklist_item, checklist] } + object_id: { $ref: '#/components/schemas/Id' } + kind: { type: string, enum: [false_positive, false_negative] } + note: { type: string, maxLength: 1000 } diff --git a/docs/decisions.md b/docs/decisions.md index f8883dc..b31e610 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,20 @@ --- +## API1–API5 · 2026-11-03 · 接口契约的取舍:全部按建议 + +依据 `docs/architecture/api-design.md` §9;契约文件 `openapi.yaml`。 + +| # | 决策 | 理由 | +|---|---|---| +| API1 | 后端骨架搭好前以 `openapi.yaml` 为准,前后端据此同时开发;之后以 FastAPI 生成的文档为准,存成 `openapi.json` 提交,CI 检查是否最新,`openapi.yaml` 不再维护 | 避免两份契约长期并存、互相偏离;契约改动在合并请求里可见 | +| API2 | 事件流(SSE)用 fetch 读取,不用 EventSource | EventSource 带不了 Bearer 头,桌面版用不了 | +| API3 | 同一账号在其他设备已登录时,新设备登录先提示,用户确认后挤掉旧设备 | 落实 FR-M6-04「提示并限制」;「旧设备不退出就不让登录」会让换了电脑的用户登不上 | +| API4 | 会扣点的接口都带「预期扣点」,和服务端当下算的不一致就拒绝、不扣点,并返回最新的扣点预览 | 红线 9:用户看到的扣点说明和实际扣点必须一致 | +| API5 | 报告在后台生成,完成后推送事件 | 生成 PDF 要几秒到十几秒;api 只做短事务 | + +- **影响**:架构 §12 第 3 步完成;`data-model.md` 的清单项 `spec` 改为按类别分组。 + ## DM1–DM7 · 2026-11-03 · 物理数据模型的取舍:全部按建议 依据 `docs/architecture/data-model.md` §9;建表脚本草案 `schema-draft.sql` 在 PostgreSQL 16 上跑通了 16 项行为测试。