124 lines
5.3 KiB
PL/PgSQL
124 lines
5.3 KiB
PL/PgSQL
-- 反馈只保存登录账号明确提交的纯文本,不采集 IP、UA、附件、凭据或额外邮箱。
|
|
CREATE TABLE feedback_submissions (
|
|
id UUID PRIMARY KEY,
|
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT,
|
|
request_id TEXT NOT NULL,
|
|
category TEXT NOT NULL
|
|
CHECK (category IN ('bug', 'feature', 'docs', 'compatibility', 'other')),
|
|
platform TEXT NOT NULL
|
|
CHECK (platform IN ('windows', 'linux', 'android', 'macos', 'ios', 'cloud', 'agent', 'other')),
|
|
app_version TEXT,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'new'
|
|
CHECK (status IN ('new', 'triaged', 'in_progress', 'resolved', 'closed')),
|
|
version BIGINT NOT NULL DEFAULT 1 CHECK (version >= 1),
|
|
redacted_at TIMESTAMPTZ,
|
|
redacted_by UUID REFERENCES accounts(id) ON DELETE RESTRICT,
|
|
redaction_reason TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT feedback_request_id_format CHECK (
|
|
char_length(request_id) BETWEEN 1 AND 128
|
|
AND request_id ~ '^[A-Za-z0-9._:-]+$'
|
|
),
|
|
CONSTRAINT feedback_title_format CHECK (
|
|
char_length(title) BETWEEN 5 AND 120
|
|
AND title = btrim(title)
|
|
AND title !~ '[[:cntrl:]]'
|
|
),
|
|
CONSTRAINT feedback_description_format CHECK (
|
|
char_length(description) BETWEEN 20 AND 4000
|
|
AND description = btrim(description)
|
|
AND regexp_replace(description, E'[\\n\\r\\t]', '', 'g') !~ '[[:cntrl:]]'
|
|
),
|
|
CONSTRAINT feedback_app_version_format CHECK (
|
|
app_version IS NULL OR (
|
|
char_length(app_version) BETWEEN 1 AND 32
|
|
AND app_version = btrim(app_version)
|
|
AND app_version ~ '^[A-Za-z0-9._+-]+$'
|
|
)
|
|
),
|
|
CONSTRAINT feedback_redaction_pair CHECK (
|
|
(redacted_at IS NULL AND redacted_by IS NULL AND redaction_reason IS NULL)
|
|
OR (
|
|
redacted_at IS NOT NULL
|
|
AND redacted_by IS NOT NULL
|
|
AND redaction_reason IS NOT NULL
|
|
AND char_length(redaction_reason) BETWEEN 5 AND 500
|
|
AND redaction_reason = btrim(redaction_reason)
|
|
AND redaction_reason !~ '[[:cntrl:]]'
|
|
AND status = 'closed'
|
|
)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX feedback_submissions_account_created_idx
|
|
ON feedback_submissions (account_id, created_at DESC, id DESC);
|
|
|
|
CREATE INDEX feedback_submissions_status_created_idx
|
|
ON feedback_submissions (status, created_at DESC, id DESC);
|
|
|
|
CREATE INDEX feedback_submissions_admin_created_idx
|
|
ON feedback_submissions (created_at DESC, id DESC);
|
|
|
|
-- 数据库层拒绝物理删除、越权改归属以及绕过状态机或乐观锁的写入。
|
|
CREATE FUNCTION guard_feedback_submission_mutation() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
RAISE EXCEPTION 'feedback submissions must be retained';
|
|
END IF;
|
|
|
|
IF NEW.account_id IS DISTINCT FROM OLD.account_id
|
|
OR NEW.request_id IS DISTINCT FROM OLD.request_id
|
|
OR NEW.category IS DISTINCT FROM OLD.category
|
|
OR NEW.platform IS DISTINCT FROM OLD.platform
|
|
OR NEW.app_version IS DISTINCT FROM OLD.app_version
|
|
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
|
RAISE EXCEPTION 'feedback submission identity is immutable';
|
|
END IF;
|
|
|
|
IF NEW.version <> OLD.version + 1 THEN
|
|
RAISE EXCEPTION 'feedback version must advance exactly once';
|
|
END IF;
|
|
|
|
IF OLD.status = 'new' AND NEW.status NOT IN ('triaged', 'closed') THEN
|
|
RAISE EXCEPTION 'invalid feedback status transition';
|
|
ELSIF OLD.status = 'triaged' AND NEW.status NOT IN ('in_progress', 'closed') THEN
|
|
RAISE EXCEPTION 'invalid feedback status transition';
|
|
ELSIF OLD.status = 'in_progress' AND NEW.status NOT IN ('resolved', 'closed') THEN
|
|
RAISE EXCEPTION 'invalid feedback status transition';
|
|
ELSIF OLD.status = 'resolved' AND NEW.status NOT IN ('in_progress', 'closed') THEN
|
|
RAISE EXCEPTION 'invalid feedback status transition';
|
|
ELSIF OLD.status = 'closed' AND NEW.status <> 'closed' THEN
|
|
RAISE EXCEPTION 'closed feedback is terminal';
|
|
END IF;
|
|
|
|
IF OLD.redacted_at IS NULL AND NEW.redacted_at IS NOT NULL THEN
|
|
IF NEW.redacted_by IS NULL
|
|
OR NEW.redaction_reason IS NULL
|
|
OR NEW.status <> 'closed'
|
|
OR NEW.title <> '[已由管理员安全脱敏]'
|
|
OR NEW.description <> '[反馈正文已由管理员执行不可逆安全脱敏]' THEN
|
|
RAISE EXCEPTION 'feedback text can only be irreversibly redacted';
|
|
END IF;
|
|
ELSIF NEW.title IS DISTINCT FROM OLD.title OR NEW.description IS DISTINCT FROM OLD.description THEN
|
|
RAISE EXCEPTION 'feedback text can only be irreversibly redacted';
|
|
END IF;
|
|
|
|
IF OLD.redacted_at IS NOT NULL AND (
|
|
NEW.redacted_at IS DISTINCT FROM OLD.redacted_at
|
|
OR NEW.redacted_by IS DISTINCT FROM OLD.redacted_by
|
|
OR NEW.redaction_reason IS DISTINCT FROM OLD.redaction_reason
|
|
) THEN
|
|
RAISE EXCEPTION 'feedback redaction is irreversible';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER feedback_submissions_mutation_guard
|
|
BEFORE UPDATE OR DELETE ON feedback_submissions
|
|
FOR EACH ROW EXECUTE FUNCTION guard_feedback_submission_mutation();
|