- data-model.md:约定、租户隔离落地、按模块的表清单、额度流水语义、 清单项与检查的建模、删除流程、对象存储布局 - schema-draft.sql:40 张表的建表草案(PostgreSQL 16+) - schema-draft-test.sql:租户隔离、额度流水、审计、删除等 16 项行为测试 - 回写架构 §3.2/§3.11/§9/§12、05 §3、FR-M2-05、FR-M3-03 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
961 lines
50 KiB
PL/PgSQL
961 lines
50 KiB
PL/PgSQL
-- 标博士 · 物理数据模型草案 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:<id>: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, -- 同一页上连续的句子,如 {<doc>:p12:s3, <doc>: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:<id> 或 rule:<code>[:<目标>];复查沿用按它 + 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'
|
||
$$;
|