Files
bid-doctor/docs/architecture/data-model.md
YANG JIANKUAN 5747a523bb docs(architecture): 接口契约 v0.1,确认 API1–API5
- openapi.yaml:用户端 MVP 主流程的 OpenAPI 3.1 契约(64 个路径、81 个操作)
- api-design.md:约定、鉴权、页面与接口对应、扣点预览、任务与事件、上传、错误码
- data-model.md:清单项 spec 改为按类别分组

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:44:28 +08:00

23 KiB
Raw Blame History

物理数据模型 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)
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:<id>: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 模型校验。例如 ★ 技术参数是 {"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)。
  • 补遗:
    • 补遗带来的新条目,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 = {"price": {"kind": "item_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/<tenant>/p/<project>/doc/<document>/original          原件
t/<tenant>/p/<project>/doc/<document>/normalized.pdf    规范化 PDF(只写一次)
t/<tenant>/p/<project>/doc/<document>/text/p<NNNN>.json 文字层,按页分片
t/<tenant>/p/<project>/doc/<document>/structure.json    结构识别结果(章节、表格、分包)
t/<tenant>/p/<project>/ai/<input_hash>                  模型输入(排查问题用)
t/<tenant>/p/<project>/report/<report>.pdf              检查报告
t/<tenant>/q/<qualification>/<file>                     资质附件(企业级密钥)
  • 按项目分前缀,删除项目时可以整个前缀一起删;
  • 每个对象都用所属项目(或企业)的数据密钥加密;
  • 对象存储不开公开读,下载一律经过 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 定义核心接口。