feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路
- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、 problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点 - identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话 - billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局 - document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号 - checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐 - ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水 - 迁移:data-model 基线、套餐、Procrastinate、会话状态函数 - 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性 - Compose(含 migrate)、镜像、CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
141
server/migrations/sql/0001_baseline/04_document.sql
Normal file
141
server/migrations/sql/0001_baseline/04_document.sql
Normal file
@@ -0,0 +1,141 @@
|
||||
-- 4. document:文档、页、出处、解析任务(FR-M2-01、02、06,FR-M3-01)
|
||||
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
|
||||
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)
|
||||
);
|
||||
Reference in New Issue
Block a user