Files
bid-doctor/docs/architecture/schema-draft-test.sql
YANG JIANKUAN 0991d0237c docs(architecture): 物理数据模型 v0.1,确认 DM1–DM7
- data-model.md:约定、租户隔离落地、按模块的表清单、额度流水语义、
  清单项与检查的建模、删除流程、对象存储布局
- schema-draft.sql:40 张表的建表草案(PostgreSQL 16+)
- schema-draft-test.sql:租户隔离、额度流水、审计、删除等 16 项行为测试
- 回写架构 §3.2/§3.11/§9/§12、05 §3、FR-M2-05、FR-M3-03

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

198 lines
13 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- schema-draft.sql 的行为测试:在空库上先跑 schema-draft.sql,再跑本文件(psql -tAq)。
-- 每条输出要么带 ok,要么实际值和 expect 一致;出现 FAIL 或 ERROR 就是不通过。
\set ON_ERROR_STOP 1
SET search_path = app;
-- 角色:bd_app 受 RLS 约束;bd_auth 只拥有两个登录函数
CREATE ROLE bd_app NOLOGIN;
CREATE ROLE bd_auth NOLOGIN BYPASSRLS;
GRANT USAGE ON SCHEMA app TO bd_app, bd_auth;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO bd_app;
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;
GRANT EXECUTE ON FUNCTION auth_resolve_session(bytea), auth_list_memberships(uuid) TO bd_app;
-- 种子数据(以超级用户写入,绕过 RLS)
INSERT INTO plan VALUES ('free', 1, '免费版', '{"parse":3}', '{}', true, now());
INSERT INTO tenant (id, name) VALUES
('00000000-0000-7000-8000-00000000000a', '甲公司'),
('00000000-0000-7000-8000-00000000000b', '乙公司');
INSERT INTO user_account (id, display_name) VALUES
('00000000-0000-7000-8000-0000000000a1', '张工'),
('00000000-0000-7000-8000-0000000000b1', '李工');
INSERT INTO membership (id, tenant_id, user_id) VALUES
('00000000-0000-7000-8000-0000000000a2', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a1'),
('00000000-0000-7000-8000-0000000000b2', '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b1');
INSERT INTO user_session (id, tenant_id, membership_id, user_id, token_hash, client_kind, expires_at) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b2',
'00000000-0000-7000-8000-0000000000b1', '\x01', 'web', now() + interval '1 day');
INSERT INTO project (id, tenant_id, name, created_by) VALUES
('00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-00000000000a', '甲的项目', '00000000-0000-7000-8000-0000000000a1'),
('00000000-0000-7000-8000-0000000000b3', '00000000-0000-7000-8000-00000000000b', '乙的项目', '00000000-0000-7000-8000-0000000000b1');
-- 1. 租户隔离
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
SELECT 'T1 visible projects (expect 1): ' || count(*) FROM project;
SELECT 'T1 visible tenants (expect 1): ' || count(*) FROM tenant;
COMMIT;
BEGIN;
SET LOCAL ROLE bd_app;
SELECT 'T2 no tenant set (expect 0): ' || count(*) FROM project;
COMMIT;
-- 2. 不能往别的租户写数据
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
DO $$ BEGIN
INSERT INTO app.project (id, tenant_id, name, created_by) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', 'x', '00000000-0000-7000-8000-0000000000a1');
RAISE NOTICE 'T3 FAIL: cross-tenant insert allowed';
EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'T3 ok: cross-tenant insert blocked';
END $$;
COMMIT;
-- 3. 复合外键:甲租户的文档不能挂到乙的项目上(以超级用户写入,绕过 RLS 也拦得住)
DO $$ BEGIN
INSERT INTO app.source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename,
detected_format, size_bytes, original_key, original_sha256, uploaded_by)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000b3',
'tender', 0, gen_random_uuid(), 'a.pdf', 'pdf', 1, 'k', '\x00', '00000000-0000-7000-8000-0000000000a1');
RAISE NOTICE 'T4 FAIL: cross-tenant reference allowed';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'T4 ok: cross-tenant FK blocked';
END $$;
-- 4. 登录前解析会话:bd_app 未设租户,也能通过函数拿到乙的会话
BEGIN;
SET LOCAL ROLE bd_app;
SELECT 'T5 session resolved (expect 1): ' || count(*) FROM auth_resolve_session('\x01');
SELECT 'T5 memberships of 李工 (expect 1): ' || count(*) FROM auth_list_memberships('00000000-0000-7000-8000-0000000000b1');
COMMIT;
-- 5. 额度:预扣 → 结算;流水不能改
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
INSERT INTO quota_account (id, tenant_id, unit) VALUES
('00000000-0000-7000-8000-0000000000a4', '00000000-0000-7000-8000-00000000000a', 'parse');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, reason) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'grant', 3, 'signup', '注册赠送');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, ref_type, ref_id, project_id) VALUES
('00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'hold', -1, 'parse_job:1:hold', 'parse_job', gen_random_uuid(), '00000000-0000-7000-8000-0000000000a3');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id, project_id) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'settle', 0, 'parse_job:1:settle', '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a3');
SELECT 'T6 balance (expect 2): ' || sum(amount) FROM quota_entry;
COMMIT;
DO $$ BEGIN
INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'release', 1, 'parse_job:1:release', '00000000-0000-7000-8000-0000000000a5');
RAISE NOTICE 'T7 FAIL: hold has two outcomes';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'T7 ok: second outcome for one hold blocked';
END $$;
DO $$ BEGIN
UPDATE app.quota_entry SET amount = 100 WHERE kind = 'grant';
RAISE NOTICE 'T8 FAIL: ledger updated';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T8 ok: ledger append-only';
END $$;
DO $$ BEGIN
INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'adjust', 5, 'ops:1');
RAISE NOTICE 'T9 FAIL: adjust without reason';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T9 ok: adjust requires reason';
END $$;
-- 6. 文档、规范化 PDF 冻结、清单、检查、问题
INSERT INTO source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, detected_format,
size_bytes, original_key, original_sha256, normalized_key, normalized_sha256, page_count, uploaded_by)
VALUES ('00000000-0000-7000-8000-0000000000a6', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'tender', 0, '00000000-0000-7000-8000-0000000000a6', '招标文件.doc', 'docx', 100, 'orig', '\x01', 'norm', '\x02', 120,
'00000000-0000-7000-8000-0000000000a1');
DO $$ BEGIN
UPDATE app.source_document SET normalized_sha256 = '\x03' WHERE id = '00000000-0000-7000-8000-0000000000a6';
RAISE NOTICE 'T10 FAIL: normalized PDF replaced';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T10 ok: normalized PDF frozen';
END $$;
INSERT INTO citation (id, tenant_id, project_id, document_id, normalized_sha256, page_no, sentence_keys, quads, quote, created_by_kind)
VALUES ('00000000-0000-7000-8000-0000000000a7', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a6', '\x02', 38, '{a6:p38:s4}', '[[0,0,1,1]]', '★ 投标人须具备……', 'ai');
INSERT INTO checklist (id, tenant_id, project_id) VALUES
('00000000-0000-7000-8000-0000000000a8', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3');
DO $$ BEGIN
INSERT INTO app.checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a8', '{tech_param}', 'x', 'substantive', 'ai', 'formal');
RAISE NOTICE 'T11 FAIL: star item without invalidity tag';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T11 ok: star item must carry invalidity tag';
END $$;
INSERT INTO checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone)
VALUES ('00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a8', '{tech_param,invalidity}', '★ 内存 ≥ 32GB', 'substantive', 'ai', 'formal');
INSERT INTO checklist_item_citation VALUES
('00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a7', 0);
INSERT INTO checklist_version (id, tenant_id, checklist_id, version_no, kind, content_hash, item_count, created_by)
VALUES ('00000000-0000-7000-8000-0000000000aa', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a8',
1, 'check', '\x00', 1, '00000000-0000-7000-8000-0000000000a1');
INSERT INTO check_run (id, tenant_id, project_id, run_no, checklist_version_id, checklist_confirmed, rule_set_version,
legal_regime, billing, quota_hold_id, created_by)
VALUES ('00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
1, '00000000-0000-7000-8000-0000000000aa', false, 'it-v0', 'gov_procurement', 'charged',
'00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a1');
DO $$ BEGIN
INSERT INTO app.finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title,
explanation, detector, presence, status)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet',
'fatal', 't', 'e', 'ai', 'new', 'confirmed_ok');
RAISE NOTICE 'T12 FAIL: fatal confirm without note';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T12 ok: fatal confirm requires note (U4)';
END $$;
INSERT INTO finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title,
explanation, detector, presence)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet',
'fatal', '内存不满足', '要求 ≥ 32GB,响应 16GB', 'ai', 'new');
UPDATE project SET latest_check_run_id = '00000000-0000-7000-8000-0000000000ab', status = 'checked', open_fatal_count = 1
WHERE id = '00000000-0000-7000-8000-0000000000a3';
INSERT INTO audit_event (id, tenant_id, actor_kind, actor_user_id, actor_name, action, object_type, object_id, project_id, before, after)
VALUES ('00000000-0000-7000-8000-0000000000ac', '00000000-0000-7000-8000-00000000000a', 'user',
'00000000-0000-7000-8000-0000000000a1', '张工', 'checklist_item.update', 'checklist_item',
'00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a3', '{"title":"旧"}', '{"title":"新"}');
-- 7. 审计只能抹内容,不能改别的
DO $$ BEGIN
UPDATE app.audit_event SET action = 'x' WHERE id = '00000000-0000-7000-8000-0000000000ac';
RAISE NOTICE 'T13 FAIL: audit action changed';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T13 ok: audit fields immutable';
END $$;
-- 8. 删除项目(G10):抹审计内容 → 删项目;额度流水和审计记录保留
BEGIN;
UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now()
WHERE project_id = '00000000-0000-7000-8000-0000000000a3';
DELETE FROM project WHERE id = '00000000-0000-7000-8000-0000000000a3';
COMMIT;
SELECT 'T14 after delete — docs/items/findings/runs (expect 0/0/0/0): '
|| (SELECT count(*) FROM source_document WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM checklist_item WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM finding WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM check_run WHERE tenant_id = '00000000-0000-7000-8000-00000000000a');
SELECT 'T14 kept — quota entries (expect 3), audit rows scrubbed (expect 1): '
|| (SELECT count(*) FROM quota_entry WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || ', '
|| (SELECT count(*) FROM audit_event WHERE before IS NULL AND content_scrubbed_at IS NOT NULL);