topfans/backend/migrations/2026_06_11_011_moderation_tables.sql
2026-06-22 17:19:48 +08:00

362 lines
18 KiB
SQL
Raw 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.

-- ============================================================================
-- 举报与反馈系统 (spec: docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md v2.5)
-- 9 张表 + 索引 + 种子数据 + 9 个序列同步
-- 创建时间: 2026-06-11
-- 文件编号: 011接续 2026_06_08_010_statistic_partitions_initial.sql
-- 命名约定: 所有 *_at 字段均为 Unix 毫秒 (BIGINT),与 Go time.Now().UnixMilli() 一致
-- 序列规范CLAUDE.md 强制 — 表创建后再 CREATE SEQUENCE OWNED BY避免 forward reference
-- ============================================================================
-- ============================================================================
-- 1. report_categories (举报分类字典)
-- ============================================================================
CREATE TABLE report_categories (
id BIGINT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
severity SMALLINT NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT chk_report_categories_severity CHECK (severity BETWEEN 1 AND 5)
);
COMMENT ON TABLE report_categories IS '举报分类字典动态配置enabled=FALSE 不下发客户端)';
CREATE SEQUENCE report_categories_id_seq START WITH 10000 OWNED BY report_categories.id;
ALTER TABLE report_categories ALTER COLUMN id SET DEFAULT nextval('report_categories_id_seq');
CREATE INDEX idx_report_categories_enabled ON report_categories(enabled, sort_order);
INSERT INTO report_categories (code, name, description, severity, sort_order, created_at, updated_at) VALUES
('pornographic', '色情低俗', '含裸露、性暗示内容', 5, 1, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('violence', '暴力血腥', '含暴力、血腥画面', 5, 2, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('infringing', '侵权盗版', '侵犯他人著作权/商标权', 4, 3, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('false_info', '虚假信息', '虚假、欺骗性内容', 3, 4, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('political', '政治敏感', '涉及政治敏感话题', 5, 5, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('ad_spam', '广告骚扰', '垃圾广告、骚扰信息', 2, 6, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('other', '其他', '其他违规情况', 1, 99, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000);
SELECT setval('report_categories_id_seq', (SELECT MAX(id) FROM report_categories));
-- ============================================================================
-- 2. feedback_categories (反馈分类字典)
-- ============================================================================
CREATE TABLE feedback_categories (
id BIGINT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
COMMENT ON TABLE feedback_categories IS '反馈分类字典';
CREATE SEQUENCE feedback_categories_id_seq START WITH 10000 OWNED BY feedback_categories.id;
ALTER TABLE feedback_categories ALTER COLUMN id SET DEFAULT nextval('feedback_categories_id_seq');
INSERT INTO feedback_categories (code, name, description, sort_order, created_at, updated_at) VALUES
('bug', 'BUG 报告', '使用中遇到的问题', 1, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('consult', '使用咨询', '不知道怎么用', 2, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('business', '内容合作', '合作/商务联系', 3, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('suggestion', '功能建议', '希望增加什么功能', 4, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000);
SELECT setval('feedback_categories_id_seq', (SELECT MAX(id) FROM feedback_categories));
-- ============================================================================
-- 3. reports (举报工单主表)
-- ============================================================================
CREATE TABLE reports (
id BIGINT PRIMARY KEY,
reporter_id BIGINT NOT NULL,
star_id BIGINT,
target_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
target_snapshot JSONB NOT NULL,
triggered_auto_hide BOOLEAN NOT NULL DEFAULT FALSE,
category_code VARCHAR(50) NOT NULL,
description VARCHAR(500),
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
is_auto_hidden BOOLEAN NOT NULL DEFAULT FALSE,
claimed_by BIGINT,
claimed_at BIGINT,
resolved_action VARCHAR(20),
resolved_by BIGINT,
resolved_at BIGINT,
resolution_note VARCHAR(500),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT fk_reports_category FOREIGN KEY (category_code)
REFERENCES report_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT chk_reports_status
CHECK (status IN ('pending','reviewing','auto_hidden','resolved','dismissed','withdrawn','archived')),
CONSTRAINT chk_reports_target_type
CHECK (target_type IN ('asset','user_profile')),
CONSTRAINT chk_reports_resolved_action
CHECK (resolved_action IS NULL OR resolved_action IN ('takedown','ban','warn','dismiss','restore')),
CONSTRAINT chk_reports_claimed_pair
CHECK (
(status = 'reviewing' AND claimed_by IS NOT NULL AND claimed_at IS NOT NULL)
OR (status <> 'reviewing' AND claimed_by IS NULL AND claimed_at IS NULL)
),
CONSTRAINT chk_reports_resolved_fields
CHECK (
(status IN ('resolved','dismissed')
AND resolved_action IS NOT NULL
AND resolved_by IS NOT NULL
AND resolved_at IS NOT NULL)
OR
(status IN ('pending','reviewing','auto_hidden','withdrawn','archived')
AND resolved_action IS NULL
AND resolved_by IS NULL
AND resolved_at IS NULL)
)
);
COMMENT ON TABLE reports IS '举报工单主表7 状态pending/reviewing/auto_hidden/resolved/dismissed/withdrawn/archived';
CREATE SEQUENCE reports_id_seq START WITH 10000 OWNED BY reports.id;
ALTER TABLE reports ALTER COLUMN id SET DEFAULT nextval('reports_id_seq');
CREATE INDEX idx_reports_status_created ON reports(status, created_at DESC);
CREATE INDEX idx_reports_target ON reports(target_type, target_id, status);
CREATE INDEX idx_reports_reporter ON reports(reporter_id, created_at DESC);
CREATE INDEX idx_reports_category ON reports(category_code, status, created_at DESC);
CREATE INDEX idx_reports_star ON reports(star_id, status, created_at DESC)
WHERE star_id IS NOT NULL;
CREATE INDEX idx_reports_triggered_autohide ON reports(target_type, target_id, created_at DESC)
WHERE triggered_auto_hide = TRUE;
CREATE UNIQUE INDEX uk_reports_reporter_target_pending
ON reports(reporter_id, target_type, target_id)
WHERE status IN ('pending', 'reviewing', 'auto_hidden');
SELECT setval('reports_id_seq', (SELECT MAX(id) FROM reports));
-- ============================================================================
-- 4. report_evidence (举报证据图)
-- ============================================================================
CREATE TABLE report_evidence (
id BIGINT PRIMARY KEY,
report_id BIGINT NOT NULL,
oss_key VARCHAR(255) NOT NULL,
oss_url VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
CONSTRAINT fk_report_evidence_report
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
);
CREATE SEQUENCE report_evidence_id_seq START WITH 10000 OWNED BY report_evidence.id;
ALTER TABLE report_evidence ALTER COLUMN id SET DEFAULT nextval('report_evidence_id_seq');
CREATE INDEX idx_report_evidence_report ON report_evidence(report_id, sort_order);
SELECT setval('report_evidence_id_seq', (SELECT MAX(id) FROM report_evidence));
-- ============================================================================
-- 5. feedbacks (反馈工单)
-- ============================================================================
CREATE TABLE feedbacks (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT,
category_code VARCHAR(50) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(320),
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
claimed_by BIGINT,
claimed_at BIGINT,
replied_by BIGINT,
replied_at BIGINT,
reply_content TEXT,
closed_by BIGINT,
closed_at BIGINT,
archived_by BIGINT,
archived_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT fk_feedbacks_category FOREIGN KEY (category_code)
REFERENCES feedback_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT chk_feedbacks_status
CHECK (status IN ('pending','reviewing','replied','closed','archived')),
CONSTRAINT chk_feedbacks_claimed_pair
CHECK (
(status = 'reviewing' AND claimed_by IS NOT NULL AND claimed_at IS NOT NULL)
OR (status <> 'reviewing' AND claimed_by IS NULL AND claimed_at IS NULL)
),
CONSTRAINT chk_feedbacks_replied_fields
CHECK (
(status = 'replied'
AND replied_by IS NOT NULL
AND replied_at IS NOT NULL
AND reply_content IS NOT NULL)
OR
(status <> 'replied'
AND replied_by IS NULL
AND replied_at IS NULL
AND reply_content IS NULL)
),
CONSTRAINT chk_feedbacks_closed_fields
CHECK (
(status = 'closed' AND closed_by IS NOT NULL AND closed_at IS NOT NULL)
OR (status <> 'closed' AND closed_by IS NULL AND closed_at IS NULL)
),
CONSTRAINT chk_feedbacks_archived_fields
CHECK (
(status = 'archived' AND archived_by IS NOT NULL AND archived_at IS NOT NULL)
OR (status <> 'archived' AND archived_by IS NULL AND archived_at IS NULL)
)
);
CREATE SEQUENCE feedbacks_id_seq START WITH 10000 OWNED BY feedbacks.id;
ALTER TABLE feedbacks ALTER COLUMN id SET DEFAULT nextval('feedbacks_id_seq');
CREATE INDEX idx_feedbacks_status_created ON feedbacks(status, created_at DESC);
CREATE INDEX idx_feedbacks_user ON feedbacks(user_id, created_at DESC);
CREATE INDEX idx_feedbacks_category ON feedbacks(category_code, status);
CREATE INDEX idx_feedbacks_claimed_by ON feedbacks(claimed_by, status, created_at DESC)
WHERE claimed_by IS NOT NULL;
CREATE INDEX idx_feedbacks_star ON feedbacks(star_id, status, created_at DESC)
WHERE star_id IS NOT NULL;
SELECT setval('feedbacks_id_seq', (SELECT MAX(id) FROM feedbacks));
-- ============================================================================
-- 6. feedback_evidence (反馈截图)
-- ============================================================================
CREATE TABLE feedback_evidence (
id BIGINT PRIMARY KEY,
feedback_id BIGINT NOT NULL,
oss_key VARCHAR(255) NOT NULL,
oss_url VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
CONSTRAINT fk_feedback_evidence_feedback
FOREIGN KEY (feedback_id) REFERENCES feedbacks(id) ON DELETE CASCADE
);
CREATE SEQUENCE feedback_evidence_id_seq START WITH 10000 OWNED BY feedback_evidence.id;
ALTER TABLE feedback_evidence ALTER COLUMN id SET DEFAULT nextval('feedback_evidence_id_seq');
CREATE INDEX idx_feedback_evidence_feedback ON feedback_evidence(feedback_id, sort_order);
SELECT setval('feedback_evidence_id_seq', (SELECT MAX(id) FROM feedback_evidence));
-- ============================================================================
-- 7. moderation_actions (审核动作流水)
-- ============================================================================
CREATE TABLE moderation_actions (
id BIGINT PRIMARY KEY,
report_id BIGINT,
feedback_id BIGINT,
admin_id BIGINT NOT NULL,
action_type VARCHAR(30) NOT NULL,
target_type VARCHAR(30),
target_id BIGINT,
note VARCHAR(500),
success BOOLEAN NOT NULL,
error_message VARCHAR(500),
created_at BIGINT NOT NULL,
CONSTRAINT chk_moderation_actions_xor
CHECK (
(report_id IS NOT NULL) <> (feedback_id IS NOT NULL)
OR (report_id IS NULL AND feedback_id IS NULL)
),
CONSTRAINT chk_moderation_actions_admin_id
CHECK (admin_id >= 0),
CONSTRAINT chk_moderation_actions_action_type
CHECK (action_type IN (
'takedown','restore','ban','warn','dismiss','reply','close','archive',
'autohide_noop','withdraw','force_release'
)),
CONSTRAINT chk_moderation_actions_target_pair
CHECK (
(target_type IS NULL AND target_id IS NULL)
OR (target_type IS NOT NULL AND target_id IS NOT NULL
AND target_type IN ('asset','user_profile'))
),
CONSTRAINT chk_moderation_actions_success_msg
CHECK ((success = TRUE AND error_message IS NULL) OR (success = FALSE AND error_message IS NOT NULL))
);
COMMENT ON TABLE moderation_actions IS '审核动作流水(永久保留,审计追溯)';
CREATE SEQUENCE moderation_actions_id_seq START WITH 10000 OWNED BY moderation_actions.id;
ALTER TABLE moderation_actions ALTER COLUMN id SET DEFAULT nextval('moderation_actions_id_seq');
CREATE INDEX idx_moderation_actions_report ON moderation_actions(report_id, created_at DESC);
CREATE INDEX idx_moderation_actions_feedback ON moderation_actions(feedback_id, created_at DESC);
CREATE INDEX idx_moderation_actions_admin ON moderation_actions(admin_id, created_at DESC);
CREATE INDEX idx_moderation_actions_target
ON moderation_actions(target_type, target_id, created_at DESC)
WHERE target_type IS NOT NULL;
SELECT setval('moderation_actions_id_seq', (SELECT MAX(id) FROM moderation_actions));
-- ============================================================================
-- 8. moderation_target_status (对象受管控状态)
-- ============================================================================
CREATE TABLE moderation_target_status (
id BIGINT PRIMARY KEY,
target_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
is_warned BOOLEAN NOT NULL DEFAULT FALSE,
warn_count INT NOT NULL DEFAULT 0,
last_warned_at BIGINT,
last_action_type VARCHAR(30) NOT NULL,
reason VARCHAR(200),
source VARCHAR(30) NOT NULL,
source_report_id BIGINT,
operator_admin_id BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_moderation_target UNIQUE (target_type, target_id),
CONSTRAINT chk_mts_target_type
CHECK (target_type IN ('asset','user_profile')),
CONSTRAINT chk_mts_source
CHECK (source IN ('auto','admin')),
CONSTRAINT chk_mts_source_report_id
CHECK (source <> 'auto' OR source_report_id IS NOT NULL),
CONSTRAINT chk_mts_last_action_type
CHECK (last_action_type IN ('takedown','ban','warn','restore','autohide','dismiss','warn_cleared')),
CONSTRAINT chk_mts_warn_fields
CHECK (
(target_type = 'user_profile'
AND (
(is_warned = FALSE AND warn_count = 0 AND last_warned_at IS NULL)
OR (is_warned = TRUE AND warn_count >= 1 AND last_warned_at IS NOT NULL)
OR (is_warned = FALSE AND warn_count >= 1 AND last_warned_at IS NOT NULL)
))
OR (target_type = 'asset'
AND is_warned = FALSE AND warn_count = 0 AND last_warned_at IS NULL)
)
);
COMMENT ON TABLE moderation_target_status IS '对象受管控状态(审计 + 警告;下架/封禁由业务表软删除承担)';
CREATE SEQUENCE moderation_target_status_id_seq START WITH 10000 OWNED BY moderation_target_status.id;
ALTER TABLE moderation_target_status ALTER COLUMN id SET DEFAULT nextval('moderation_target_status_id_seq');
CREATE INDEX idx_mts_warned_user ON moderation_target_status(target_id)
WHERE is_warned = TRUE AND target_type = 'user_profile';
CREATE INDEX idx_mts_source_report ON moderation_target_status(source_report_id, target_type, target_id)
WHERE source_report_id IS NOT NULL;
SELECT setval('moderation_target_status_id_seq', (SELECT MAX(id) FROM moderation_target_status));
-- ============================================================================
-- 9. admin_audit_logs (管理员操作日志)
-- ============================================================================
CREATE TABLE admin_audit_logs (
id BIGINT PRIMARY KEY,
admin_id BIGINT NOT NULL,
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(30),
resource_id BIGINT,
ip VARCHAR(500),
user_agent VARCHAR(500),
extra JSONB,
created_at BIGINT NOT NULL,
CONSTRAINT chk_admin_audit_logs_resource_pair
CHECK (
(resource_type IS NULL AND resource_id IS NULL)
OR (resource_type IS NOT NULL AND resource_id IS NOT NULL)
)
);
CREATE SEQUENCE admin_audit_logs_id_seq START WITH 10000 OWNED BY admin_audit_logs.id;
ALTER TABLE admin_audit_logs ALTER COLUMN id SET DEFAULT nextval('admin_audit_logs_id_seq');
CREATE INDEX idx_admin_audit_logs_admin ON admin_audit_logs(admin_id, created_at DESC);
CREATE INDEX idx_admin_audit_logs_resource
ON admin_audit_logs(resource_type, resource_id, created_at DESC)
WHERE resource_id IS NOT NULL;
SELECT setval('admin_audit_logs_id_seq', (SELECT MAX(id) FROM admin_audit_logs));