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:
24
server/migrations/env.py
Normal file
24
server/migrations/env.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Alembic 环境:迁移用 bd_owner 连接;不用自动建表,迁移脚本都是手写的 SQL。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from bid_doctor.core.config import get_settings
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
engine = create_engine(
|
||||
get_settings().migrate_database_url,
|
||||
connect_args={"options": "-c search_path=app,public"},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
context.configure(connection=connection, version_table_schema="public", transaction_per_migration=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
raise SystemExit("不支持离线模式:迁移脚本里有需要连接数据库的步骤")
|
||||
run_migrations_online()
|
||||
23
server/migrations/sql/0001_baseline/00_common.sql
Normal file
23
server/migrations/sql/0001_baseline/00_common.sql
Normal 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 $$;
|
||||
76
server/migrations/sql/0001_baseline/01_identity.sql
Normal file
76
server/migrations/sql/0001_baseline/01_identity.sql
Normal 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;
|
||||
110
server/migrations/sql/0001_baseline/02_billing.sql
Normal file
110
server/migrations/sql/0001_baseline/02_billing.sql
Normal 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)
|
||||
);
|
||||
60
server/migrations/sql/0001_baseline/03_project.sql
Normal file
60
server/migrations/sql/0001_baseline/03_project.sql
Normal 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
|
||||
);
|
||||
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)
|
||||
);
|
||||
132
server/migrations/sql/0001_baseline/05_checklist.sql
Normal file
132
server/migrations/sql/0001_baseline/05_checklist.sql
Normal 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
|
||||
);
|
||||
119
server/migrations/sql/0001_baseline/06_check.sql
Normal file
119
server/migrations/sql/0001_baseline/06_check.sql
Normal 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
|
||||
);
|
||||
@@ -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;
|
||||
29
server/migrations/sql/0001_baseline/08_ai.sql
Normal file
29
server/migrations/sql/0001_baseline/08_ai.sql
Normal 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();
|
||||
42
server/migrations/sql/0001_baseline/09_audit.sql
Normal file
42
server/migrations/sql/0001_baseline/09_audit.sql
Normal 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();
|
||||
16
server/migrations/sql/0001_baseline/10_storage.sql
Normal file
16
server/migrations/sql/0001_baseline/10_storage.sql
Normal 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;
|
||||
22
server/migrations/sql/0001_baseline/11_ops.sql
Normal file
22
server/migrations/sql/0001_baseline/11_ops.sql
Normal 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
|
||||
);
|
||||
@@ -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 $$;
|
||||
@@ -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'
|
||||
$$;
|
||||
15
server/migrations/sql/0001_baseline/90_roles_and_grants.sql
Normal file
15
server/migrations/sql/0001_baseline/90_roles_and_grants.sql
Normal 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;
|
||||
15
server/migrations/sqlfiles.py
Normal file
15
server/migrations/sqlfiles.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""按文件名顺序执行一个目录下的 SQL 文件(迁移脚本共用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
|
||||
SQL_DIR = Path(__file__).parent / "sql"
|
||||
|
||||
|
||||
def run_dir(name: str) -> None:
|
||||
for f in sorted((SQL_DIR / name).glob("*.sql")):
|
||||
# psycopg 的 exec_driver_sql 支持一次执行多条语句;SQL 里的 % 不做参数替换
|
||||
op.get_bind().exec_driver_sql(f.read_text().replace("%", "%%"))
|
||||
23
server/migrations/versions/0001_baseline.py
Normal file
23
server/migrations/versions/0001_baseline.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""基线:data-model v0.1 的全部 40 张表、行级安全策略、角色授权。
|
||||
|
||||
Revision ID: 0001
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from sqlfiles import run_dir
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_dir("0001_baseline")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError("基线不支持回退")
|
||||
38
server/migrations/versions/0002_seed_plans.py
Normal file
38
server/migrations/versions/0002_seed_plans.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""套餐 v1:额度数字是 PRD 的建议值,D2 成本实测后改数字就是加一个版本(data-model §4.3)。
|
||||
|
||||
Revision ID: 0002
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
|
||||
MB = 1024 * 1024
|
||||
PLANS = [
|
||||
("free", 1, "免费版", {"parse": 3}, {"max_file_bytes": 50 * MB, "max_pages": 300, "max_projects": 1}),
|
||||
(
|
||||
"pro",
|
||||
1,
|
||||
"专业版",
|
||||
{"parse": 500, "generate": 300},
|
||||
{"max_file_bytes": 200 * MB, "max_pages": 1000, "max_projects": None},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for code, version, name, quotas, limits in PLANS:
|
||||
op.get_bind().execute(
|
||||
text("INSERT INTO plan (code, version, name, quotas, limits) VALUES (:c, :v, :n, :q, :l)"),
|
||||
{"c": code, "v": version, "n": name, "q": json.dumps(quotas), "l": json.dumps(limits)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(text("DELETE FROM plan WHERE version = 1"))
|
||||
31
server/migrations/versions/0003_procrastinate.py
Normal file
31
server/migrations/versions/0003_procrastinate.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""任务队列 Procrastinate 的表(AR4)。放在 public schema:它的 SQL 函数里表名不带 schema。
|
||||
|
||||
任务状态的权威是业务表(parse_job 等);队列只负责派发(架构 §3.4)。这些表没有 tenant_id,不受行级安全策略约束。
|
||||
升级 Procrastinate 时,按它的版本说明另加迁移(server/pyproject.toml 里锁定了大版本)。
|
||||
|
||||
Revision ID: 0003
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
from procrastinate.schema import SchemaManager
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.exec_driver_sql("SET LOCAL search_path = public")
|
||||
bind.exec_driver_sql(SchemaManager.get_schema().replace("%", "%%"))
|
||||
bind.exec_driver_sql(
|
||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bd_app;"
|
||||
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO bd_app;"
|
||||
"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO bd_app;"
|
||||
"SET LOCAL search_path = app, public"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError
|
||||
30
server/migrations/versions/0004_session_state.py
Normal file
30
server/migrations/versions/0004_session_state.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""登录前查令牌为什么失效:被吊销(含挤下线)还是过期(API3,api-design §2)。
|
||||
|
||||
和 auth_resolve_session 一样是 SECURITY DEFINER,所有者 bd_auth,只返回状态,不返回会话内容(data-model §2.3)。
|
||||
|
||||
Revision ID: 0004
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("""
|
||||
CREATE FUNCTION auth_session_state(p_token_hash bytea)
|
||||
RETURNS TABLE (revoke_reason text, expired boolean)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$
|
||||
SELECT s.revoke_reason, s.expires_at <= now()
|
||||
FROM user_session s WHERE s.token_hash = p_token_hash
|
||||
$$;
|
||||
ALTER FUNCTION auth_session_state(bytea) OWNER TO bd_auth;
|
||||
GRANT EXECUTE ON FUNCTION auth_session_state(bytea) TO bd_app;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("DROP FUNCTION auth_session_state(bytea)")
|
||||
Reference in New Issue
Block a user