-- ============================================================================ -- 举报与反馈系统 (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));