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:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent 5747a523bb
commit ad7fba6021
106 changed files with 8094 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
-- 0. 公共
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
CREATE SCHEMA IF NOT EXISTS 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 $$;

View File

@@ -0,0 +1,76 @@
-- 1. identity:人、登录方式、企业主体、成员身份、会话(架构 §3.14)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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;

View File

@@ -0,0 +1,110 @@
-- 2. billing:套餐、订阅、开通申请、额度(架构 §3.9,FR-M6-03、05)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
-- 全局表:套餐定义带版本,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)
);

View File

@@ -0,0 +1,60 @@
-- 3. project:项目、基本信息字段、分包、项目成员(FR-M2-03、04,FR-M6-01、02)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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
);

View 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)
);

View File

@@ -0,0 +1,132 @@
-- 5. checklist:清单、清单项、版本快照、标注规则(FR-M2-05~10)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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
);

View File

@@ -0,0 +1,119 @@
-- 6. check:检查批次、问题、人工核对(FR-M3-02~08)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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
);

View File

@@ -0,0 +1,81 @@
-- 7. report / qualification / feedback / notification
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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;

View File

@@ -0,0 +1,29 @@
-- 8. ai:调用流水账(架构 §3.6)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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();

View File

@@ -0,0 +1,42 @@
-- 9. audit:审计事件(架构 §3.10,FR-M6-07)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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();

View File

@@ -0,0 +1,16 @@
-- 10. 加密密钥:信封加密,删除时销毁(架构 §3.11,DM4)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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;

View File

@@ -0,0 +1,22 @@
-- 11. ops:运营后台账号(独立登录,架构 §3.11)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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
);

View File

@@ -0,0 +1,13 @@
-- 12. updated_at 触发器
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
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 $$;

View File

@@ -0,0 +1,45 @@
-- 13. 行级安全策略(架构 §3.3 第 2 层)
-- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案)
-- 角色: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'
$$;

View File

@@ -0,0 +1,15 @@
-- 90. 角色与权限(data-model §2.4)
-- bd_app、bd_auth 两个角色由部署脚本创建(deploy/compose/postgres/init-roles.sh);这里只授权。
GRANT USAGE ON SCHEMA app TO bd_app, bd_auth;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO bd_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO bd_app;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA app TO bd_app;
-- 登录前查会话和成员身份:只有这两个函数能跨租户(data-model §2.3)
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;
-- 运营后台的跨租户接口、以及额度流水、审计这类只追加表,应用角色不能删除
REVOKE DELETE ON quota_entry, ai_call, audit_event FROM bd_app;