- 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>
198 lines
13 KiB
PL/PgSQL
198 lines
13 KiB
PL/PgSQL
-- 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);
|