diff --git a/AGENTS.md b/AGENTS.md index 6e9db8b..0faff45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,4 +155,5 @@ samples/ - 2026-11-03:MVP 原型 13 页全部按 v0.2 重刷:框架、项目头和标签页、对照布局、问题处理交互与 UI demo 一致,业务流程和红线内容不变。报告页脚与免责声明改为「本报告为辅助检查结果,仅供投标前自查参考」,规范 §7.17 和 FR-M3-09 同步,消除了「保证」字样。 - 2026-11-03:技术架构升到 v0.3,新增 §3.14。团队版、企业版、飞书 / 企业微信登录都是以后的版本,早期不做;MVP 的表结构先按多人、多登录方式建:人 / 登录方式 / 成员身份分三层,项目成员表和统一的权限检查点先建好,以后接入不用重构(AR11、AR12)。 - 2026-11-03:VI 手册第 20 页报告页脚去掉「保证」字样,改为与设计规范 §7.17 一致的「辅助检查结果,仅供投标前自查参考」,PDF 已重出。 -- 下一步:进入实现阶段前的工程准备(物理数据建模、接口契约、工程骨架,见 `docs/architecture/architecture.md` 第 12 节)。 +- 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 节。 diff --git a/docs/analysis/03-functional-requirements.md b/docs/analysis/03-functional-requirements.md index 459c7f9..5e19461 100644 --- a/docs/analysis/03-functional-requirements.md +++ b/docs/analysis/03-functional-requirements.md @@ -44,7 +44,7 @@ ### FR-M2-05 结构化提取 · P0 -按五类生成清单项:PRD 的四类,加上 IT 集成需要的「技术参数」。字段定义见 `05-it-integration-domain.md` §3。 +按六类生成清单项:PRD 的四类,加上 IT 集成需要的「技术参数」,以及承载分项限价的「报价」(DM7)。字段定义见 `05-it-integration-domain.md` §3。 | 类别 | 内容 | |---|---| @@ -53,6 +53,7 @@ | 评分办法 | 评标方法、价格 / 技术 / 商务分值、每个评分项(分值、标准、证明材料)、中小企业价格扣除 | | 关键日期与递交 | 截止时间、开标时间、提疑 / 质疑截止、保证金、投标有效期、递交方式、份数、签章、密封 / 加密 | | 技术参数 | 参数项、要求值、比较方式(≥ ≤ = 包含 支持)、重要性(★ / ▲ / 一般)、所属包 | +| 报价 | 分项限价、单价上限(总价限价和采购包限价在项目基本信息和分包里提取,FR-M2-03、04) | **验收** - 每条清单项至少有一个出处(文件 + 页码 + 原句);没有出处的进「待确认」区(FR-M2-07)。 @@ -138,7 +139,7 @@ **验收** - 每个问题都带两边的出处:招标原文,和投标原文(「未找到」时写明找过哪些分册、哪些章节)。 -- 每个问题都对应一条清单项;用户能从问题跳到清单项,也能反过来。 +- AI 判断的问题都对应一条清单项;规则检查的问题有对应清单项时也挂上,没有时(如大小写金额不一致)对应规则编号,界面显示规则说明(DM6)。用户能从问题跳到清单项,也能反过来。 ### FR-M3-04 形式要件检查 · P0 diff --git a/docs/analysis/04-domain-model.md b/docs/analysis/04-domain-model.md index 0d03174..aea3c4b 100644 --- a/docs/analysis/04-domain-model.md +++ b/docs/analysis/04-domain-model.md @@ -1,6 +1,6 @@ # 04 · 领域模型草案 -> 这是需求层面的概念模型,不是数据库设计。字段只列出决定业务规则的那些,物理建模留到技术架构阶段。 +> 这是需求层面的概念模型,不是数据库设计。字段只列出决定业务规则的那些;物理模型见 `docs/architecture/data-model.md`。 > 核心是 PRD 第 5 章说的「**项目检查清单**」:M2 产出它,M3 拿它做核对,V1 的 M4 拿它做生成约束,M5 也挂在它上面。 ## 1. 实体关系 diff --git a/docs/analysis/05-it-integration-domain.md b/docs/analysis/05-it-integration-domain.md index 4be0ae6..926140a 100644 --- a/docs/analysis/05-it-integration-domain.md +++ b/docs/analysis/05-it-integration-domain.md @@ -63,7 +63,7 @@ IT 集成项目的采购人大量是政府机关、事业单位,**多数走政 | 字段 | 说明 | |---|---| -| 类别标签 | 资格 / 无效条款 / 评分 / 日期与递交 / 技术参数 / 格式文件(可以有多个) | +| 类别标签 | 资格 / 无效条款 / 评分 / 日期与递交 / 技术参数 / 格式文件 / 报价(可以有多个;「报价」见 DM7) | | 要求原文 | 原句,不改写 | | 要求摘要 | AI 生成的简短说明,界面标注是 AI 生成 | | 重要性 | ★ 实质性 / ▲ 重要 / 一般。常见用 ★ 标实质性条款、用 ▲ 标重要指标,但**样本中 3 / 10 份用「斜体 + 下划线」标实质性条款**(07 F1),所以必须先提取文件里的「标注规则说明」原文,再按规则识别 | @@ -80,6 +80,7 @@ IT 集成项目的采购人大量是政府机关、事业单位,**多数走政 | 评分 | 评分因素、分值、评分标准、所属部分(价格 / 技术 / 商务)、中小企业价格扣除规则 | | 日期与递交 | 日期类型(提疑截止 / 投标截止 / 开标 / 保证金截止)、具体时间;保证金金额与形式;投标有效期;递交方式(电子 / 纸质 / CA 加密);正副本份数;签章要求;密封要求 | | 技术参数 | 参数名、要求值、单位、比较方式(≥ ≤ = 包含 支持 / 具备)、所属设备或系统 | +| 报价 | 分项限价、单价上限:限价对象(分项名称)、比较方式、限价金额。总价限价放在项目基本信息里,采购包限价放在分包上(`docs/architecture/data-model.md` §5.3) | | 格式文件 | 格式名称(投标函、开标一览表、授权委托书、偏离表、中小企业声明函等)、是否允许修改 | ## 4. IT 集成投标文件的常见结构(M3 检查对象) diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 72c9c24..61f0255 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,已确认)。 +> 状态:v0.3。v0.2 的选型已全部确认;v0.3 新增 §3.14:MVP 的表结构先按多人、多登录方式建,给以后的团队版、企业版、飞书 / 企业微信登录留结构(AR11、AR12,已确认)。物理数据模型见 `data-model.md`(DM1–DM7,已确认)。 > - 已确认(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。 @@ -158,7 +158,8 @@ flowchart LR | `report` | 检查报告的生成与归档 | FR-M3-09 | | `qualification` | 最小资质库 | FR-M6-08 | | `audit` | 审计事件 | FR-M6-07 | -| `ops` | 运营后台接口 | FR-M6-09 | +| `ops` | 运营后台接口、误报 / 漏报反馈 | FR-M6-09 | +| `notification` | 站内通知(解析 / 检查完成、额度提醒、开通) | FR-M2-02、FR-M6-03 | 每个模块内部分三层: @@ -312,7 +313,7 @@ flowchart LR - **调试开关**:「跳过登录」之类的开关只在测试配置里存在;生产环境检测到这类开关会直接退出。 - **加密**: - 传输:全链路 TLS。 - - 存储:云盘和 OSS 开服务端加密。文件另做**信封加密**,每个租户(或每个项目)一把数据密钥,删除时销毁密钥,备份里的数据也就随之不可读,用来落实 G10 的「彻底删除」。 + - 存储:云盘和 OSS 开服务端加密。文件另做**信封加密**,每个项目一把数据密钥(资质附件用企业级密钥,DM4),删除时销毁密钥,备份里的数据也就随之不可读,用来落实 G10 的「彻底删除」。 - 模型供应商的 API Key 加密存储。 - **运营后台**:独立应用、独立登录,加 IP 白名单和二次验证,所有操作都写审计。 - **日志脱敏**:日志和监控里只出现编号和哈希,不出现文档内容、引文、金额;有专门的脱敏中间件,加上测试。 @@ -788,7 +789,8 @@ docs/ # 现有文档 - 业务表里记录「谁」的字段(创建人、确认人、处理人)都指向 `user_account`,不指向成员身份;人不做物理删除。 - 出处(Citation)存:文档编号、规范化 PDF 的哈希、页码、句子编号、字符范围、坐标框数组(`quads`)。 - AI 值和人工值分开存(§3.8);状态字段用枚举,不用自由文本。 -- 文字层的逐字数据量大,存在对象存储里;数据库里只存句子和块的索引,以及校验要用的文本。 +- 文字层(逐字坐标、格式、句子切分)存在对象存储里,按页分片;**句子不进数据库**(DM1),数据库只存页摘要和出处快照(原句、坐标框)。 +- 完整的表结构见 `data-model.md` 和 `schema-draft.sql`。 ## 10. 编码前要做的技术验证 @@ -835,6 +837,6 @@ S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项 ## 12. 下一步 1. 做技术验证 S1–S4,结论写进本文件,并据此确定 FR-M2-05、FR-M2-06 的目标值。 -2. 物理数据建模:在 04 领域模型的基础上细化表结构,重点是清单项、出处、问题、额度流水,以及 §3.14 的人 / 登录方式 / 成员身份、项目成员。 +2. ~~物理数据建模~~:✅ 已完成,见 `data-model.md`、`schema-draft.sql`(DM1–DM7 已确认)。 3. 定接口契约:先写核心接口的 OpenAPI 定义,前后端据此并行开发。 4. 搭工程骨架:仓库结构、CI、Compose 一键启动、一个贯通「上传 → 解析 → 显示清单」的最小链路。 diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 0000000..63a50cc --- /dev/null +++ b/docs/architecture/data-model.md @@ -0,0 +1,321 @@ +# 物理数据模型 v0.1 + +> 依据:`docs/analysis/04-domain-model.md`(概念模型)、架构 v0.3 §3、§9、§3.14,以及 03 功能需求和 decisions.md 里已确认的规则。 +> 建表脚本草案:`schema-draft.sql`(PostgreSQL 16+,40 张表);行为测试:`schema-draft-test.sql`。2026-11-03 在 PostgreSQL 16 上跑通,结果见 §10。 +> 这是设计草案。工程骨架搭好后,按模块拆成 Alembic 迁移,这两个 SQL 文件不再维护。 +> DM1–DM7 已于 2026-11-03 全部按建议确认(`docs/decisions.md`),见 §9。 + +## 0. 结论速览 + +- **核心是清单项**:一条要求的稳定编号贯穿解读、检查和复查。当前值、AI 原值、人工改过哪些字段,三者分开存。检查引用的是清单的不可变快照。 +- **出处不可变**:出处存规范化 PDF 的哈希、句子编号、坐标框和原句快照。清单项、问题、项目字段、标注规则都引用同一张出处表。 +- **句子不进数据库**(DM1):逐字坐标和句子都放在对象存储的文字层文件里。数据库只存页摘要和出处快照,否则一年会有十亿行级的句子数据。 +- **额度流水只追加**:每笔流水的数量就是它对余额的影响,余额等于流水求和。一笔预扣只能有一个结局(结算或退回),这条由唯一索引保证。 +- **租户隔离有三道保险**: + - 应用层:每次查询都带租户上下文; + - 数据库:行级安全策略; + - 外键:都带上 `tenant_id`,跨租户引用在数据库层就插不进去(DM3)。 +- **删除项目是真删除**:项目下的数据级联删除,对象存储里的文件靠销毁密钥让它不可读。额度流水和不含业务内容的审计记录保留(G10)。 + +## 1. 约定 + +| 事项 | 约定 | +|---|---| +| 主键 | UUIDv7,由应用生成。少数从属表用复合主键,比如 `document_page`(文档 + 页码) | +| 租户 | 业务表都带 `tenant_id`。全局表只有 5 张:`user_account`、`login_identity`、`plan`、`ops_admin`、`ops_session` | +| 时间 | 一律用 `timestamptz`;可改的表带 `created_at` 和 `updated_at`,`updated_at` 由触发器维护 | +| 状态和枚举 | 用 `text` 加 `CHECK`,不用 PostgreSQL 的枚举类型(DM2)。Python 侧用 `StrEnum` 定义同一组值,CI 检查两边一致 | +| 金额 | `numeric(18,2)`。投标文件里读到的金额同时保留原文写法;系统不生成任何报价数字(红线 6) | +| 记录「谁」 | 指向 `user_account`,不指向成员身份(架构 §3.14) | +| 模块归属 | 每张表只属于一个模块(§3)。模块之间可以有外键(保证数据完整),但不能直接读写别的模块的表 | +| 项目内的外键 | 一律 `ON DELETE CASCADE` 或 `SET NULL`。只在删除整个项目时真正触发,清单项平时只做软删除 | +| 只追加 | `quota_entry`、`ai_call` 禁止 UPDATE 和 DELETE;`citation`、`report` 禁止 UPDATE;`audit_event` 只允许「抹掉内容」这一种修改(§8)。都由触发器保证 | +| 大字段 | 文档原件、规范化 PDF、文字层、报告、模型输入输出都放对象存储,数据库只存对象键和哈希 | + +## 2. 租户隔离的落地 + +### 2.1 行级安全策略 + +- api 和 worker 每个事务开始时执行 `SET LOCAL app.tenant_id = '<租户>'`。 +- 所有带 `tenant_id` 的表都打开 `FORCE ROW LEVEL SECURITY`,策略是 `tenant_id = current_tenant()`,读和写都生效。`tenant` 表本身按 `id` 隔离。 +- 没设置租户时,`current_tenant()` 返回空,什么也查不到。代码漏了租户条件时,结果是查不到,而不是查到全部。 + +### 2.2 复合外键 + +每张业务表都有 `UNIQUE (tenant_id, id)`。子表引用父表时用 `(tenant_id, parent_id)`,所以甲租户的文档不可能挂到乙租户的项目上。行级安全策略只管「能看到哪些行」,复合外键管的是「行和行之间的引用不跨租户」,即使是绕过策略的运营后台也写不进去。 + +代价是每个外键多一列。好处是私有化客户如果关掉了行级安全策略(AR3),这一道保护仍然在。 + +### 2.3 登录前的查询 + +请求刚进来时还不知道租户,这时要按令牌哈希找会话,或者按人找他属于哪些企业。这两类查询用两个 `SECURITY DEFINER` 函数来做: + +- `auth_resolve_session(token_hash)`:返回会话、租户和成员身份,同时校验会话没吊销、没过期,成员身份和企业都还有效; +- `auth_list_memberships(user_id)`:返回这个人的成员身份列表,供切换企业使用。 + +两个函数的所有者是 `bd_auth` 角色,它能绕过行级安全策略,但只对 `user_session`、`membership`、`tenant` 三张表有 SELECT 权限。应用角色只有调用这两个函数的权限。这样 `user_session` 和 `membership` 仍然是普通的租户表,§3.14 说的「只有两张全局表例外」不变。 + +### 2.4 数据库角色 + +| 角色 | 用途 | 行级安全策略 | +|---|---|---| +| `bd_owner` | 建表和迁移 | 表的所有者,因为打开了 FORCE,同样受约束 | +| `bd_app` | api 和 worker | 受约束 | +| `bd_auth` | 只拥有 §2.3 的两个函数,不用来登录 | 绕过,只限三张表 | +| `bd_ops` | 运营后台 | 绕过;每次跨租户操作都写审计事件 | + +## 3. 表清单与模块归属 + +| 模块 | 表 | 说明 | +|---|---|---| +| identity | `tenant` `user_account` `login_identity` `membership` `user_session` | 人、登录方式、成员身份三层(AR11) | +| billing | `plan` `subscription` `activation_request` `quota_account` `quota_entry` `quota_alert` | 套餐带版本;开通申请注销后保留 | +| project | `project` `project_member` `lot` `project_field` | 项目字段的 AI 值和人工值分开存 | +| document | `source_document` `document_page` `parse_job` `citation` | 出处表归 document 模块,各模块共用 | +| checklist | `checklist` `checklist_item` `checklist_item_citation` `checklist_version` `checklist_version_item` `marking_rule` | | +| check | `check_run` `check_run_document` `finding` `finding_citation` `manual_check` | | +| report | `report` | | +| qualification | `qualification` `qualification_file` | | +| ai | `ai_call` | | +| audit | `audit_event` | | +| ops | `ops_admin` `ops_session` `feedback` | 反馈由用户写,在运营后台处理 | +| notification | `notification` | 站内通知(G14),v0.1 新增的小模块,已加入架构 §3.2 | +| (基础设施)storage | `data_key` | 信封加密的数据密钥(DM4) | + +```mermaid +erDiagram + tenant ||--o{ membership : "" + user_account ||--o{ membership : "" + user_account ||--o{ login_identity : "" + membership ||--o{ user_session : "" + tenant ||--o{ subscription : "" + tenant ||--o{ quota_account : "" + quota_account ||--o{ quota_entry : "只追加" + tenant ||--o{ project : "" + project ||--o{ project_member : "" + project ||--o{ lot : "" + project ||--o{ project_field : "" + project ||--o{ source_document : "" + source_document ||--o{ document_page : "" + source_document ||--o{ parse_job : "每次重试一行" + source_document ||--o{ citation : "不可变" + project ||--|| checklist : "" + checklist ||--o{ checklist_item : "稳定编号" + checklist_item }o--o{ citation : "checklist_item_citation" + checklist ||--o{ checklist_version : "不可变快照" + project ||--o{ check_run : "" + check_run }o--|| checklist_version : "" + check_run }o--o{ source_document : "check_run_document" + check_run ||--o{ finding : "" + finding }o--o| checklist_item : "" + finding }o--o{ citation : "finding_citation(招标侧 / 投标侧)" + check_run ||--o{ report : "" +``` + +## 4. 身份与额度 + +### 4.1 身份 + +- **注册**:建 `user_account`、`login_identity`(来源为手机号)、`tenant`、`membership`(所有者)、免费版 `subscription`,再建额度账户并发放额度,都在同一个事务里完成。 +- **手机号**(DM5):`login_identity.external_user_id` 存的是号码的 HMAC 值,用来查找和保证唯一。完整号码加密后存在 `contact_cipher` 里,用来发短信、在运营后台查询。界面显示 `contact_hint`(138****5678)。数据库泄露时拿不到明文号码。 +- **一个手机号只属于一个企业**(FR-M6-04):在服务层检查,表结构不做限制(AR11)。 +- **异地多设备并发登录**:按 `user_id` 列出未吊销的会话,被挤下线的会话记 `revoke_reason = concurrent_login`。 + +### 4.2 额度流水 + +`amount` 是这笔流水对余额的影响,余额等于 `sum(amount)`。`quota_account.balance` 是缓存,在同一个事务里更新,定期和流水对账。 + +| 类型 | amount | 说明 | +|---|---|---| +| 发放 grant | +N | 挂在订阅上(`subscription_id`),「本期」按订阅周期计算 | +| 预扣 hold | −1 | 发起任务时记,和建任务、入队在同一个事务里(架构 §3.4) | +| 结算 settle | 0 | 任务成功;`hold_entry_id` 指回预扣 | +| 退回 release | +1 | 任务失败、超时、取消;指回预扣 | +| 免扣 waive | 0 | 24 小时内复查(Q13);`reason = recheck_24h`,关联这次检查 | +| 过期 expire | −余量 | 订阅周期结束。如果还有没了结的预扣,等它了结后再记 | +| 调整 adjust | ±N | 运营后台人工调整,原因必填 | + +几条典型流水: + +- 解析成功:预扣 −1 → 结算 0,净扣 1 次。 +- 解析失败后重试成功:预扣 −1 → 退回 +1 → 预扣 −1 → 结算 0,净扣 1 次。重试建一条新的 `parse_job`,所以幂等键不会冲突。 +- 24 小时内复查:只记一行免扣 0(Q13),用户能在明细里核对。 + +数据库层面的保证: + +- 幂等键 `(tenant_id, idempotency_key)` 唯一,例如 `parse_job::hold`; +- 一笔预扣最多只能有一个结局(结算或退回),由部分唯一索引保证; +- 调整必须填原因,结算和免扣的数量必须是 0,由 CHECK 约束保证。 + +预扣前先对 `quota_account` 加行锁,确认余额 ≥ 1。同一租户的并发预扣就此串行,不会扣成负数。 + +### 4.3 检查怎么扣 + +计费口径来自 01 §4,这里只说怎么落到表上: + +- 专业版的检查从解析额度扣:预扣记在 `unit = parse` 的账户上,关联 `check_run`。 +- 免费版只限 1 个项目,项目内的检查不限次数:`check_run.billing = free_plan`,不记流水。免费版没有检查额度可言,每次都记一行 0 只会让明细变乱。 +- 24 小时免扣:`project.last_charged_check_at` 是起点,只在真正扣了额度的检查之后更新(P1)。窗口内的检查 `billing = waived_recheck`,记一行免扣。 +- `quota_account.unit` 里预留了 `check`、`generate`、`dedupe`,给以后的套餐用。 + +## 5. 文档、清单与检查 + +### 5.1 文档与出处 + +- **`source_document`** 记录招标文件、补遗、澄清和投标分册: + - 招标侧:`seq_no` 就是「补遗 N」里的 N; + - 投标侧:同一分册的各个版本共用一个 `lineage_id`,`seq_no` 是版本号; + - 规范化 PDF 的键和哈希一旦写入就不能再改,由触发器保证(04 §4.1); + - `pipeline_profile` 记下转换器、字体包、切句算法的版本,复盘时能知道这份 PDF 是怎么来的。 +- **文字层**(DM1):逐字坐标、字体、格式和句子切分结果,放在对象存储里(`text_layer_key`),按页分片。 + - 数据库只存 `document_page`:页尺寸、文字来源(文字层 / OCR)、字数、句数。用来做逐页覆盖率检查,阅读器也要用页尺寸。 + - 句子编号沿用 S3 的格式 `<文档>:p<页>:s<页内序号>`。规范化 PDF 不会重新生成,所以编号稳定。 +- **`citation`** 不可变,存这些内容: + - 规范化 PDF 的哈希、页码; + - 同一页上连续的句子编号,需要时再加句内的字符范围; + - 坐标框快照、原句快照; + - 三道校验通过了哪几道(`checks_passed`)。 + - 阅读器按坐标框画高亮,不用再读文字层。哈希和文档当前的哈希对不上时拒绝渲染,宁可不显示,也不画错位置。 +- **一条要求跨页时**,就建多条出处。 + +### 5.2 清单项 + +一条清单项有三组值: + +| 字段 | 内容 | +|---|---| +| 当前值(各列) | 界面和检查用的值;人工改过的字段以人工为准 | +| `ai_values` | 最近一次 AI 给出的原值,人工修改不覆盖(架构 §3.8),用于误报分析 | +| `edited_fields` | 人工改过哪些字段。重新解析对齐时,这些字段不动 | + +- **类别**:`categories` 可以有多个标签。CHECK 约束保证 ★ 条款一定带「无效 / 废标」标签(FR-M2-05)。 +- **类别特有字段**(05 §3.3)放在 `spec` 里,按类别用 Pydantic 校验。例如技术参数是 `{"param": "内存", "op": ">=", "value": 32, "unit": "GB", "device": "服务器"}`。规则引擎要用的字段都放在这里,常用的再提出来建索引。 +- **重要性依据**:`importance_basis` 记下凭什么认定为 ★,可以是标注符号、法规列明或原文写明。`marking_rule_id` 指向文件自己的标注规则(07 F1)。 +- **区域**:`zone` 分待确认区和正式清单。进待确认区的原因写在 `pending_reason`:没有出处、置信度低、校验没通过、标注句没被覆盖(规则兜底生成的)、重新解析时没再出现。待确认区的条目不参与检查(FR-M2-07)。 +- **补遗**: + - 补遗带来的新条目,`origin_document_id` 指向这份补遗; + - 日期类条目被更新时,旧条目的 `superseded_by_id` 指向新条目,等用户确认(FR-M2-10); + - 项目字段(截止时间等)的新值先放在 `project_field.pending_value` 里,用户确认后才生效。 +- **软删除**:`deleted_at`,可以恢复(FR-M2-08)。修改历史从审计事件里查。 +- **标注规则**:`marking_rule` 存文件里的「标注规则说明」,包括标记、含义和出处。文件写明了数量的(比如「★ 项共 14 条」),存在 `declared_count` 里,用来和实际抽到的数量对比。 + +### 5.3 报价限价三层 + +07 F6 说的三层限价,分别落在: + +| 层 | 存在哪里 | +|---|---| +| 总价限价 | `project_field`,字段 `ceiling_total` | +| 采购包限价 | `lot.ceiling_amount` | +| 分项限价、单价上限 | 清单项,类别是「报价」(DM7),`spec = {"kind": "price_ceiling", "target": "<分项名>", "op": "<=", "value": "..."}` | + +报价形式检查读投标文件里的金额,和这三层比对。读到的金额写在问题的 `detail` 里,同时保留原文写法和出处。 + +### 5.4 清单版本 + +- 用户确认清单时,生成一份 `checklist_version`(`kind = confirmed`),每条正式条目在 `checklist_version_item` 里存一份当时的值和出处编号。 +- 清单没确认也能检查(G7):检查开始前也生成一份快照(`kind = check`),检查批次总是引用某一个快照,所以历史检查都能复现。 +- 内容哈希和上一份快照相同时,直接复用上一份,不重复生成。 + +### 5.5 检查批次与问题 + +- **`check_run`** 记下:清单快照、本次用的投标文件版本(`check_run_document`)、规则集版本、法律体系、所投的包、计费方式。阶段检查点和取消标记的用法与解析任务一样。 +- **`finding.match_key`** 是复查沿用的匹配依据(FR-M3-08),和问题类型一起,在同一批次内唯一: + - AI 判断的问题:`item:<清单项编号>`,必须挂在清单项上; + - 规则问题:有对应清单项时也挂上(比如分项限价);没有时用 `rule:<规则编号>[:<目标>]`,比如大小写金额不一致(DM6)。 +- **`presence`**: + - 新增:本次新出现的问题; + - 沿用:上次也有,沿用上次的处理状态,上次是「已修改」的改回「待处理」; + - 本次已解决:上次有、这次没有的问题,在本批次里留一行,状态不再变。这样每个批次都能独立列出完整结果,报告也只需要读一个批次。 +- **处理规则的数据库约束**:忽略必须写原因;致命问题选「确认无误」也必须写说明(U4)。这句说明同时写一条反馈(`feedback.source = fatal_confirm`)。 +- **`manual_check`**:需人工核对的勾选按项目存(P2),以 `check_key` 为键,复查时直接读取,报告里写明勾选人和时间。 + +### 5.6 就绪标记 + +`project.open_fatal_count` 是最近一次检查里还没处理的致命问题数,`project.latest_check_run_id` 指向最近一次检查。处理问题时,check 模块在同一个事务里更新这两个值。工作台列表直接读,不用每次去数问题表。数值大于 0 就是「未就绪」,但只在「已检查」状态下显示(G7)。 + +## 6. 删除 + +### 6.1 删除项目(J4、G10) + +一个事务内: + +1. 写一条审计事件「删除项目」,不含业务内容; +2. 把这个项目的审计事件的 `before` 和 `after` 抹掉,`content_digest` 保留,哈希链仍然能校验; +3. 删除 `project` 这一行,项目下的文档、出处、清单、检查、问题、报告、反馈都级联删除; +4. 销毁这个项目的数据密钥(`data_key.wrapped_key` 置空)。 + +事务提交后,再由后台任务删除对象存储里 `t/<租户>/p/<项目>/` 下的文件。删除失败也没关系:密钥已经销毁,这些文件和备份里的副本都已无法解密。 + +保留下来的数据: + +- 额度流水:`project_id` 和任务编号都不加外键,流水仍然在,明细里显示「(已删除的项目)」; +- `ai_call`:不含内容,成本数据保留; +- 审计记录:只剩操作类型、时间、对象编号。 + +### 6.2 注销账号 + +MVP 一个企业只有一个成员,所以注销账号就是注销企业: + +1. 按 §6.1 删除所有项目,再删资质库和通知; +2. 吊销所有会话,成员身份改为「已离开」; +3. 删除 `login_identity`;如果这个人不再属于任何企业,`user_account` 改成匿名(显示名换成「已注销用户」),这一行本身保留,审计事件里的 `actor_user_id` 仍然有效;审计里的姓名快照一并抹掉; +4. 企业改为「已注销」,这一行保留,开通申请、订阅和发票记录按财务要求保留(J4)。 + +## 7. 对象存储布局 + +``` +t//p//doc//original 原件 +t//p//doc//normalized.pdf 规范化 PDF(只写一次) +t//p//doc//text/p.json 文字层,按页分片 +t//p//doc//structure.json 结构识别结果(章节、表格、分包) +t//p//ai/ 模型输入(排查问题用) +t//p//report/.pdf 检查报告 +t//q// 资质附件(企业级密钥) +``` + +- 按项目分前缀,删除项目时可以整个前缀一起删; +- 每个对象都用所属项目(或企业)的数据密钥加密; +- 对象存储不开公开读,下载一律经过 api 签发短时有效的链接。 + +## 8. 审计与 AI 流水 + +- **`audit_event`**: + - 和业务修改在同一个事务里写入(架构 §3.10); + - 记下操作人、姓名快照、动作、对象、修改前、修改后、请求编号; + - 触发器只允许一种修改:把 `before` 和 `after` 置空,并写上抹除时间。其他字段都改不了,也不能删除; + - 可选的哈希链(`prev_hash`、`row_hash`)只覆盖内容的哈希(`content_digest`),不覆盖内容本身,所以抹掉内容以后链条仍然能校验。 +- **`ai_call`**:字段按架构 §3.6,另加估算成本(百万分之一元),给 D2 定价用。不存原文。数据量大了以后按月分区。 + +## 9. 决策(已确认) + +| # | 事项 | 结论 | 影响 | +|---|---|---|---| +| DM1 | 句子放在哪里 | **放对象存储,不进数据库**。S3 实测每份文件约 2400 句;一个项目算上投标分册的多个版本,有几万句;1000 个企业一年就是十亿行级。用到句子的地方(抽取、校验、对齐、检索)都在 worker 里按文档整份读取,不需要在数据库里查询;阅读器读的是出处快照 | 修改架构 §9「数据库里只存句子和块的索引」 | +| DM2 | 枚举怎么存 | `text` 加 `CHECK`,不用 PostgreSQL 的枚举类型。改枚举值只要改约束,信创数据库的兼容性也更好 | 工程约定 | +| DM3 | 外键带租户 | 所有业务表之间的外键都带上 `tenant_id`(§2.2) | 每个外键多一列;关掉行级安全策略时仍能防止跨租户引用 | +| DM4 | 加密粒度 | **每个项目一把数据密钥**,资质附件用企业级密钥。删除项目时销毁密钥,备份里的数据随之不可读(落实 G10「彻底删除」) | 架构 §3.11 原文写的是「每个租户(或每个项目)」,这里定为项目 | +| DM5 | 手机号怎么存 | **不存明文**:查找用 HMAC 值,发短信用密文,界面显示掩码 | 运营后台按手机号查询时,同样先算 HMAC | +| DM6 | 规则问题要不要挂清单项 | **AI 判断的问题必须挂清单项**;规则问题有对应条目时挂,没有时(大小写金额不一致、出现其他项目名称等)用规则编号,界面显示规则说明 | FR-M3-03「每个问题都对应一条清单项」改为「AI 判断的问题都对应一条清单项;规则检查的问题对应清单项或规则」 | +| DM7 | 分项限价放在哪 | **新增清单类别「报价」**,承载分项限价和单价上限(§5.3)。总价限价和包限价分别放在项目字段和分包上 | 05 §3.2 的类别从 6 个变为 7 个,FR-M2-05 同步修改 | + +## 10. 验证与下一步 + +**已验证**(2026-11-03,PostgreSQL 16,`schema-draft-test.sql`,16 项全部通过): + +- 租户隔离: + - 只能看到本租户的数据;没设置租户时什么都查不到; + - 不能往别的租户写数据,也不能删别的租户的项目; + - 复合外键拦住了跨租户引用,超级用户写入也拦得住; + - 登录前的两个函数在没有租户上下文时也能正常查到会话和成员身份。 +- 额度:预扣加结算后余额正确;一笔预扣不能有两个结局;流水不能修改;人工调整必须写原因。 +- 其他约束: + - 规范化 PDF 写入后不能替换; + - ★ 条款必须带「无效 / 废标」标签; + - 致命问题「确认无误」必须写说明; + - 审计事件除了抹掉内容,其他字段都改不了。 +- 删除项目:项目下的数据全部级联删除,额度流水和抹掉内容后的审计记录保留。这一项第一次测试没通过:多条级联路径之间互相阻塞。改为「项目内的外键一律级联」后通过(§1)。 + +**已回写**:架构 §3.2(加 notification 模块)、§3.11、§9;05 §3.2–3.3;FR-M2-05、FR-M3-03。 + +**下一步**:架构 §12 的第 3 步,用 OpenAPI 定义核心接口。 diff --git a/docs/architecture/schema-draft-test.sql b/docs/architecture/schema-draft-test.sql new file mode 100644 index 0000000..1ef5773 --- /dev/null +++ b/docs/architecture/schema-draft-test.sql @@ -0,0 +1,197 @@ +-- schema-draft.sql 的行为测试:在空库上先跑 schema-draft.sql,再跑本文件(psql -tAq)。 +-- 每条输出要么带 ok,要么实际值和 expect 一致;出现 FAIL 或 ERROR 就是不通过。 +\set ON_ERROR_STOP 1 +SET search_path = app; + +-- 角色:bd_app 受 RLS 约束;bd_auth 只拥有两个登录函数 +CREATE ROLE bd_app NOLOGIN; +CREATE ROLE bd_auth NOLOGIN BYPASSRLS; +GRANT USAGE ON SCHEMA app TO bd_app, bd_auth; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO bd_app; +GRANT SELECT ON user_session, membership, tenant TO bd_auth; +ALTER FUNCTION auth_resolve_session(bytea) OWNER TO bd_auth; +ALTER FUNCTION auth_list_memberships(uuid) OWNER TO bd_auth; +GRANT EXECUTE ON FUNCTION auth_resolve_session(bytea), auth_list_memberships(uuid) TO bd_app; + +-- 种子数据(以超级用户写入,绕过 RLS) +INSERT INTO plan VALUES ('free', 1, '免费版', '{"parse":3}', '{}', true, now()); +INSERT INTO tenant (id, name) VALUES + ('00000000-0000-7000-8000-00000000000a', '甲公司'), + ('00000000-0000-7000-8000-00000000000b', '乙公司'); +INSERT INTO user_account (id, display_name) VALUES + ('00000000-0000-7000-8000-0000000000a1', '张工'), + ('00000000-0000-7000-8000-0000000000b1', '李工'); +INSERT INTO membership (id, tenant_id, user_id) VALUES + ('00000000-0000-7000-8000-0000000000a2', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a1'), + ('00000000-0000-7000-8000-0000000000b2', '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b1'); +INSERT INTO user_session (id, tenant_id, membership_id, user_id, token_hash, client_kind, expires_at) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b2', + '00000000-0000-7000-8000-0000000000b1', '\x01', 'web', now() + interval '1 day'); +INSERT INTO project (id, tenant_id, name, created_by) VALUES + ('00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-00000000000a', '甲的项目', '00000000-0000-7000-8000-0000000000a1'), + ('00000000-0000-7000-8000-0000000000b3', '00000000-0000-7000-8000-00000000000b', '乙的项目', '00000000-0000-7000-8000-0000000000b1'); + +-- 1. 租户隔离 +BEGIN; +SET LOCAL ROLE bd_app; +SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; +SELECT 'T1 visible projects (expect 1): ' || count(*) FROM project; +SELECT 'T1 visible tenants (expect 1): ' || count(*) FROM tenant; +COMMIT; + +BEGIN; +SET LOCAL ROLE bd_app; +SELECT 'T2 no tenant set (expect 0): ' || count(*) FROM project; +COMMIT; + +-- 2. 不能往别的租户写数据 +BEGIN; +SET LOCAL ROLE bd_app; +SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; +DO $$ BEGIN + INSERT INTO app.project (id, tenant_id, name, created_by) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', 'x', '00000000-0000-7000-8000-0000000000a1'); + RAISE NOTICE 'T3 FAIL: cross-tenant insert allowed'; +EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'T3 ok: cross-tenant insert blocked'; +END $$; +COMMIT; + +-- 3. 复合外键:甲租户的文档不能挂到乙的项目上(以超级用户写入,绕过 RLS 也拦得住) +DO $$ BEGIN + INSERT INTO app.source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, + detected_format, size_bytes, original_key, original_sha256, uploaded_by) + VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000b3', + 'tender', 0, gen_random_uuid(), 'a.pdf', 'pdf', 1, 'k', '\x00', '00000000-0000-7000-8000-0000000000a1'); + RAISE NOTICE 'T4 FAIL: cross-tenant reference allowed'; +EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'T4 ok: cross-tenant FK blocked'; +END $$; + +-- 4. 登录前解析会话:bd_app 未设租户,也能通过函数拿到乙的会话 +BEGIN; +SET LOCAL ROLE bd_app; +SELECT 'T5 session resolved (expect 1): ' || count(*) FROM auth_resolve_session('\x01'); +SELECT 'T5 memberships of 李工 (expect 1): ' || count(*) FROM auth_list_memberships('00000000-0000-7000-8000-0000000000b1'); +COMMIT; + +-- 5. 额度:预扣 → 结算;流水不能改 +BEGIN; +SET LOCAL ROLE bd_app; +SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a'; +INSERT INTO quota_account (id, tenant_id, unit) VALUES + ('00000000-0000-7000-8000-0000000000a4', '00000000-0000-7000-8000-00000000000a', 'parse'); +INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, reason) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'grant', 3, 'signup', '注册赠送'); +INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, ref_type, ref_id, project_id) VALUES + ('00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', + 'hold', -1, 'parse_job:1:hold', 'parse_job', gen_random_uuid(), '00000000-0000-7000-8000-0000000000a3'); +INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id, project_id) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', + 'settle', 0, 'parse_job:1:settle', '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a3'); +SELECT 'T6 balance (expect 2): ' || sum(amount) FROM quota_entry; +COMMIT; + +DO $$ BEGIN + INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', + 'release', 1, 'parse_job:1:release', '00000000-0000-7000-8000-0000000000a5'); + RAISE NOTICE 'T7 FAIL: hold has two outcomes'; +EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'T7 ok: second outcome for one hold blocked'; +END $$; + +DO $$ BEGIN + UPDATE app.quota_entry SET amount = 100 WHERE kind = 'grant'; + RAISE NOTICE 'T8 FAIL: ledger updated'; +EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T8 ok: ledger append-only'; +END $$; + +DO $$ BEGIN + INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key) VALUES + (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'adjust', 5, 'ops:1'); + RAISE NOTICE 'T9 FAIL: adjust without reason'; +EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T9 ok: adjust requires reason'; +END $$; + +-- 6. 文档、规范化 PDF 冻结、清单、检查、问题 +INSERT INTO source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, detected_format, + size_bytes, original_key, original_sha256, normalized_key, normalized_sha256, page_count, uploaded_by) +VALUES ('00000000-0000-7000-8000-0000000000a6', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + 'tender', 0, '00000000-0000-7000-8000-0000000000a6', '招标文件.doc', 'docx', 100, 'orig', '\x01', 'norm', '\x02', 120, + '00000000-0000-7000-8000-0000000000a1'); +DO $$ BEGIN + UPDATE app.source_document SET normalized_sha256 = '\x03' WHERE id = '00000000-0000-7000-8000-0000000000a6'; + RAISE NOTICE 'T10 FAIL: normalized PDF replaced'; +EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T10 ok: normalized PDF frozen'; +END $$; + +INSERT INTO citation (id, tenant_id, project_id, document_id, normalized_sha256, page_no, sentence_keys, quads, quote, created_by_kind) +VALUES ('00000000-0000-7000-8000-0000000000a7', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + '00000000-0000-7000-8000-0000000000a6', '\x02', 38, '{a6:p38:s4}', '[[0,0,1,1]]', '★ 投标人须具备……', 'ai'); +INSERT INTO checklist (id, tenant_id, project_id) VALUES + ('00000000-0000-7000-8000-0000000000a8', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3'); + +DO $$ BEGIN + INSERT INTO app.checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone) + VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + '00000000-0000-7000-8000-0000000000a8', '{tech_param}', 'x', 'substantive', 'ai', 'formal'); + RAISE NOTICE 'T11 FAIL: star item without invalidity tag'; +EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T11 ok: star item must carry invalidity tag'; +END $$; + +INSERT INTO checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone) +VALUES ('00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + '00000000-0000-7000-8000-0000000000a8', '{tech_param,invalidity}', '★ 内存 ≥ 32GB', 'substantive', 'ai', 'formal'); +INSERT INTO checklist_item_citation VALUES + ('00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a7', 0); +INSERT INTO checklist_version (id, tenant_id, checklist_id, version_no, kind, content_hash, item_count, created_by) +VALUES ('00000000-0000-7000-8000-0000000000aa', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a8', + 1, 'check', '\x00', 1, '00000000-0000-7000-8000-0000000000a1'); +INSERT INTO check_run (id, tenant_id, project_id, run_no, checklist_version_id, checklist_confirmed, rule_set_version, + legal_regime, billing, quota_hold_id, created_by) +VALUES ('00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + 1, '00000000-0000-7000-8000-0000000000aa', false, 'it-v0', 'gov_procurement', 'charged', + '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a1'); + +DO $$ BEGIN + INSERT INTO app.finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title, + explanation, detector, presence, status) + VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + '00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet', + 'fatal', 't', 'e', 'ai', 'new', 'confirmed_ok'); + RAISE NOTICE 'T12 FAIL: fatal confirm without note'; +EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T12 ok: fatal confirm requires note (U4)'; +END $$; + +INSERT INTO finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title, + explanation, detector, presence) +VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3', + '00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet', + 'fatal', '内存不满足', '要求 ≥ 32GB,响应 16GB', 'ai', 'new'); +UPDATE project SET latest_check_run_id = '00000000-0000-7000-8000-0000000000ab', status = 'checked', open_fatal_count = 1 +WHERE id = '00000000-0000-7000-8000-0000000000a3'; + +INSERT INTO audit_event (id, tenant_id, actor_kind, actor_user_id, actor_name, action, object_type, object_id, project_id, before, after) +VALUES ('00000000-0000-7000-8000-0000000000ac', '00000000-0000-7000-8000-00000000000a', 'user', + '00000000-0000-7000-8000-0000000000a1', '张工', 'checklist_item.update', 'checklist_item', + '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a3', '{"title":"旧"}', '{"title":"新"}'); + +-- 7. 审计只能抹内容,不能改别的 +DO $$ BEGIN + UPDATE app.audit_event SET action = 'x' WHERE id = '00000000-0000-7000-8000-0000000000ac'; + RAISE NOTICE 'T13 FAIL: audit action changed'; +EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T13 ok: audit fields immutable'; +END $$; + +-- 8. 删除项目(G10):抹审计内容 → 删项目;额度流水和审计记录保留 +BEGIN; +UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now() +WHERE project_id = '00000000-0000-7000-8000-0000000000a3'; +DELETE FROM project WHERE id = '00000000-0000-7000-8000-0000000000a3'; +COMMIT; +SELECT 'T14 after delete — docs/items/findings/runs (expect 0/0/0/0): ' + || (SELECT count(*) FROM source_document WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' + || (SELECT count(*) FROM checklist_item WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' + || (SELECT count(*) FROM finding WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/' + || (SELECT count(*) FROM check_run WHERE tenant_id = '00000000-0000-7000-8000-00000000000a'); +SELECT 'T14 kept — quota entries (expect 3), audit rows scrubbed (expect 1): ' + || (SELECT count(*) FROM quota_entry WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || ', ' + || (SELECT count(*) FROM audit_event WHERE before IS NULL AND content_scrubbed_at IS NOT NULL); diff --git a/docs/architecture/schema-draft.sql b/docs/architecture/schema-draft.sql new file mode 100644 index 0000000..2e611c3 --- /dev/null +++ b/docs/architecture/schema-draft.sql @@ -0,0 +1,960 @@ +-- 标博士 · 物理数据模型草案 v0.1(PostgreSQL 16+) +-- 说明见 docs/architecture/data-model.md。这是设计草案,不是迁移脚本: +-- 工程骨架搭好后按模块拆成 Alembic 迁移,本文件不再维护。 +-- +-- 约定(data-model.md §1): +-- 主键 UUIDv7,由应用生成;时间一律 timestamptz;状态字段用 text + CHECK。 +-- 业务表都带 tenant_id,并用 (tenant_id, id) 复合外键保证不跨租户引用。 +-- 项目内部的外键一律 CASCADE / SET NULL:只在删除整个项目时真正触发(清单项只做软删除)。 +-- 全局表只有 user_account、login_identity、plan、ops_admin、ops_session。 + +-- ============================================================ +-- 0. 公共 +-- ============================================================ + +CREATE SCHEMA IF NOT EXISTS app; +SET search_path = app; + +-- 当前事务的租户;api / worker 在事务开始时 SET LOCAL app.tenant_id +CREATE FUNCTION current_tenant() RETURNS uuid +LANGUAGE sql STABLE AS $$ + SELECT nullif(current_setting('app.tenant_id', true), '')::uuid +$$; + +CREATE FUNCTION set_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END $$; + +-- 只追加表:禁止 UPDATE / DELETE +CREATE FUNCTION forbid_change() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME; +END $$; + +-- ============================================================ +-- 1. identity:人、登录方式、企业主体、成员身份、会话(架构 §3.14) +-- ============================================================ + +CREATE TABLE tenant ( + id uuid PRIMARY KEY, + name text NOT NULL, + uscc text, -- 统一社会信用代码,MVP 可空(G9) + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'suspended', 'closed')), + closed_at timestamptz, -- 注销:业务数据删除,本行保留给订单和发票 + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- 全局表:不带 tenant_id,不受 RLS 约束,只有 identity 模块能读写 +CREATE TABLE user_account ( + id uuid PRIMARY KEY, + display_name text NOT NULL, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'disabled', 'anonymized')), + anonymized_at timestamptz, -- 注销账号:抹掉个人信息,行保留(data-model §6.2) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE login_identity ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES user_account (id), + provider text NOT NULL + CHECK (provider IN ('phone', 'password', 'feishu', 'wecom', 'oidc', 'ldap')), + external_org_id text NOT NULL DEFAULT '', -- 手机号、账号密码:空串 + external_user_id text NOT NULL, -- 手机号:HMAC(pepper, E.164 号码),不存明文(DM5) + contact_cipher bytea, -- 手机号密文,发短信和运营查询用 + contact_hint text, -- 138****5678 + secret_hash text, -- 只有 password 来源用(argon2id) + verified_at timestamptz, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (provider, external_org_id, external_user_id) +); +CREATE INDEX ON login_identity (user_id); + +CREATE TABLE membership ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + user_id uuid NOT NULL REFERENCES user_account (id), + role text NOT NULL DEFAULT 'owner' + CHECK (role IN ('owner', 'admin', 'member')), -- MVP 只有 owner + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('invited', 'active', 'disabled', 'left')), + joined_at timestamptz NOT NULL DEFAULT now(), + left_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, user_id) +); +CREATE INDEX ON membership (user_id); + +CREATE TABLE user_session ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + membership_id uuid NOT NULL, + user_id uuid NOT NULL REFERENCES user_account (id), + token_hash bytea NOT NULL UNIQUE, -- SHA-256(令牌),令牌本身不落库(AR8) + client_kind text NOT NULL CHECK (client_kind IN ('web', 'desktop')), + user_agent text, + ip inet, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), -- 最多每 5 分钟写一次 + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + revoke_reason text CHECK (revoke_reason IN ('logout', 'concurrent_login', 'admin', 'tenant_closed', 'expired')), + FOREIGN KEY (tenant_id, membership_id) REFERENCES membership (tenant_id, id) +); +CREATE INDEX ON user_session (user_id) WHERE revoked_at IS NULL; + +-- ============================================================ +-- 2. billing:套餐、订阅、开通申请、额度(架构 §3.9,FR-M6-03、05) +-- ============================================================ + +-- 全局表:套餐定义带版本,D2 定价之后改数字就是加一个版本 +CREATE TABLE plan ( + code text NOT NULL CHECK (code IN ('free', 'pro', 'enterprise')), + version int NOT NULL, + name text NOT NULL, + quotas jsonb NOT NULL, -- {"parse": 500, "generate": 300};检查怎么扣见 data-model §4.3 + limits jsonb NOT NULL, -- 单文件大小、页数、项目数上限 + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (code, version) +); + +CREATE TABLE activation_request ( -- 专业版开通申请(G8);注销后按财务要求保留 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + applicant_user_id uuid NOT NULL REFERENCES user_account (id), + plan_code text NOT NULL, + plan_version int NOT NULL, + invoice_info jsonb NOT NULL, -- 抬头、税号、地址电话、开户行账号 + status text NOT NULL DEFAULT 'submitted' + CHECK (status IN ('submitted', 'paid', 'activated', 'rejected', 'cancelled')), + ops_note text, + handled_by_ops uuid, + handled_at timestamptz, + invoice_no text, + invoiced_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + FOREIGN KEY (plan_code, plan_version) REFERENCES plan (code, version) +); + +CREATE TABLE subscription ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + plan_code text NOT NULL, + plan_version int NOT NULL, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'expired', 'cancelled')), + source text NOT NULL CHECK (source IN ('signup', 'manual', 'online')), + seats int NOT NULL DEFAULT 1 CHECK (seats >= 1), -- MVP 固定 1(AR11) + starts_at timestamptz NOT NULL, + ends_at timestamptz, -- 免费版为空 + activation_request_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + FOREIGN KEY (plan_code, plan_version) REFERENCES plan (code, version), + FOREIGN KEY (tenant_id, activation_request_id) REFERENCES activation_request (tenant_id, id) +); +-- 同一时间只有一个生效的订阅 +CREATE UNIQUE INDEX ON subscription (tenant_id) WHERE status = 'active'; + +CREATE TABLE quota_account ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + unit text NOT NULL CHECK (unit IN ('parse', 'check', 'generate', 'dedupe')), + balance int NOT NULL DEFAULT 0, -- 缓存,和流水在同一个事务里更新,定期对账 + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, unit) +); + +-- 只追加。amount 是对余额的影响:发放 +N、预扣 −1、退回 +1、结算 0、免扣 0、过期 −余量、调整 ±N +CREATE TABLE quota_entry ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + subscription_id uuid, -- 发放和过期挂在订阅周期上;「本期已用」按它汇总 + kind text NOT NULL + CHECK (kind IN ('grant', 'hold', 'settle', 'release', 'expire', 'adjust', 'waive')), + amount int NOT NULL, + idempotency_key text NOT NULL, -- 如 parse_job::hold + hold_entry_id uuid, -- 结算、退回指回对应的预扣 + ref_type text CHECK (ref_type IN ('parse_job', 'check_run')), + ref_id uuid, -- 不加外键:删除项目后流水保留(G10) + project_id uuid, -- 同上 + reason text, -- 调整必填;免扣写规则,如 recheck_24h + actor_user_id uuid REFERENCES user_account (id), + actor_ops_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, idempotency_key), + FOREIGN KEY (tenant_id, account_id) REFERENCES quota_account (tenant_id, id), + FOREIGN KEY (tenant_id, subscription_id) REFERENCES subscription (tenant_id, id), + FOREIGN KEY (tenant_id, hold_entry_id) REFERENCES quota_entry (tenant_id, id), + CHECK (kind <> 'adjust' OR reason IS NOT NULL), + CHECK (kind NOT IN ('settle', 'waive') OR amount = 0), + CHECK (kind <> 'hold' OR amount < 0), + CHECK (kind NOT IN ('settle', 'release') OR hold_entry_id IS NOT NULL) +); +CREATE INDEX ON quota_entry (tenant_id, account_id, created_at DESC); +CREATE INDEX ON quota_entry (tenant_id, ref_type, ref_id); +-- 一笔预扣只能有一个结局 +CREATE UNIQUE INDEX ON quota_entry (tenant_id, hold_entry_id) WHERE kind IN ('settle', 'release'); +CREATE TRIGGER quota_entry_append_only BEFORE UPDATE OR DELETE ON quota_entry + FOR EACH ROW EXECUTE FUNCTION forbid_change(); + +CREATE TABLE quota_alert ( -- 80% / 100% 每个周期只提醒一次 + tenant_id uuid NOT NULL, + account_id uuid NOT NULL, + subscription_id uuid NOT NULL, + threshold int NOT NULL CHECK (threshold IN (80, 100)), + sent_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, account_id, subscription_id, threshold), + FOREIGN KEY (tenant_id, account_id) REFERENCES quota_account (tenant_id, id), + FOREIGN KEY (tenant_id, subscription_id) REFERENCES subscription (tenant_id, id) +); + +-- ============================================================ +-- 3. project:项目、基本信息字段、分包、项目成员(FR-M2-03、04,FR-M6-01、02) +-- ============================================================ + +CREATE TABLE project ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + -- 下面几列是 project_field 的当前值,冗余在这里给工作台列表排序和筛选用 + name text NOT NULL, + code text, + purchaser text, + agency text, + procurement_method text, + legal_regime text NOT NULL DEFAULT 'unknown' + CHECK (legal_regime IN ('gov_procurement', 'tendering', 'unknown')), + deadline_at timestamptz, + status text NOT NULL DEFAULT 'tracking' + CHECK (status IN ('tracking', 'drafting', 'checked', 'submitted', 'archived')), + status_before_archive text CHECK (status_before_archive IN ('checked', 'submitted')), + -- 就绪标记:只在 checked 状态显示(G7);由 check 模块在处理问题的同一个事务里更新 + latest_check_run_id uuid, + open_fatal_count int NOT NULL DEFAULT 0, + last_charged_check_at timestamptz, -- 24 小时免扣的起点(P1) + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id) +); +CREATE INDEX ON project (tenant_id, status, deadline_at); + +CREATE TABLE project_member ( -- AR12:MVP 只有建项目的人,角色 lead + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + user_id uuid NOT NULL REFERENCES user_account (id), + role text NOT NULL CHECK (role IN ('lead', 'editor', 'viewer')), + added_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, project_id, user_id), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); +CREATE INDEX ON project_member (tenant_id, user_id); + +CREATE TABLE lot ( -- 包 / 标段;没分包时只有一个默认包 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + lot_no text NOT NULL, -- 原文里的写法:「1」「包2」「第三标段」 + name text, + budget_amount numeric(18, 2), + ceiling_amount numeric(18, 2), -- 采购包限价;三层限价的另外两层见 data-model §5.3 + is_default boolean NOT NULL DEFAULT false, + is_bidding boolean NOT NULL DEFAULT true, -- 用户选择投哪些包 + origin text NOT NULL CHECK (origin IN ('ai', 'human')), + ai_values jsonb, -- AI 原值,人工修改不覆盖 + edited_fields text[] NOT NULL DEFAULT '{}', + sort_order int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, project_id, lot_no), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); + +-- ============================================================ +-- 4. document:文档、页、出处、解析任务(FR-M2-01、02、06,FR-M3-01) +-- ============================================================ + +CREATE TABLE source_document ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + role text NOT NULL + CHECK (role IN ('tender', 'addendum', 'clarification', 'bid_volume')), + seq_no int NOT NULL, -- 招标侧:补遗 N 的 N;投标侧:分册内的版本号 + volume_name text, -- 投标分册:商务 / 技术 / 报价 / 资格证明 + lineage_id uuid NOT NULL, -- 同一分册的各个版本共用;招标侧等于 id + original_filename text NOT NULL, + detected_format text NOT NULL + CHECK (detected_format IN ('pdf', 'docx', 'doc', 'wps', 'ofd', 'encrypted_bid', 'unknown')), + size_bytes bigint NOT NULL, + original_key text NOT NULL, -- 对象存储键 + original_sha256 bytea NOT NULL, + -- 规范化 PDF:只生成一次,写入后不再改(由服务层和触发器保证) + normalized_key text, + normalized_sha256 bytea, + page_count int, + text_layer_key text, -- 逐字坐标、格式、句子,存对象存储(DM1) + pipeline_profile jsonb, -- 转换器、字体包、切句算法的版本,复盘用 + status text NOT NULL DEFAULT 'uploaded' + CHECK (status IN ('uploaded', 'normalizing', 'ready', 'failed')), + uploaded_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, project_id, role, lineage_id, seq_no), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); + +CREATE FUNCTION freeze_normalized_pdf() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.normalized_sha256 IS NOT NULL AND + (NEW.normalized_sha256 IS DISTINCT FROM OLD.normalized_sha256 OR + NEW.normalized_key IS DISTINCT FROM OLD.normalized_key) THEN + RAISE EXCEPTION 'normalized PDF of document % is immutable', OLD.id; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER source_document_freeze BEFORE UPDATE ON source_document + FOR EACH ROW EXECUTE FUNCTION freeze_normalized_pdf(); + +CREATE TABLE document_page ( -- 逐页摘要:覆盖率检查、OCR 决策、阅读器尺寸 + tenant_id uuid NOT NULL, + document_id uuid NOT NULL, + page_no int NOT NULL, + width_pt real NOT NULL, + height_pt real NOT NULL, + rotation int NOT NULL DEFAULT 0, + text_source text NOT NULL CHECK (text_source IN ('text', 'ocr', 'mixed', 'empty')), + char_count int NOT NULL, + sentence_count int NOT NULL, + PRIMARY KEY (tenant_id, document_id, page_no), + FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE +); + +CREATE TABLE parse_job ( -- 招标文件 / 补遗 / 澄清的解析;重试是新的一行 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + document_id uuid NOT NULL, + attempt_no int NOT NULL, + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + stage text CHECK (stage IN ('detect', 'convert', 'text_layer', 'ocr', 'structure', + 'extract', 'verify', 'merge')), + checkpoints jsonb NOT NULL DEFAULT '{}', -- 每个阶段完成时写入,失败后从这里续跑 + progress int NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), + cancel_requested_at timestamptz, + error_class text CHECK (error_class IN ('infra', 'input', 'content', 'cancelled')), + error_code text, + error_detail text, -- 不含文档内容 + prompt_versions jsonb, -- {"extract.qualification": "v3", ...};逐次调用见 ai_call + quota_hold_id uuid, + queue_job_id bigint, -- Procrastinate 的任务编号 + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + finished_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, document_id, attempt_no), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, quota_hold_id) REFERENCES quota_entry (tenant_id, id) +); +-- 一份文档同时只能有一个在跑的解析 +CREATE UNIQUE INDEX ON parse_job (tenant_id, document_id) WHERE status IN ('queued', 'running'); + +-- 出处:不可变。清单项、问题、项目字段、标注规则都引用它 +CREATE TABLE citation ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + document_id uuid NOT NULL, + normalized_sha256 bytea NOT NULL, -- 和文档上的哈希一致才能渲染 + page_no int NOT NULL, + sentence_keys text[] NOT NULL, -- 同一页上连续的句子,如 {:p12:s3, :p12:s4} + char_start int, -- 只引用句子的一部分时用,相对于首句 + char_end int, + quads jsonb NOT NULL, -- 坐标框快照,阅读器直接画 + quote text NOT NULL, -- 原句快照 + created_by_kind text NOT NULL CHECK (created_by_kind IN ('ai', 'rule', 'human')), + checks_passed text[] NOT NULL DEFAULT '{}', -- {id, quote, basis}:三道校验(架构 §3.6) + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + CHECK (cardinality(sentence_keys) >= 1), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE +); +CREATE INDEX ON citation (tenant_id, document_id, page_no); +CREATE TRIGGER citation_immutable BEFORE UPDATE ON citation + FOR EACH ROW EXECUTE FUNCTION forbid_change(); + +-- 项目基本信息字段(FR-M2-03):AI 值和人工值分开,每个字段带出处 +CREATE TABLE project_field ( + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + field_key text NOT NULL + CHECK (field_key IN ('name', 'code', 'purchaser', 'agency', 'procurement_method', + 'legal_regime', 'budget_total', 'ceiling_total', 'eval_method', + 'accept_consortium', 'accept_imported', 'sme_only', 'deadline_at')), + ai_value jsonb, + ai_citation_id uuid, + ai_document_id uuid, -- 最新一次提取来自哪份文件(补遗覆盖时变) + human_value jsonb, + human_set_by uuid REFERENCES user_account (id), + human_set_at timestamptz, + pending_value jsonb, -- 补遗带来的新值,等用户确认(FR-M2-10) + pending_citation_id uuid, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, project_id, field_key), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, ai_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (ai_citation_id), + FOREIGN KEY (tenant_id, pending_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (pending_citation_id), + FOREIGN KEY (tenant_id, ai_document_id) REFERENCES source_document (tenant_id, id) ON DELETE SET NULL (ai_document_id) +); + +-- ============================================================ +-- 5. checklist:清单、清单项、版本快照、标注规则(FR-M2-05~10) +-- ============================================================ + +CREATE TABLE checklist ( -- 每个项目一份 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'confirmed')), + last_version_no int NOT NULL DEFAULT 0, + confirmed_version_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, project_id), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); + +-- 文件自己的「标注规则说明」(07 F1):先认规则,再按规则认实质性条款 +CREATE TABLE marking_rule ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + document_id uuid NOT NULL, + marker text NOT NULL + CHECK (marker IN ('star', 'asterisk', 'triangle', 'italic_underline', 'bold', 'underline', 'keyword')), + marker_text text, -- keyword 时是关键词,如「必须」 + meaning text NOT NULL CHECK (meaning IN ('substantive', 'important')), + declared_count int, -- 「★ 项共 14 条」;和实际抽到的数量对比 + citation_id uuid NOT NULL, + origin text NOT NULL CHECK (origin IN ('ai', 'rule', 'human')), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE +); + +CREATE TABLE checklist_item ( + id uuid PRIMARY KEY, -- 稳定编号:重新解析时对齐,不换号(架构 §3.8) + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + checklist_id uuid NOT NULL, + -- 当前值(人工改过的字段以人工为准) + categories text[] NOT NULL, -- 可以多个,见下方 CHECK + title text NOT NULL, + requirement_text text, -- 要求原文,不改写;人工添加时可空 + summary text, -- AI 摘要,界面标注 AI 生成 + importance text NOT NULL DEFAULT 'normal' + CHECK (importance IN ('substantive', 'important', 'normal')), -- ★ / ▲ / 一般 + importance_basis text CHECK (importance_basis IN ('marker', 'statute', 'tender_text', 'human')), + marking_rule_id uuid, -- 按哪条标注规则认定为 ★ + lot_id uuid, -- 空 = 各包通用 + evidence_required text, -- 证明材料要求 + spec jsonb NOT NULL DEFAULT '{}', -- 类别特有字段(05 §3.3),按类别用 Pydantic 校验 + -- 来源与区域 + origin text NOT NULL CHECK (origin IN ('ai', 'rule', 'human')), + origin_document_id uuid, -- 来自哪份文件;补遗 N 由文档的 seq_no 得出 + zone text NOT NULL CHECK (zone IN ('pending', 'formal')), -- 待确认区 / 正式清单 + pending_reason text CHECK (pending_reason IN ('no_citation', 'low_confidence', 'verify_failed', + 'marker_uncovered', 'absent_in_rerun')), + no_citation_ack_by uuid REFERENCES user_account (id), -- 「人工确认、无出处」 + accepted_by uuid REFERENCES user_account (id), -- 从待确认区采纳 + accepted_at timestamptz, + superseded_by_id uuid, -- 被补遗的新条目更新(日期类,FR-M2-10) + supersede_confirmed_at timestamptz, + -- AI 原值与对齐 + ai_values jsonb, -- 最近一次 AI 给出的字段原值,人工修改不覆盖 + edited_fields text[] NOT NULL DEFAULT '{}', -- 人工改过的字段:重跑对齐时不动它们 + confidence numeric(4, 3), + last_parse_job_id uuid, + anchor_sentence_key text, -- 首个出处的句子编号:排序和对齐用 + sort_key text, + -- 软删除(FR-M2-08) + deleted_at timestamptz, + deleted_by uuid REFERENCES user_account (id), + created_by uuid REFERENCES user_account (id), -- 人工添加时 + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + CHECK (cardinality(categories) >= 1 AND + categories <@ ARRAY['qualification', 'invalidity', 'scoring', 'schedule', + 'tech_param', 'format_doc', 'price']::text[]), + -- ★ 条款必须同时在「无效 / 废标」类(FR-M2-05) + CHECK (importance <> 'substantive' OR 'invalidity' = ANY (categories)), + CHECK (zone <> 'pending' OR pending_reason IS NOT NULL), + FOREIGN KEY (tenant_id, checklist_id) REFERENCES checklist (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, lot_id) REFERENCES lot (tenant_id, id) ON DELETE SET NULL (lot_id), + FOREIGN KEY (tenant_id, origin_document_id) REFERENCES source_document (tenant_id, id) ON DELETE SET NULL (origin_document_id), + FOREIGN KEY (tenant_id, marking_rule_id) REFERENCES marking_rule (tenant_id, id) ON DELETE SET NULL (marking_rule_id), + FOREIGN KEY (tenant_id, superseded_by_id) REFERENCES checklist_item (tenant_id, id) ON DELETE SET NULL (superseded_by_id), + FOREIGN KEY (tenant_id, last_parse_job_id) REFERENCES parse_job (tenant_id, id) ON DELETE SET NULL (last_parse_job_id) +); +CREATE INDEX ON checklist_item (tenant_id, checklist_id, zone) WHERE deleted_at IS NULL; +CREATE INDEX ON checklist_item USING gin (categories); + +CREATE TABLE checklist_item_citation ( + tenant_id uuid NOT NULL, + item_id uuid NOT NULL, + citation_id uuid NOT NULL, + position int NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, item_id, citation_id), + FOREIGN KEY (tenant_id, item_id) REFERENCES checklist_item (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE +); + +-- 不可变快照:确认清单时生成;清单没确认就检查时,检查前也生成一份(kind = check) +CREATE TABLE checklist_version ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + checklist_id uuid NOT NULL, + version_no int NOT NULL, + kind text NOT NULL CHECK (kind IN ('confirmed', 'check')), + content_hash bytea NOT NULL, -- 内容没变就复用上一份,不重复生成 + item_count int NOT NULL, + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, checklist_id, version_no), + FOREIGN KEY (tenant_id, checklist_id) REFERENCES checklist (tenant_id, id) ON DELETE CASCADE +); +ALTER TABLE checklist + ADD FOREIGN KEY (tenant_id, confirmed_version_id) REFERENCES checklist_version (tenant_id, id) + ON DELETE SET NULL (confirmed_version_id); + +CREATE TABLE checklist_version_item ( -- 只收正式清单里、未删除的条目 + tenant_id uuid NOT NULL, + version_id uuid NOT NULL, + item_id uuid NOT NULL, -- 指向稳定编号;条目以后被删也不影响快照 + payload jsonb NOT NULL, -- 当时的当前值 + 出处编号列表 + PRIMARY KEY (tenant_id, version_id, item_id), + FOREIGN KEY (tenant_id, version_id) REFERENCES checklist_version (tenant_id, id) ON DELETE CASCADE +); + +-- ============================================================ +-- 6. check:检查批次、问题、人工核对(FR-M3-02~08) +-- ============================================================ + +CREATE TABLE check_run ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + run_no int NOT NULL, -- 项目内第几次检查 + checklist_version_id uuid NOT NULL, + checklist_confirmed boolean NOT NULL, -- false 时报告和界面提示「清单未确认」(G7) + rule_set_version text NOT NULL, + legal_regime text NOT NULL CHECK (legal_regime IN ('gov_procurement', 'tendering', 'unknown')), + lot_ids uuid[] NOT NULL DEFAULT '{}', -- 本次检查的包 + previous_run_id uuid, -- 状态沿用的来源 + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + stage text CHECK (stage IN ('prepare_bid', 'match', 'rules', 'verify', 'carry_over', 'summarize')), + checkpoints jsonb NOT NULL DEFAULT '{}', + progress int NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), + cancel_requested_at timestamptz, + error_class text CHECK (error_class IN ('infra', 'input', 'content', 'cancelled')), + error_code text, + error_detail text, + billing text NOT NULL CHECK (billing IN ('charged', 'waived_recheck', 'free_plan')), + quota_hold_id uuid, + prompt_versions jsonb, + coverage jsonb, -- 查了什么、没查什么(报告首页) + counts jsonb, -- 各级别问题数,结束时写入 + queue_job_id bigint, + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + finished_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, project_id, run_no), + CHECK (billing <> 'charged' OR quota_hold_id IS NOT NULL), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, checklist_version_id) REFERENCES checklist_version (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, previous_run_id) REFERENCES check_run (tenant_id, id) ON DELETE SET NULL (previous_run_id), + FOREIGN KEY (tenant_id, quota_hold_id) REFERENCES quota_entry (tenant_id, id) +); +CREATE UNIQUE INDEX ON check_run (tenant_id, project_id) WHERE status IN ('queued', 'running'); +ALTER TABLE project + ADD FOREIGN KEY (tenant_id, latest_check_run_id) REFERENCES check_run (tenant_id, id) + ON DELETE SET NULL (latest_check_run_id); + +CREATE TABLE check_run_document ( -- 本次检查用了哪些投标文件版本 + tenant_id uuid NOT NULL, + check_run_id uuid NOT NULL, + document_id uuid NOT NULL, + PRIMARY KEY (tenant_id, check_run_id, document_id), + FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE +); + +CREATE TABLE finding ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + check_run_id uuid NOT NULL, + checklist_item_id uuid, -- AI 判断的问题必填;规则问题有对应条目时填(DM6) + rule_code text, -- 规则问题的规则编号,如 PRICE.CAPITAL_MISMATCH + match_key text NOT NULL, -- item: 或 rule:[:<目标>];复查沿用按它 + type 匹配 + type text NOT NULL + CHECK (type IN ('unresponded', 'negative_deviation', 'suspected_unmet', + 'formality', 'qualification', 'price_form', 'manual_review')), + severity text NOT NULL CHECK (severity IN ('fatal', 'important', 'hint')), + title text NOT NULL, + explanation text NOT NULL, + detector text NOT NULL CHECK (detector IN ('ai', 'rule')), + detail jsonb NOT NULL DEFAULT '{}', -- 按类型:参数的要求值 / 响应值、资质预警种类、读到的报价金额 + search_scope jsonb, -- 「未找到」时找过哪些分册、哪些章节 + checks_passed text[] NOT NULL DEFAULT '{}', + presence text NOT NULL CHECK (presence IN ('new', 'carried', 'resolved')), -- 新增 / 沿用 / 本次已解决 + carried_from_id uuid, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed_ok', 'modified', 'ignored')), + resolution_note text, -- 忽略原因;致命问题「确认无误」的说明(U4) + resolved_by uuid REFERENCES user_account (id), + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + UNIQUE (tenant_id, check_run_id, match_key, type), + CHECK (checklist_item_id IS NOT NULL OR rule_code IS NOT NULL), + CHECK (detector <> 'ai' OR checklist_item_id IS NOT NULL), + CHECK (status <> 'ignored' OR resolution_note IS NOT NULL), + CHECK (NOT (severity = 'fatal' AND status = 'confirmed_ok') OR resolution_note IS NOT NULL), + CHECK (presence <> 'carried' OR carried_from_id IS NOT NULL), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, checklist_item_id) REFERENCES checklist_item (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, carried_from_id) REFERENCES finding (tenant_id, id) ON DELETE CASCADE +); +CREATE INDEX ON finding (tenant_id, check_run_id, severity, status); + +CREATE TABLE finding_citation ( + tenant_id uuid NOT NULL, + finding_id uuid NOT NULL, + citation_id uuid NOT NULL, + side text NOT NULL CHECK (side IN ('tender', 'bid')), + position int NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, finding_id, citation_id), + FOREIGN KEY (tenant_id, finding_id) REFERENCES finding (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE +); + +-- 需人工核对的勾选:按项目存,复查沿用(P2) +CREATE TABLE manual_check ( + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + check_key text NOT NULL, -- item:<清单项编号> 或 rule:<规则编号> + checked boolean NOT NULL, + note text, + checked_by uuid NOT NULL REFERENCES user_account (id), + checked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, project_id, check_key), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); + +-- ============================================================ +-- 7. report / qualification / feedback / notification +-- ============================================================ + +CREATE TABLE report ( -- 生成后不再改;重新导出就是新的一行 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + check_run_id uuid NOT NULL, + object_key text NOT NULL, + sha256 bytea NOT NULL, + page_count int NOT NULL, + template_version text NOT NULL, + generated_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE +); +CREATE TRIGGER report_immutable BEFORE UPDATE ON report + FOR EACH ROW EXECUTE FUNCTION forbid_change(); + +CREATE TABLE qualification ( -- 最小资质库(FR-M6-08) + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + name text NOT NULL, + kind text NOT NULL CHECK (kind IN ('certificate', 'system_cert', 'software_copyright', 'other')), + level text, + cert_no text, + issuer text, + valid_from date, + valid_to date, -- 空 = 长期有效 + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, id) +); + +CREATE TABLE qualification_file ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + qualification_id uuid NOT NULL, + object_key text NOT NULL, + filename text NOT NULL, + sha256 bytea NOT NULL, + size_bytes bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (tenant_id, qualification_id) REFERENCES qualification (tenant_id, id) ON DELETE CASCADE +); + +CREATE TABLE feedback ( -- 误报 / 漏报,进运营后台(J5) + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + project_id uuid NOT NULL, + object_type text NOT NULL CHECK (object_type IN ('finding', 'checklist_item', 'checklist')), + object_id uuid, + kind text NOT NULL CHECK (kind IN ('false_positive', 'false_negative')), + source text NOT NULL CHECK (source IN ('user', 'fatal_confirm')), -- 致命问题「确认无误」的说明自动进来(U4) + note text, + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'triaged', 'closed')), + handled_by_ops uuid, + handled_at timestamptz, + created_by uuid NOT NULL REFERENCES user_account (id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); +CREATE INDEX ON feedback (status, created_at); + +CREATE TABLE notification ( -- 站内通知(G14) + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + user_id uuid NOT NULL REFERENCES user_account (id), + kind text NOT NULL + CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', + 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring')), + project_id uuid, + payload jsonb NOT NULL DEFAULT '{}', -- 只放编号和数字,不放文档内容 + read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE +); +CREATE INDEX ON notification (tenant_id, user_id, created_at DESC) WHERE read_at IS NULL; + +-- ============================================================ +-- 8. ai:调用流水账(架构 §3.6) +-- ============================================================ + +CREATE TABLE ai_call ( -- 不存原文;输入按 input_hash 去对象存储取 + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + job_type text NOT NULL CHECK (job_type IN ('parse_job', 'check_run')), + job_id uuid NOT NULL, -- 不加外键:删除项目后成本数据保留 + task text NOT NULL, -- 如 extract.qualification + capability text NOT NULL CHECK (capability IN ('vision', 'fast', 'reasoning', 'embedding')), + provider text NOT NULL, + model text NOT NULL, + prompt_id text NOT NULL, + prompt_version text NOT NULL, + prompt_hash bytea NOT NULL, + input_hash bytea NOT NULL, + output_hash bytea, + tokens_in int, + tokens_out int, + tokens_cached int, + latency_ms int, + output_mode text CHECK (output_mode IN ('json_schema', 'json_mode', 'repaired')), + error_class text CHECK (error_class IN ('timeout', 'rate_limited', 'server', 'network', 'bad_output')), + cost_micro_cny bigint, -- 估算成本,百万分之一元 + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ON ai_call (tenant_id, job_id); +CREATE INDEX ON ai_call (created_at); +CREATE TRIGGER ai_call_append_only BEFORE UPDATE OR DELETE ON ai_call + FOR EACH ROW EXECUTE FUNCTION forbid_change(); + +-- ============================================================ +-- 9. audit:审计事件(架构 §3.10,FR-M6-07) +-- ============================================================ + +CREATE TABLE audit_event ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + actor_kind text NOT NULL CHECK (actor_kind IN ('user', 'ops', 'system')), + actor_user_id uuid REFERENCES user_account (id), + actor_ops_id uuid, + actor_name text, -- 姓名快照:人离职后留痕上仍显示当时的人 + action text NOT NULL, -- 如 checklist_item.update、finding.resolve + object_type text NOT NULL, + object_id uuid, + project_id uuid, -- 不加外键:删除项目后审计保留 + before jsonb, + after jsonb, + content_digest bytea, -- before / after 的哈希;抹掉内容后哈希链仍可校验 + content_scrubbed_at timestamptz, -- 删除项目时抹掉业务内容(G10) + request_id text, + ip inet, + prev_hash bytea, -- 可选的哈希链 + row_hash bytea, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ON audit_event (tenant_id, project_id, created_at); +CREATE INDEX ON audit_event (tenant_id, object_type, object_id); + +-- 审计只追加;唯一允许的修改是「抹掉业务内容」 +CREATE FUNCTION audit_scrub_only() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'audit_event is append-only'; + END IF; + IF NEW.before IS NOT NULL OR NEW.after IS NOT NULL OR NEW.content_scrubbed_at IS NULL + OR (to_jsonb(NEW) - ARRAY['before', 'after', 'content_scrubbed_at', 'actor_name']) + IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['before', 'after', 'content_scrubbed_at', 'actor_name']) THEN + RAISE EXCEPTION 'audit_event only allows scrubbing content'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER audit_event_guard BEFORE UPDATE OR DELETE ON audit_event + FOR EACH ROW EXECUTE FUNCTION audit_scrub_only(); + +-- ============================================================ +-- 10. 加密密钥:信封加密,删除时销毁(架构 §3.11,DM4) +-- ============================================================ + +CREATE TABLE data_key ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenant (id), + scope text NOT NULL CHECK (scope IN ('tenant', 'project')), + project_id uuid, -- 不加外键:项目删除后这行留下销毁记录 + wrapped_key bytea, -- 被 KMS 主密钥加密的数据密钥;销毁时置空 + kek_ref text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + destroyed_at timestamptz, + CHECK ((scope = 'project') = (project_id IS NOT NULL)), + CHECK ((destroyed_at IS NULL) = (wrapped_key IS NOT NULL)) +); +CREATE UNIQUE INDEX ON data_key (tenant_id) WHERE scope = 'tenant' AND destroyed_at IS NULL; +CREATE UNIQUE INDEX ON data_key (tenant_id, project_id) WHERE scope = 'project' AND destroyed_at IS NULL; + +-- ============================================================ +-- 11. ops:运营后台账号(独立登录,架构 §3.11) +-- ============================================================ + +CREATE TABLE ops_admin ( + id uuid PRIMARY KEY, + username text NOT NULL UNIQUE, + display_name text NOT NULL, + secret_hash text NOT NULL, + totp_cipher bytea NOT NULL, -- 二次验证密钥(密文) + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE ops_session ( + id uuid PRIMARY KEY, + admin_id uuid NOT NULL REFERENCES ops_admin (id), + token_hash bytea NOT NULL UNIQUE, + ip inet NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz +); + +-- ============================================================ +-- 12. updated_at 触发器 +-- ============================================================ + +DO $$ +DECLARE t text; +BEGIN + FOR t IN + SELECT c.table_name FROM information_schema.columns c + WHERE c.table_schema = 'app' AND c.column_name = 'updated_at' + LOOP + EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_updated_at()', + t || '_updated_at', t); + END LOOP; +END $$; + +-- ============================================================ +-- 13. 行级安全策略(架构 §3.3 第 2 层) +-- ============================================================ +-- 角色:bd_owner 建表(迁移用);bd_app 给 api / worker 用,受 RLS 约束; +-- bd_ops 给运营后台用,BYPASSRLS,所有操作写审计。 + +DO $$ +DECLARE t text; +BEGIN + FOR t IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables tb USING (table_schema, table_name) + WHERE c.table_schema = 'app' AND c.column_name = 'tenant_id' AND tb.table_type = 'BASE TABLE' + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY tenant_isolation ON %I + USING (tenant_id = current_tenant()) + WITH CHECK (tenant_id = current_tenant())', t); + END LOOP; +END $$; + +-- tenant 表按自己的 id 隔离 +ALTER TABLE tenant ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON tenant + USING (id = current_tenant()) WITH CHECK (id = current_tenant()); + +-- 登录前还不知道租户:只有这两个函数能跨租户查成员身份和会话(data-model §2.3) +CREATE FUNCTION auth_resolve_session(p_token_hash bytea) +RETURNS TABLE (session_id uuid, tenant_id uuid, membership_id uuid, user_id uuid, expires_at timestamptz) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$ + SELECT s.id, s.tenant_id, s.membership_id, s.user_id, s.expires_at + FROM user_session s + JOIN membership m ON m.id = s.membership_id AND m.status = 'active' + JOIN tenant t ON t.id = s.tenant_id AND t.status = 'active' + WHERE s.token_hash = p_token_hash AND s.revoked_at IS NULL AND s.expires_at > now() +$$; + +CREATE FUNCTION auth_list_memberships(p_user_id uuid) +RETURNS TABLE (membership_id uuid, tenant_id uuid, tenant_name text, role text, status text) +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$ + SELECT m.id, m.tenant_id, t.name, m.role, m.status + FROM membership m JOIN tenant t ON t.id = m.tenant_id + WHERE m.user_id = p_user_id AND t.status <> 'closed' +$$; diff --git a/docs/decisions.md b/docs/decisions.md index 803029f..f8883dc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,23 @@ --- +## DM1–DM7 · 2026-11-03 · 物理数据模型的取舍:全部按建议 + +依据 `docs/architecture/data-model.md` §9;建表脚本草案 `schema-draft.sql` 在 PostgreSQL 16 上跑通了 16 项行为测试。 + +| # | 决策 | 理由 | +|---|---|---| +| DM1 | 文字层和句子放对象存储,**不进数据库**;数据库只存页摘要和出处快照 | 每份文件约 2400 句,一年十亿行级;用到句子的地方都在 worker 里整份读取 | +| DM2 | 状态和枚举用 `text` 加 `CHECK`,不用 PostgreSQL 枚举类型 | 改值只改约束;信创数据库兼容性更好 | +| DM3 | 业务表之间的外键都带 `tenant_id` | 关掉行级安全策略时仍能防止跨租户引用 | +| DM4 | 每个项目一把数据密钥,资质附件用企业级密钥;删除项目时销毁 | 落实 G10「彻底删除」,备份里的数据随之不可读 | +| DM5 | 手机号不存明文:查找用 HMAC,发短信用密文,界面显示掩码 | 数据库泄露时拿不到号码 | +| DM6 | AI 判断的问题必须挂清单项;规则问题有对应条目时挂,没有时用规则编号 | 大小写金额不一致、出现其他项目名称这类问题,招标文件里没有对应要求 | +| DM7 | 清单新增类别「报价」,承载分项限价和单价上限;总价限价放项目字段,包限价放分包 | 三层限价(07 F6)都要有确定的落点 | + +- **另外**:新增 `notification` 模块(站内通知);项目内部的外键一律级联删除,平时清单项只做软删除。 +- **影响**:架构 §3.2、§3.11、§9、§12;05 §3.2–3.3;FR-M2-05、FR-M3-03;04 指向物理模型。 + ## AR11–AR12 · 2026-11-03 · 早期只给多人、多登录方式留结构 - **原则**:团队版、企业版(多席位、按角色和项目分权限)、飞书 / 企业微信登录、私有化 SSO 都是以后的版本,早期版本不做;但 MVP 的架构和表结构要先考虑到,以后能直接接进来,不用重构。理由:用户要求。