Files
geo/server/migrations/20260505102000_create_compliance_tables.up.sql
T
root 745cdd79cf feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web
content-safety pages, admin-web editor/publish gate integration, desktop publish
block surfacing, and supporting migrations / shared types / config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:14 +08:00

215 lines
11 KiB
SQL

ALTER TABLE article_versions
ADD COLUMN IF NOT EXISTS plaintext_snapshot TEXT,
ADD COLUMN IF NOT EXISTS manual_review_status VARCHAR(16) NOT NULL DEFAULT 'none',
ADD COLUMN IF NOT EXISTS manual_review_id BIGINT,
ADD COLUMN IF NOT EXISTS manual_review_updated_at TIMESTAMPTZ;
ALTER TABLE article_versions
DROP CONSTRAINT IF EXISTS ck_article_versions_manual_review_status;
ALTER TABLE article_versions
ADD CONSTRAINT ck_article_versions_manual_review_status
CHECK (manual_review_status IN ('none', 'pending', 'approved', 'rejected'));
COMMENT ON COLUMN article_versions.plaintext_snapshot IS
'Plaintext extracted at save time, used by compliance gate to skip diff reconstruction.';
COMMENT ON COLUMN article_versions.manual_review_status IS
'Hot-path manual review stamp: none|pending|approved|rejected.';
CREATE TABLE IF NOT EXISTS compliance_check_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT REFERENCES articles(id) ON DELETE SET NULL,
article_version_id BIGINT REFERENCES article_versions(id) ON DELETE SET NULL,
target_platforms TEXT[] NOT NULL DEFAULT '{}',
content_hash CHAR(64) NOT NULL,
policy_fingerprint VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL,
enforcement_mode VARCHAR(24) NOT NULL,
highest_level VARCHAR(16),
passed BOOLEAN NOT NULL,
hit_count INT NOT NULL DEFAULT 0,
stored_hit_count INT NOT NULL DEFAULT 0,
truncated BOOLEAN NOT NULL DEFAULT FALSE,
dict_versions JSONB NOT NULL DEFAULT '{}'::jsonb,
llm_used BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INT NOT NULL DEFAULT 0,
checked_by BIGINT,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
manual_review_status VARCHAR(16) NOT NULL DEFAULT 'none',
manual_review_id BIGINT,
CONSTRAINT ck_compliance_check_trigger_source CHECK (
trigger_source IN (
'editor_manual',
'publish_gate',
'auto_draft',
'api',
'scheduler_recheck',
'manual_review_bypass'
)
),
CONSTRAINT ck_compliance_check_mode CHECK (enforcement_mode IN ('mandatory', 'advisory', 'disabled')),
CONSTRAINT ck_compliance_check_highest_level CHECK (highest_level IS NULL OR highest_level IN ('block', 'high', 'medium', 'info')),
CONSTRAINT ck_compliance_check_manual_status CHECK (manual_review_status IN ('none', 'pending', 'approved', 'rejected'))
);
CREATE INDEX IF NOT EXISTS idx_compliance_check_records_article_checked
ON compliance_check_records (tenant_id, article_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_compliance_check_records_version_checked
ON compliance_check_records (article_version_id, checked_at DESC)
WHERE article_version_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS compliance_violations (
id BIGSERIAL PRIMARY KEY,
record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
platform_code TEXT,
source VARCHAR(24) NOT NULL,
dictionary_code VARCHAR(64),
dictionary_name VARCHAR(128),
term_pattern TEXT,
matched_text TEXT NOT NULL,
start_offset INT NOT NULL DEFAULT 0,
end_offset INT NOT NULL DEFAULT 0,
dom_path TEXT,
level VARCHAR(16) NOT NULL,
hint TEXT,
reference_law TEXT,
acknowledged_in_ack_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_compliance_violation_source CHECK (source IN ('dict', 'regex', 'llm')),
CONSTRAINT ck_compliance_violation_level CHECK (level IN ('block', 'high', 'medium', 'info')),
CONSTRAINT ck_compliance_violation_offsets CHECK (start_offset >= 0 AND end_offset >= start_offset),
CONSTRAINT ck_violations_matched_text_len CHECK (char_length(matched_text) <= 80)
);
CREATE INDEX IF NOT EXISTS idx_compliance_violations_record
ON compliance_violations (record_id, id);
CREATE INDEX IF NOT EXISTS idx_compliance_violations_tenant_level
ON compliance_violations (tenant_id, level, id DESC);
CREATE TABLE IF NOT EXISTS compliance_ack_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT NOT NULL REFERENCES articles(id),
article_version_id BIGINT NOT NULL REFERENCES article_versions(id),
check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
content_hash CHAR(64) NOT NULL,
policy_fingerprint VARCHAR(64) NOT NULL,
acknowledged_by BIGINT NOT NULL REFERENCES users(id),
ack_violation_ids BIGINT[] NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
consumed_by_job_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ack_lookup
ON compliance_ack_records (tenant_id, article_id, article_version_id, check_record_id)
WHERE consumed_at IS NULL;
ALTER TABLE compliance_violations
DROP CONSTRAINT IF EXISTS fk_compliance_violations_ack;
ALTER TABLE compliance_violations
ADD CONSTRAINT fk_compliance_violations_ack
FOREIGN KEY (acknowledged_in_ack_id)
REFERENCES compliance_ack_records(id)
ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS compliance_review_jobs (
id BIGSERIAL PRIMARY KEY,
message_id UUID NOT NULL,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT NOT NULL REFERENCES articles(id),
article_version_id BIGINT NOT NULL REFERENCES article_versions(id),
check_record_id BIGINT NOT NULL REFERENCES compliance_check_records(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_compliance_review_jobs_record UNIQUE (check_record_id),
CONSTRAINT uk_compliance_review_jobs_message UNIQUE (message_id),
CONSTRAINT ck_compliance_review_job_status CHECK (status IN ('pending', 'running', 'done', 'failed'))
);
CREATE INDEX IF NOT EXISTS idx_compliance_review_jobs_pending
ON compliance_review_jobs (available_at, id)
WHERE status = 'pending';
CREATE TABLE IF NOT EXISTS compliance_manual_reviews (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT NOT NULL REFERENCES articles(id),
article_version_id BIGINT NOT NULL REFERENCES article_versions(id),
original_check_record_id BIGINT REFERENCES compliance_check_records(id) ON DELETE SET NULL,
original_content_hash CHAR(64) NOT NULL,
original_policy_fingerprint VARCHAR(64) NOT NULL,
original_target_platforms TEXT[] NOT NULL DEFAULT '{}',
original_violation_summary JSONB NOT NULL DEFAULT '[]'::jsonb,
requested_by BIGINT NOT NULL REFERENCES users(id),
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
request_note TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
cancelled_by BIGINT REFERENCES users(id),
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
reviewed_by BIGINT,
reviewed_at TIMESTAMPTZ,
decision_reason TEXT,
CONSTRAINT ck_manual_review_request_note_len CHECK (request_note IS NULL OR char_length(request_note) <= 500),
CONSTRAINT ck_manual_review_cancel_reason_len CHECK (cancel_reason IS NULL OR char_length(cancel_reason) <= 500),
CONSTRAINT ck_manual_review_decision_reason_len CHECK (decision_reason IS NULL OR char_length(decision_reason) <= 1000),
CONSTRAINT ck_manual_review_status CHECK (status IN ('pending', 'approved', 'rejected', 'cancelled')),
CONSTRAINT ck_manual_review_decision CHECK (
(status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR
(status IN ('approved','rejected') AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND decision_reason IS NOT NULL
AND cancelled_by IS NULL AND cancelled_at IS NULL AND cancel_reason IS NULL) OR
(status = 'cancelled' AND reviewed_by IS NULL AND reviewed_at IS NULL AND decision_reason IS NULL
AND cancelled_by IS NOT NULL AND cancelled_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_manual_review_active_per_version
ON compliance_manual_reviews (article_version_id)
WHERE status IN ('pending', 'approved', 'rejected');
CREATE INDEX IF NOT EXISTS idx_manual_review_pending_queue
ON compliance_manual_reviews (requested_at)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_manual_review_lookup_by_version
ON compliance_manual_reviews (article_version_id, status);
CREATE INDEX IF NOT EXISTS idx_manual_review_tenant_status
ON compliance_manual_reviews (tenant_id, status, requested_at DESC);
ALTER TABLE article_versions
DROP CONSTRAINT IF EXISTS fk_article_versions_manual_review;
ALTER TABLE article_versions
ADD CONSTRAINT fk_article_versions_manual_review
FOREIGN KEY (manual_review_id)
REFERENCES compliance_manual_reviews(id)
ON DELETE SET NULL;
ALTER TABLE desktop_publish_jobs
ADD COLUMN IF NOT EXISTS article_id BIGINT REFERENCES articles(id),
ADD COLUMN IF NOT EXISTS article_version_id BIGINT REFERENCES article_versions(id),
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'queued',
ADD COLUMN IF NOT EXISTS compliance_blocked_record_id BIGINT REFERENCES compliance_check_records(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS compliance_blocked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS compliance_blocked_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_desktop_publish_jobs_status_scheduled
ON desktop_publish_jobs (status, scheduled_at, created_at)
WHERE status IN ('queued', 'blocked_by_compliance');