# Plan A: 数据库迁移 + Go moderationService 微服务 + Gateway 集成 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **父计划:** `docs/superpowers/plans/2026-06-17-moderation-report-feedback-system.md` **Goal:** 完成 topfans 主仓的举报反馈系统数据层 + 微服务层 + Gateway 路由层,端到端可提交举报/反馈 **Architecture:** - 共享 PostgreSQL `top-fans` 库 9 张表 - 独立 Go Dubbo 微服务 `moderationService`(端口 20011)承载客户端 API + 自动隐藏阈值触发 - Gateway 注册 `/api/v1/moderation/*`(仅客户端路由) - Redis 计数 + Lua 自动隐藏 - 复用 socialService / notificationService 的分层模式 **Tech Stack:** Go 1.21+ / Dubbo-go / GORM / PostgreSQL 16 / Redis 7 **仓库:** topfans 主仓 (`/Users/liulujian/Documents/code/TopFansByGithub`) **依赖前置:** - ✅ 已有 `socialService`(端口 20002)作为分层参考 - ✅ 已有 `notificationService`(端口 20008)作为客户端调用参考 - ✅ 已有 `aiChatService`(端口 20008)作为 Redis 客户端参考 - ✅ 已有 `gateway`(端口 8080)作为 HTTP 路由参考 - ⚠️ 端口 20011 已分配给 moderationService(接续 aiChatService 20008 + 3) --- ## 阶段 A.1:数据库 Schema ### Task A.1.1: 创建迁移文件骨架 **Files:** - Create: `backend/migrations/2026_06_11_011_moderation_tables.sql` - [ ] **Step 1: 创建文件 + 头部注释** ```bash touch backend/migrations/2026_06_11_011_moderation_tables.sql ``` 文件顶部加注释: ```sql -- ============================================================================ -- 举报与反馈系统 (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() 一致 -- ============================================================================ ``` - [ ] **Step 2: 写 report_categories 表** ```sql -- 1. report_categories (举报分类字典) CREATE SEQUENCE report_categories_id_seq START WITH 10000 OWNED BY report_categories.id; CREATE TABLE report_categories ( id BIGSERIAL 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 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)); ``` - [ ] **Step 3: 写 feedback_categories 表** ```sql -- 2. feedback_categories (反馈分类字典) CREATE SEQUENCE feedback_categories_id_seq START WITH 10000 OWNED BY feedback_categories.id; CREATE TABLE feedback_categories ( id BIGSERIAL 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 '反馈分类字典'; 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)); ``` - [ ] **Step 4: 写 reports 表(最复杂)** 完整 SQL 按 spec §3.4,重点约束: - `chk_reports_status` 含 `'withdrawn'` / `'archived'`(v2.4 V2) - `chk_reports_target_type` 限 `'asset'|'user_profile'` - `chk_reports_claimed_pair` —— reviewing 状态必 claimed_by/claimed_at 非空;其余状态必 NULL - `chk_reports_resolved_fields` —— resolved/dismissed 状态必 resolved_action/resolved_by/resolved_at 非空 - 关键索引: - `idx_reports_status_created (status, created_at DESC)` - `idx_reports_triggered_autohide (target_type, target_id, created_at DESC) WHERE triggered_auto_hide=TRUE` - `uk_reports_reporter_target_pending` 唯一索引(同 reporter+target pending 只能一条) 注意:`reports.target_owner_uid_at_submit` 列**不创建**(v2.4 V1 删除死列),用 `target_snapshot.owner_uid` 替代。 ```sql CREATE SEQUENCE reports_id_seq START WITH 10000 OWNED BY reports.id; CREATE TABLE reports ( id BIGSERIAL 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 '举报工单主表(5+2 状态:pending/reviewing/auto_hidden/resolved/dismissed/withdrawn/archived)'; 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)); ``` - [ ] **Step 5: 写 report_evidence 表** ```sql -- 4. report_evidence (举报证据图) CREATE SEQUENCE report_evidence_id_seq START WITH 10000 OWNED BY report_evidence.id; CREATE TABLE report_evidence ( id BIGSERIAL 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 INDEX idx_report_evidence_report ON report_evidence(report_id, sort_order); SELECT setval('report_evidence_id_seq', (SELECT MAX(id) FROM report_evidence)); ``` - [ ] **Step 6: 写 feedbacks 表** 按 spec §3.6,重点约束: - `chk_feedbacks_status` 限 `'pending'|'reviewing'|'replied'|'closed'|'archived'` - 4 个终态 CHECK:`chk_feedbacks_replied_fields` / `chk_feedbacks_closed_fields` / `chk_feedbacks_archived_fields` - `chk_feedbacks_claimed_pair`(与 reports 对齐) - `contact VARCHAR(320)`(v1.6 L1 修正) - 关键索引:`idx_feedbacks_claimed_by (claimed_by, status, created_at DESC) WHERE claimed_by IS NOT NULL` ```sql CREATE SEQUENCE feedbacks_id_seq START WITH 10000 OWNED BY feedbacks.id; CREATE TABLE feedbacks ( id BIGSERIAL 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 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)); ``` - [ ] **Step 7: 写 feedback_evidence 表** ```sql -- 6. feedback_evidence (反馈截图) CREATE SEQUENCE feedback_evidence_id_seq START WITH 10000 OWNED BY feedback_evidence.id; CREATE TABLE feedback_evidence ( id BIGSERIAL 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 INDEX idx_feedback_evidence_feedback ON feedback_evidence(feedback_id, sort_order); SELECT setval('feedback_evidence_id_seq', (SELECT MAX(id) FROM feedback_evidence)); ``` - [ ] **Step 8: 写 moderation_actions 表** 按 spec §3.8,重点约束: - `chk_moderation_actions_xor` 改为 OR:`(report_id IS NOT NULL) <> (feedback_id IS NOT NULL) OR (report_id IS NULL AND feedback_id IS NULL)`(v2.2 B3 — 允许 warn_cleared 无 ticket 关联) - `chk_moderation_actions_admin_id (admin_id >= 0)`(v2.2 B4 — 0 是系统自动 sentinel) - `chk_moderation_actions_action_type` 含 `'autohide_noop'` / `'withdraw'` / `'force_release'`(v2.4 V3) - `chk_moderation_actions_target_pair` - `chk_moderation_actions_success_msg` ```sql CREATE SEQUENCE moderation_actions_id_seq START WITH 10000 OWNED BY moderation_actions.id; CREATE TABLE moderation_actions ( id BIGSERIAL 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 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)); ``` - [ ] **Step 9: 写 moderation_target_status 表** 按 spec §3.9,重点约束: - `chk_mts_target_type` 限 `'asset'|'user_profile'` - `chk_mts_source (source IN ('auto','admin'))` - `chk_mts_source_report_id (source='auto' → source_report_id NOT NULL)` - `chk_mts_last_action_type` 含 `'warn_cleared'`(v2.4 V4) - `chk_mts_warn_fields` 第 3 子句支持"历史警告已清除"(v1.7 H2 / v2.5 H4) - `uk_moderation_target UNIQUE (target_type, target_id)` ```sql CREATE SEQUENCE moderation_target_status_id_seq START WITH 10000 OWNED BY moderation_target_status.id; CREATE TABLE moderation_target_status ( id BIGSERIAL 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 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)); ``` - [ ] **Step 10: 写 admin_audit_logs 表** 按 spec §3.10,重点: - `ip VARCHAR(500)`(v1.5 M1 兼容 IPv6 多级代理链) - `chk_admin_audit_logs_resource_pair` 成对约束(v1.4 M7) ```sql CREATE SEQUENCE admin_audit_logs_id_seq START WITH 10000 OWNED BY admin_audit_logs.id; CREATE TABLE admin_audit_logs ( id BIGSERIAL 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 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)); ``` - [ ] **Step 11: 验证 SQL 语法** ```bash # 用 docker 起临时 PG 16 docker run -d --name pg-mod-verify -e POSTGRES_PASSWORD=test -p 15433:5432 postgres:16 sleep 3 PGPASSWORD=test psql -h localhost -p 15433 -U postgres -d postgres -f backend/migrations/2026_06_11_011_moderation_tables.sql 2>&1 | tee /tmp/migration.log # 期望:所有 CREATE/INSERT/setval 都成功,无 CHECK 违例,无 NOT NULL 错误 docker rm -f pg-mod-verify ``` - [ ] **Step 12: 验证 9 个序列都同步** ```bash docker run -d --name pg-mod-check -e POSTGRES_PASSWORD=test -p 15434:5432 postgres:16 sleep 3 PGPASSWORD=test psql -h localhost -p 15434 -U postgres -d postgres -f backend/migrations/2026_06_11_011_moderation_tables.sql PGPASSWORD=test psql -h localhost -p 15434 -U postgres -d postgres -c " SELECT sequencename, last_value, CASE WHEN sequencename LIKE 'report_categories%' THEN (SELECT MAX(id) FROM report_categories) WHEN sequencename LIKE 'feedback_categories%' THEN (SELECT MAX(id) FROM feedback_categories) WHEN sequencename LIKE 'reports%' THEN (SELECT MAX(id) FROM reports) WHEN sequencename LIKE 'report_evidence%' THEN (SELECT MAX(id) FROM report_evidence) WHEN sequencename LIKE 'feedbacks%' THEN (SELECT MAX(id) FROM feedbacks) WHEN sequencename LIKE 'feedback_evidence%' THEN (SELECT MAX(id) FROM feedback_evidence) WHEN sequencename LIKE 'moderation_actions%' THEN (SELECT MAX(id) FROM moderation_actions) WHEN sequencename LIKE 'moderation_target_status%' THEN (SELECT MAX(id) FROM moderation_target_status) WHEN sequencename LIKE 'admin_audit_logs%' THEN (SELECT MAX(id) FROM admin_audit_logs) END AS table_max_id FROM pg_sequences WHERE sequencename IN ( 'report_categories_id_seq','feedback_categories_id_seq','reports_id_seq', 'report_evidence_id_seq','feedbacks_id_seq','feedback_evidence_id_seq', 'moderation_actions_id_seq','moderation_target_status_id_seq','admin_audit_logs_id_seq' ) ORDER BY sequencename; " # 期望:所有 last_value >= table_max_id docker rm -f pg-mod-check ``` --- ### Task A.1.2: 业务表软删除字段确认(无须改) - [ ] **Step 1: 验证 assets 表已有 is_active + deleted_at** ```sql SELECT column_name FROM information_schema.columns WHERE table_name = 'assets' AND column_name IN ('is_active','deleted_at'); -- 期望:2 行 ``` - [ ] **Step 2: 验证 users 表已有 is_active + deleted_at** ```sql SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND column_name IN ('is_active','deleted_at'); -- 期望:2 行 ``` **如果不存在**:跳过(spec §6 阶段 6 说明"业务表读路径无须改 SQL",所以字段必须已存在;如果不存在则本计划阻塞,需先建字段迁移) --- ## 阶段 A.2:proto 定义 + Go 模型 ### Task A.2.1: 创建 proto 文件 **Files:** - Create: `backend/proto/moderation.proto` - [ ] **Step 1: 写 proto 定义** 参考已有 `backend/proto/social.proto` 模板,写: ```proto syntax = "proto3"; package github.com.topfans.backend.pkg.proto.moderation; option go_package = "github.com/topfans/backend/pkg/proto/moderation;moderation"; service ModerationService { rpc SubmitReport(SubmitReportRequest) returns (SubmitReportResponse); rpc ListMyReports(ListMyReportsRequest) returns (ListMyReportsResponse); rpc GetReport(GetReportRequest) returns (GetReportResponse); rpc GetReportCategories(GetReportCategoriesRequest) returns (GetReportCategoriesResponse); rpc SubmitFeedback(SubmitFeedbackRequest) returns (SubmitFeedbackResponse); rpc ListMyFeedbacks(ListMyFeedbacksRequest) returns (ListMyFeedbacksResponse); rpc GetFeedback(GetFeedbackRequest) returns (GetFeedbackResponse); rpc GetFeedbackCategories(GetFeedbackCategoriesRequest) returns (GetFeedbackCategoriesResponse); } message SubmitReportRequest { int64 reporter_id = 1; string target_type = 2; int64 target_id = 3; string category_code = 4; string description = 5; bool is_anonymous = 6; repeated string evidence_keys = 7; string client_ip = 8; string device_fp = 9; } message SubmitReportResponse { int64 report_id = 1; string status = 2; bool auto_hidden = 3; bool target_hidden = 4; int64 claimed_by = 5; int64 claimed_at = 6; int64 created_at = 7; } message ListMyReportsRequest { int64 reporter_id = 1; string status = 2; int32 page = 3; int32 page_size = 4; } message ListMyReportsResponse { repeated ReportSummary reports = 1; int32 total = 2; } message ReportSummary { int64 id = 1; string target_type = 2; int64 target_id = 3; string category_code = 4; string status = 5; int64 created_at = 6; int64 resolved_at = 7; string resolved_action = 8; } message GetReportRequest { int64 reporter_id = 1; int64 id = 2; } message GetReportResponse { ReportSummary report = 1; string description = 2; repeated EvidenceSummary evidence = 3; string target_snapshot_json = 4; } message EvidenceSummary { string oss_key = 1; string oss_url = 2; } message GetReportCategoriesRequest {} message GetReportCategoriesResponse { repeated CategoryItem categories = 1; } message CategoryItem { string code = 1; string name = 2; string description = 3; int32 severity = 4; int32 sort_order = 5; } // Feedback (同模式,省略) message SubmitFeedbackRequest { /* ... */ } message SubmitFeedbackResponse { /* ... */ } message ListMyFeedbacksRequest { /* ... */ } message ListMyFeedbacksResponse { /* ... */ } message GetFeedbackRequest { /* ... */ } message GetFeedbackResponse { /* ... */ } message GetFeedbackCategoriesRequest {} message GetFeedbackCategoriesResponse { repeated CategoryItem categories = 1; } ``` **完整字段按 spec §5.1 + 5.2 客户端 API 补全。** - [ ] **Step 2: 编译生成 .pb.go 和 .triple.go** ```bash cd backend # 参考现有 proto 编译流程 make proto-gen # 或: protoc --go_out=. --go-triple_out=. proto/moderation.proto ls pkg/proto/moderation/ # 期望:moderation.pb.go + moderation.triple.go ``` --- ### Task A.2.2: 创建 GORM 模型 **Files:** - Create: `backend/pkg/models/moderation.go` - [ ] **Step 1: 写 9 张表的 GORM 模型** ```go package models import ( "database/sql/driver" "encoding/json" "time" "gorm.io/gorm" "gorm.io/datatypes" ) // JSONB 支持 type JSONB datatypes.JSON func (j JSONB) Value() (driver.Value, error) { if len(j) == 0 { return nil, nil } return json.RawMessage(j).MarshalJSON() } func (j *JSONB) Scan(value interface{}) error { if value == nil { *j = nil; return nil } return json.RawMessage(*j).Scan(value) } // 1. ReportCategory type ReportCategory struct { ID int64 `gorm:"primaryKey;column:id"` Code string `gorm:"column:code;uniqueIndex"` Name string `gorm:"column:name"` Description *string `gorm:"column:description"` Severity int16 `gorm:"column:severity"` Enabled bool `gorm:"column:enabled"` SortOrder int32 `gorm:"column:sort_order"` CreatedAt int64 `gorm:"column:created_at"` UpdatedAt int64 `gorm:"column:updated_at"` } func (ReportCategory) TableName() string { return "report_categories" } // 2. FeedbackCategory (同模式) // 3. Report type Report struct { ID int64 `gorm:"primaryKey;column:id"` ReporterID int64 `gorm:"column:reporter_id"` StarID *int64 `gorm:"column:star_id"` TargetType string `gorm:"column:target_type"` TargetID int64 `gorm:"column:target_id"` TargetSnapshot JSONB `gorm:"column:target_snapshot;type:jsonb"` TriggeredAutoHide bool `gorm:"column:triggered_auto_hide"` CategoryCode string `gorm:"column:category_code"` Description *string `gorm:"column:description"` IsAnonymous bool `gorm:"column:is_anonymous"` Status string `gorm:"column:status"` IsAutoHidden bool `gorm:"column:is_auto_hidden"` ClaimedBy *int64 `gorm:"column:claimed_by"` ClaimedAt *int64 `gorm:"column:claimed_at"` ResolvedAction *string `gorm:"column:resolved_action"` ResolvedBy *int64 `gorm:"column:resolved_by"` ResolvedAt *int64 `gorm:"column:resolved_at"` ResolutionNote *string `gorm:"column:resolution_note"` CreatedAt int64 `gorm:"column:created_at"` UpdatedAt int64 `gorm:"column:updated_at"` } func (Report) TableName() string { return "reports" } // 4. ReportEvidence type ReportEvidence struct { ID int64 `gorm:"primaryKey;column:id"` ReportID int64 `gorm:"column:report_id"` OSSKey string `gorm:"column:oss_key"` OSSURL *string `gorm:"column:oss_url"` SortOrder int32 `gorm:"column:sort_order"` CreatedAt int64 `gorm:"column:created_at"` } func (ReportEvidence) TableName() string { return "report_evidence" } // 5. Feedback (字段同 spec §3.6) // 6. FeedbackEvidence // 7. ModerationAction type ModerationAction struct { ID int64 `gorm:"primaryKey;column:id"` ReportID *int64 `gorm:"column:report_id"` FeedbackID *int64 `gorm:"column:feedback_id"` AdminID int64 `gorm:"column:admin_id"` ActionType string `gorm:"column:action_type"` TargetType *string `gorm:"column:target_type"` TargetID *int64 `gorm:"column:target_id"` Note *string `gorm:"column:note"` Success bool `gorm:"column:success"` ErrorMessage *string `gorm:"column:error_message"` CreatedAt int64 `gorm:"column:created_at"` } func (ModerationAction) TableName() string { return "moderation_actions" } // 8. ModerationTargetStatus type ModerationTargetStatus struct { ID int64 `gorm:"primaryKey;column:id"` TargetType string `gorm:"column:target_type"` TargetID int64 `gorm:"column:target_id"` IsWarned bool `gorm:"column:is_warned"` WarnCount int32 `gorm:"column:warn_count"` LastWarnedAt *int64 `gorm:"column:last_warned_at"` LastActionType string `gorm:"column:last_action_type"` Reason *string `gorm:"column:reason"` Source string `gorm:"column:source"` SourceReportID *int64 `gorm:"column:source_report_id"` OperatorAdminID *int64 `gorm:"column:operator_admin_id"` CreatedAt int64 `gorm:"column:created_at"` UpdatedAt int64 `gorm:"column:updated_at"` } func (ModerationTargetStatus) TableName() string { return "moderation_target_status" } // 9. AdminAuditLog type AdminAuditLog struct { ID int64 `gorm:"primaryKey;column:id"` AdminID int64 `gorm:"column:admin_id"` Action string `gorm:"column:action"` ResourceType *string `gorm:"column:resource_type"` ResourceID *int64 `gorm:"column:resource_id"` IP *string `gorm:"column:ip"` UserAgent *string `gorm:"column:user_agent"` Extra JSONB `gorm:"column:extra;type:jsonb"` CreatedAt int64 `gorm:"column:created_at"` } func (AdminAuditLog) TableName() string { return "admin_audit_logs" } // 业务表 asset / user (软删除字段确认) type Asset struct { gorm.Model IsActive bool `gorm:"column:is_active"` DeletedAt *time.Time `gorm:"column:deleted_at"` OwnerUID int64 `gorm:"column:owner_uid"` StarID *int64 `gorm:"column:star_id"` Name string `gorm:"column:name"` } func (Asset) TableName() string { return "assets" } type User struct { gorm.Model IsActive bool `gorm:"column:is_active"` DeletedAt *time.Time `gorm:"column:deleted_at"` StarID *int64 `gorm:"column:identity_id"` // 用户绑定的明星 } func (User) TableName() string { return "users" } ``` - [ ] **Step 2: 编译验证** ```bash cd backend go build ./pkg/models/... # 期望:编译通过 ``` --- ## 阶段 A.3:moderationService 微服务骨架 ### Task A.3.1: 创建目录结构 - [ ] **Step 1: 复制 socialService 骨架** ```bash cd backend/services cp -r socialService moderationService cd moderationService # 替换文件内所有 "social" / "Social" → "moderation" / "Moderation" # 替换端口 20002 → 20011 # 替换 SERVICE_NAME=social-service → moderation-service # 替换 imports 中的 social → moderation ``` **注意**:保持目录结构一致: ``` moderationService/ ├── main.go ├── start.sh ├── go.mod ├── go.sum ├── configs/ │ └── dubbo.yaml ├── config/ │ └── moderation_config.go ├── model/ │ └── (本计划不用,复用 pkg/models) ├── repository/ │ └── moderation_repository.go ├── service/ │ ├── report_service.go │ ├── feedback_service.go │ ├── category_service.go │ ├── auto_hide_service.go │ └── transaction_helper.go ├── client/ │ ├── asset_client.go │ ├── user_client.go │ ├── notification_client.go (v1.5 stub) │ └── redis_client.go └── provider/ └── moderation_provider.go ``` - [ ] **Step 2: 修改 go.work** ```bash cd backend cat go.work # 添加:./services/moderationService ``` - [ ] **Step 3: 编译验证骨架** ```bash cd backend/services/moderationService go mod tidy go build -o moderationService main.go # 期望:编译通过(即使还是 socialService 的实现) ``` --- ### Task A.3.2: 写 config **Files:** - Modify: `backend/services/moderationService/config/moderation_config.go` - [ ] **Step 1: 写 ModerationConfig** ```go package config import "time" type ModerationConfig struct { AutoHideThreshold int // 默认 5 CounterTTL time.Duration // 7 天 UserMarkerTTL time.Duration // 24h LockTTL time.Duration // 5s MaxDescriptionLen int // 500 MaxEvidenceCount int // 5 MaxContactLen int // 320 MaxReplyLen int // 2000 MaxEvidenceSize int64 // 5MB AllowedMimeTypes []string // ["image/png","image/jpeg"] RateLimitUserPerDay int // 30 RateLimitIPPerDay int // 200 RateLimitDevPerDay int // 200 RateLimitFbUserDay int // 5 ClaimTimeout time.Duration // 15m PIIAnonymizeAfter time.Duration // 90 天 AutoArchiveAfter time.Duration // 90 天 RedisKeyPrefix string // "mod:report" | "test:mod:report" PathDCounterPolicy string // "reset" | "preserve" | "expire_1h" } var Default = ModerationConfig{ AutoHideThreshold: 5, CounterTTL: 7 * 24 * time.Hour, UserMarkerTTL: 24 * time.Hour, LockTTL: 5 * time.Second, MaxDescriptionLen: 500, MaxEvidenceCount: 5, MaxContactLen: 320, MaxReplyLen: 2000, MaxEvidenceSize: 5 * 1024 * 1024, AllowedMimeTypes: []string{"image/png", "image/jpeg"}, RateLimitUserPerDay: 30, RateLimitIPPerDay: 200, RateLimitDevPerDay: 200, RateLimitFbUserDay: 5, ClaimTimeout: 15 * time.Minute, PIIAnonymizeAfter: 90 * 24 * time.Hour, AutoArchiveAfter: 90 * 24 * time.Hour, RedisKeyPrefix: "mod:report", PathDCounterPolicy: "preserve", } ``` --- ## 阶段 A.4:仓储层 ### Task A.4.1: 写 moderation_repository.go **Files:** - Create: `backend/services/moderationService/repository/moderation_repository.go` - [ ] **Step 1: 写仓储结构 + 关键方法** ```go package repository import ( "context" "errors" "gorm.io/gorm" "github.com/topfans/backend/pkg/models" ) var ( ErrNotFound = errors.New("moderation record not found") ErrConflict = errors.New("optimistic lock conflict") ) type ModerationRepository struct { db *gorm.DB } func NewModerationRepository(db *gorm.DB) *ModerationRepository { return &ModerationRepository{db: db} } // ===== Report ===== func (r *ModerationRepository) CreateReport(ctx context.Context, report *models.Report) error { return r.db.WithContext(ctx).Create(report).Error } func (r *ModerationRepository) GetReportByID(ctx context.Context, id int64) (*models.Report, error) { var report models.Report err := r.db.WithContext(ctx).First(&report, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &report, err } func (r *ModerationRepository) GetReportByReporterAndTarget( ctx context.Context, reporterID int64, targetType string, targetID int64, ) (*models.Report, error) { var report models.Report err := r.db.WithContext(ctx). Where("reporter_id = ? AND target_type = ? AND target_id = ? AND status IN ?", reporterID, targetType, targetID, []string{"pending", "reviewing", "auto_hidden"}). First(&report).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &report, err } // Claim - 用 affected rows 判定(spec §4.1 并发认领防护) func (r *ModerationRepository) ClaimReport( ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64, ) (bool, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id = ? AND status IN ?", id, []string{"pending", "auto_hidden"}). Updates(map[string]interface{}{ "status": "reviewing", "claimed_by": adminID, "claimed_at": now, "updated_at": now, }) if res.Error != nil { return false, res.Error } return res.RowsAffected == 1, nil } func (r *ModerationRepository) ReleaseReport( ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64, ) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID). Updates(map[string]interface{}{ "status": "pending", "claimed_by": nil, "claimed_at": nil, "updated_at": now, }) return res.RowsAffected, res.Error } func (r *ModerationRepository) DismissReport( ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64, resolutionNote string, restore bool, ) (int64, error) { db := r.useTx(ctx, tx) // path C/D 需要 restore 参数;path A 不需要(pending → dismissed 直接) // 此方法假定调用方已锁定 status(path B/C/D 都从 reviewing 入口;path A 单独方法) res := db.Model(&models.Report{}). Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID). Updates(map[string]interface{}{ "status": "dismissed", "resolved_action": "dismiss", "resolved_by": adminID, "resolved_at": now, "claimed_by": nil, "claimed_at": nil, "resolution_note": resolutionNote, "updated_at": now, }) return res.RowsAffected, res.Error } // DismissPathA - pending → dismissed 快速通道(spec §4.1 路径 A) func (r *ModerationRepository) DismissPathA( ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64, note string, ) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id = ? AND status = 'pending'", id). Updates(map[string]interface{}{ "status": "dismissed", "resolved_action": "dismiss", "claimed_by": nil, "claimed_at": nil, "resolved_by": adminID, "resolved_at": now, "resolution_note": note, "updated_at": now, }) return res.RowsAffected, res.Error } func (r *ModerationRepository) ResolveReport( ctx context.Context, tx *gorm.DB, id int64, action string, adminID int64, now int64, note string, ) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID). Updates(map[string]interface{}{ "status": "resolved", "resolved_action": action, "resolved_by": adminID, "resolved_at": now, "claimed_by": nil, "claimed_at": nil, "resolution_note": note, "updated_at": now, }) return res.RowsAffected, res.Error } // BulkDismiss - spec §5.2 bulk-dismiss 端点 func (r *ModerationRepository) BulkDismiss( ctx context.Context, tx *gorm.DB, ids []int64, adminID int64, now int64, ) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id IN ? AND status IN ?", ids, []string{"pending", "auto_hidden"}). Updates(map[string]interface{}{ "status": "dismissed", "resolved_action": "dismiss", "claimed_by": nil, "claimed_at": nil, "resolved_by": adminID, "resolved_at": now, "resolution_note": "bulk dismiss", "updated_at": now, }) return res.RowsAffected, res.Error } // ForceRelease - spec §5.2 force-release func (r *ModerationRepository) ForceRelease( ctx context.Context, tx *gorm.DB, id int64, now int64, ) (int64, int64, error) { db := r.useTx(ctx, tx) var report models.Report err := db.First(&report, id).Error if err != nil { return 0, 0, err } prevClaimedBy := int64(0) if report.ClaimedBy != nil { prevClaimedBy = *report.ClaimedBy } res := db.Model(&models.Report{}). Where("id = ? AND status = 'reviewing'", id). Updates(map[string]interface{}{ "status": "pending", "claimed_by": nil, "claimed_at": nil, "updated_at": now, }) return res.RowsAffected, prevClaimedBy, res.Error } // WithdrawReport - spec §5.1 DELETE 客户端撤回 func (r *ModerationRepository) WithdrawReport( ctx context.Context, tx *gorm.DB, id int64, reporterID int64, now int64, ) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Report{}). Where("id = ? AND reporter_id = ? AND status = 'pending'", id, reporterID). Updates(map[string]interface{}{ "status": "withdrawn", "updated_at": now, }) return res.RowsAffected, res.Error } // ListReports func (r *ModerationRepository) ListMyReports( ctx context.Context, reporterID int64, status string, page, pageSize int, ) ([]*models.Report, int64, error) { var reports []*models.Report var total int64 q := r.db.WithContext(ctx).Model(&models.Report{}).Where("reporter_id = ?", reporterID) if status != "" { q = q.Where("status = ?", status) } if err := q.Count(&total).Error; err != nil { return nil, 0, err } if err := q.Order("created_at DESC").Offset((page-1)*pageSize).Limit(pageSize).Find(&reports).Error; err != nil { return nil, 0, err } return reports, total, nil } // ===== Evidence ===== func (r *ModerationRepository) CreateReportEvidence(ctx context.Context, ev *models.ReportEvidence) error { return r.db.WithContext(ctx).Create(ev).Error } func (r *ModerationRepository) GetReportEvidence(ctx context.Context, reportID int64) ([]*models.ReportEvidence, error) { var evs []*models.ReportEvidence err := r.db.WithContext(ctx).Where("report_id = ?", reportID).Order("sort_order ASC").Find(&evs).Error return evs, err } // ===== Feedback ===== (同模式:CreateFeedback / GetFeedbackByID / ListMyFeedbacks / // ClaimFeedback / ReleaseFeedback / ReplyFeedback / CloseFeedback / ArchiveFeedback / CreateFeedbackEvidence / GetFeedbackEvidence) // ===== ModerationAction ===== func (r *ModerationRepository) CreateAction(ctx context.Context, tx *gorm.DB, action *models.ModerationAction) error { return r.useTx(ctx, tx).Create(action).Error } func (r *ModerationRepository) ListActionsByReport(ctx context.Context, reportID int64) ([]*models.ModerationAction, error) { var actions []*models.ModerationAction err := r.db.WithContext(ctx).Where("report_id = ?", reportID).Order("created_at DESC").Find(&actions).Error return actions, err } // ===== ModerationTargetStatus ===== func (r *ModerationRepository) UpsertTargetStatus(ctx context.Context, tx *gorm.DB, mts *models.ModerationTargetStatus) error { return r.useTx(ctx, tx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "target_type"}, {Name: "target_id"}}, DoUpdates: clause.AssignmentColumns([]string{ "last_action_type", "reason", "source", "source_report_id", "operator_admin_id", "updated_at", "is_warned", "warn_count", "last_warned_at", }), }).Create(mts).Error } // ===== AdminAuditLog ===== func (r *ModerationRepository) CreateAuditLog(ctx context.Context, log *models.AdminAuditLog) error { return r.db.WithContext(ctx).Create(log).Error } // ===== Categories ===== func (r *ModerationRepository) ListReportCategories(ctx context.Context) ([]*models.ReportCategory, error) { var cats []*models.ReportCategory err := r.db.WithContext(ctx).Where("enabled = TRUE").Order("sort_order ASC").Find(&cats).Error return cats, err } func (r *ModerationRepository) ListFeedbackCategories(ctx context.Context) ([]*models.FeedbackCategory, error) { var cats []*models.FeedbackCategory err := r.db.WithContext(ctx).Where("enabled = TRUE").Order("sort_order ASC").Find(&cats).Error return cats, err } // ===== Asset / User ===== func (r *ModerationRepository) GetAsset(ctx context.Context, id int64) (*models.Asset, error) { var a models.Asset err := r.db.WithContext(ctx).First(&a, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &a, err } func (r *ModerationRepository) GetUser(ctx context.Context, id int64) (*models.User, error) { var u models.User err := r.db.WithContext(ctx).First(&u, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return &u, err } // SoftDeleteAsset - asset 表 UPDATE is_active=false func (r *ModerationRepository) SoftDeleteAsset(ctx context.Context, tx *gorm.DB, id int64, now int64) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Asset{}). Where("id = ? AND is_active = TRUE", id). Updates(map[string]interface{}{"is_active": false, "deleted_at": now}) return res.RowsAffected, res.Error } func (r *ModerationRepository) RestoreAsset(ctx context.Context, tx *gorm.DB, id int64) (int64, error) { db := r.useTx(ctx, tx) res := db.Model(&models.Asset{}). Where("id = ? AND is_active = FALSE", id). Updates(map[string]interface{}{"is_active": true, "deleted_at": nil}) return res.RowsAffected, res.Error } // SoftDeleteUser / RestoreUser 同模式 // helpers func (r *ModerationRepository) useTx(ctx context.Context, tx *gorm.DB) *gorm.DB { if tx != nil { return tx.WithContext(ctx) } return r.db.WithContext(ctx) } ``` - [ ] **Step 2: 编译验证** ```bash cd backend/services/moderationService go build ./repository/... # 期望:编译通过 ``` --- ## 阶段 A.5:Service 层 ### Task A.5.1: 写 transaction_helper.go **Files:** - Create: `backend/services/moderationService/service/transaction_helper.go` ```go package service import ( "context" "gorm.io/gorm" ) type TxHelper struct { db *gorm.DB } func NewTxHelper(db *gorm.DB) *TxHelper { return &TxHelper{db: db} } func (h *TxHelper) WithTx(ctx context.Context, fn func(*gorm.DB) error) error { return h.db.WithContext(ctx).Transaction(fn) } ``` --- ### Task A.5.2: 写 Lua 脚本 **Files:** - Create: `backend/services/moderationService/service/lua/auto_hide.lua` ```lua -- 自动隐藏 Lua 脚本 (spec §6.4) -- KEYS[1]=counter, KEYS[2]=user_marker -- ARGV[1]=threshold, ARGV[2]=ttl_counter, ARGV[3]=ttl_marker local first = redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3]) if not first then return {0, tonumber(redis.call('GET', KEYS[1]) or '0')} end local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if n >= tonumber(ARGV[1]) then return {1, n} end return {0, n} ``` --- ### Task A.5.3: 写 auto_hide_service.go **Files:** - Create: `backend/services/moderationService/service/auto_hide_service.go` ```go package service import ( "context" _ "embed" "fmt" "time" "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" "github.com/topfans/backend/services/moderationService/config" "github.com/topfans/backend/services/moderationService/repository" ) //go:embed lua/auto_hide.lua var autoHideLuaScript string type AutoHideService struct { repo *repository.ModerationRepository redis *redis.Client tx *TxHelper cfg config.ModerationConfig } func NewAutoHideService( repo *repository.ModerationRepository, redis *redis.Client, tx *TxHelper, cfg config.ModerationConfig, ) *AutoHideService { return &AutoHideService{repo: repo, redis: redis, tx: tx, cfg: cfg} } // TryTrigger - 提交举报后调用(spec §6.1 step 6) // 返回值:(triggered bool, counter int64) func (s *AutoHideService) TryTrigger( ctx context.Context, targetType string, targetID int64, reporterID int64, ) (bool, int64, error) { // 应用层短锁(spec §6.4:5s 锁覆盖整个 auto-hide 流程) lockKey := fmt.Sprintf("%s:lock:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID) ok, err := s.redis.SetNX(ctx, lockKey, "1", s.cfg.LockTTL).Result() if err != nil { return false, 0, err } if !ok { return false, 0, nil } // 5s 内已有并发请求在跑 defer func() { if err := s.redis.Del(ctx, lockKey).Err(); err != nil { logger.Sugar.Warnw("failed to release auto-hide lock", "key", lockKey, "err", err) } }() counterKey := fmt.Sprintf("%s:counter:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID) userMarkerKey := fmt.Sprintf("%s:user:%s:%d:%d", s.cfg.RedisKeyPrefix, targetType, targetID, reporterID) result, err := s.redis.Eval(ctx, autoHideLuaScript, []string{counterKey, userMarkerKey}, s.cfg.AutoHideThreshold, int(s.cfg.CounterTTL.Seconds()), int(s.cfg.UserMarkerTTL.Seconds()), ).Result() if err != nil { return false, 0, err } arr := result.([]interface{}) triggered := arr[0].(int64) == 1 counter := arr[1].(int64) return triggered, counter, nil } // ExecuteAutoHide - 触发自动隐藏(事务内:业务表软删除 + reports UPDATE + mts UPSERT + 流水) func (s *AutoHideService) ExecuteAutoHide( ctx context.Context, targetType string, targetID int64, triggerReportID int64, reporterID int64, ) error { now := time.Now().UnixMilli() return s.tx.WithTx(ctx, func(tx *gorm.DB) error { var affected int64 var err error // ① 业务表软删除(按 target_type 路由) switch targetType { case "asset": affected, err = s.repo.SoftDeleteAsset(ctx, tx, targetID, now) case "user_profile": affected, err = s.repo.SoftDeleteUser(ctx, tx, targetID, now) default: return fmt.Errorf("unsupported target_type: %s", targetType) } if err != nil { return err } // ② reports UPDATE pending → auto_hidden // v2.4 V5: triggered_auto_hide 仅触发者置 TRUE res := tx.Model(&models.Report{}). Where("target_type = ? AND target_id = ? AND status = 'pending'", targetType, targetID). Updates(map[string]interface{}{ "status": "auto_hidden", "is_auto_hidden": true, "triggered_auto_hide": gorm.Expr("(id = ?)", triggerReportID), "updated_at": now, }) if res.Error != nil { return res.Error } // ③ mts UPSERT mts := &models.ModerationTargetStatus{ TargetType: targetType, TargetID: targetID, LastActionType: "autohide", Source: "auto", SourceReportID: &triggerReportID, CreatedAt: now, UpdatedAt: now, } if err := s.repo.UpsertTargetStatus(ctx, tx, mts); err != nil { return err } // ③.5 写 moderation_actions 流水(v2.4 V7 conditional) actionType := "takedown" note := "auto-hide threshold reached" if affected == 0 { actionType = "autohide_noop" note = "auto-hide no-op: target already is_active=false" } reportIDCopy := triggerReportID action := &models.ModerationAction{ ReportID: &reportIDCopy, AdminID: 0, // 系统自动 sentinel (v2.2 B4) ActionType: actionType, TargetType: &targetType, TargetID: &targetID, Note: ¬e, Success: true, CreatedAt: now, } return s.repo.CreateAction(ctx, tx, action) }) } // ResetCounter - spec §6.2 path C: admin 判定误报,重置 Redis 计数 func (s *AutoHideService) ResetCounter(ctx context.Context, targetType string, targetID int64) error { counterKey := fmt.Sprintf("%s:counter:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID) lockKey := fmt.Sprintf("%s:lock:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID) userMarkerPattern := fmt.Sprintf("%s:user:%s:%d:*", s.cfg.RedisKeyPrefix, targetType, targetID) // DEL counter + lock if err := s.redis.Del(ctx, counterKey, lockKey).Err(); err != nil { return err } // SCAN MATCH user_marker 逐个 DEL(spec §6.4 v2.2 B1:DEL 不支持通配) iter := s.redis.Scan(ctx, 0, userMarkerPattern, 100).Iterator() var keys []string for iter.Next(ctx) { keys = append(keys, iter.Val()) } if err := iter.Err(); err != nil { return err } if len(keys) > 0 { if err := s.redis.Del(ctx, keys...).Err(); err != nil { return err } } return nil } ``` --- ### Task A.5.4: 写 report_service.go **Files:** - Create: `backend/services/moderationService/service/report_service.go` ```go package service import ( "context" "encoding/json" "fmt" "time" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/moderation" "github.com/topfans/backend/services/moderationService/client" "github.com/topfans/backend/services/moderationService/config" "github.com/topfans/backend/services/moderationService/repository" ) type ReportService struct { repo *repository.ModerationRepository assetClient *client.AssetClient userClient *client.UserClient notifClient *client.NotificationClient autoHide *AutoHideService cfg config.ModerationConfig } func NewReportService( repo *repository.ModerationRepository, assetClient *client.AssetClient, userClient *client.UserClient, notifClient *client.NotificationClient, autoHide *AutoHideService, cfg config.ModerationConfig, ) *ReportService { return &ReportService{repo: repo, assetClient: assetClient, userClient: userClient, notifClient: notifClient, autoHide: autoHide, cfg: cfg} } // SubmitReport - spec §6.1 完整流程 func (s *ReportService) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) { now := time.Now().UnixMilli() // 0. 限流(user/IP/device,每天 30/200/200) if err := s.checkRateLimit(ctx, req); err != nil { return nil, err } // 1. 验证 target_type、分类启用、description ≤ 500、evidence ≤ 5 if req.TargetType != "asset" && req.TargetType != "user_profile" { return nil, fmt.Errorf("invalid target_type") // 50016 } if len(req.Description) > s.cfg.MaxDescriptionLen { return nil, fmt.Errorf("description too long") // 50005 } if len(req.EvidenceKeys) > s.cfg.MaxEvidenceCount { return nil, fmt.Errorf("too many evidence") // 50006 } if !s.isCategoryEnabled(ctx, req.CategoryCode, "report") { return nil, fmt.Errorf("invalid category") // 50001 } // 2. 验证目标存在 + 抓取 snapshot snapshot, ownerUID, starID, err := s.fetchTargetSnapshot(ctx, req.TargetType, req.TargetId) if err != nil { return nil, fmt.Errorf("target not found") } // 50003 snapshotMap := map[string]interface{}{ "snapshot_at": now, } snapshotBytes, _ := json.Marshal(snapshotMap) var snapshotJSON models.JSONB = snapshotBytes snapshotJSON["snapshot_at"] = now // ... 合并到 snapshot // 2.5 自举报拦截 if req.TargetType == "asset" && req.ReporterId == ownerUID { return nil, fmt.Errorf("cannot self-report") // 50011 } if req.TargetType == "user_profile" && req.ReporterId == req.TargetId { return nil, fmt.Errorf("cannot self-report") // 50011 } // 3. 防重复检查 existing, _ := s.repo.GetReportByReporterAndTarget(ctx, req.ReporterId, req.TargetType, req.TargetId) if existing != nil { return nil, fmt.Errorf("already reported") // 50004 } // 4. 写 reports(status='pending', claimed_by/claimed_at=NULL — chk_reports_claimed_pair 强制) report := &models.Report{ ReporterID: req.ReporterId, StarID: starID, TargetType: req.TargetType, TargetID: req.TargetId, TargetSnapshot: snapshotJSON, CategoryCode: req.CategoryCode, Description: &req.Description, IsAnonymous: req.IsAnonymous, Status: "pending", IsAutoHidden: false, ClaimedBy: nil, ClaimedAt: nil, CreatedAt: now, UpdatedAt: now, } if err := s.repo.CreateReport(ctx, report); err != nil { return nil, err } // 5. 写 report_evidence for i, key := range req.EvidenceKeys { ev := &models.ReportEvidence{ ReportID: report.ID, OSSKey: key, SortOrder: int32(i), CreatedAt: now, } if err := s.repo.CreateReportEvidence(ctx, ev); err != nil { return nil, err } } // 6. Redis Lua 计数 → 自动隐藏 triggered, _, _ := s.autoHide.TryTrigger(ctx, req.TargetType, req.TargetId, req.ReporterId) autoHidden := false targetHidden := false if triggered { if err := s.autoHide.ExecuteAutoHide(ctx, req.TargetType, req.TargetId, report.ID, req.ReporterId); err != nil { // log } else { autoHidden = true // target_hidden 取决于业务表 UPDATE 是否执行(affected > 0) // 这里简化为 true,实际应查询 repo targetHidden = true } } status := "pending" if autoHidden { status = "auto_hidden" } return &pb.SubmitReportResponse{ ReportId: report.ID, Status: status, AutoHidden: autoHidden, TargetHidden: targetHidden, ClaimedBy: 0, ClaimedAt: 0, CreatedAt: now, }, nil } // ... 其他方法(ListMyReports / GetReport / WithdrawReport)省略 ``` **完整实现按 spec §5.1 + §6.1 写**,本计划仅给核心结构。 --- ### Task A.5.5: 写 feedback_service.go **Files:** - Create: `backend/services/moderationService/service/feedback_service.go` **关键点:** - `ClaimFeedback` —— 与 ReportService.ClaimReport 同模式(affected rows 判定) - `ReplyFeedback` —— 终态,**清 claimed_by/claimed_at**(chk_feedbacks_claimed_pair 强制) - `CloseFeedback` —— 终态,清 claimed_* - `ArchiveFeedback` —— 仅从 reviewing 入口(spec §4.2 / v2.0 CRITICAL 修复) ```go func (s *FeedbackService) ReplyFeedback(ctx, feedbackID, adminID, content string) error { now := time.Now().UnixMilli() return s.tx.WithTx(ctx, func(tx *gorm.DB) error { // UPDATE feedbacks SET status='replied', reply_content=?, replied_by=?, replied_at=$now, claimed_by=NULL, claimed_at=NULL res := tx.Model(&models.Feedback{}). Where("id = ? AND status = 'reviewing' AND claimed_by = ?", feedbackID, adminID). Updates(map[string]interface{}{ "status": "replied", "reply_content": content, "replied_by": adminID, "replied_at": now, "claimed_by": nil, "claimed_at": nil, "updated_at": now, }) if res.RowsAffected == 0 { return ErrNotClaimed } // 50008/50009 // 写 moderation_actions(action_type='reply') return s.repo.CreateAction(ctx, tx, &models.ModerationAction{ FeedbackID: &feedbackID, AdminID: parseInt(adminID), ActionType: "reply", Success: true, CreatedAt: now, }) }) } func (s *FeedbackService) ArchiveFeedback(ctx, feedbackID, adminID int64) error { now := time.Now().UnixMilli() // v2.0 CRITICAL: archive 仅从 reviewing 入口(replied 是终态不能再 archive) return s.tx.WithTx(ctx, func(tx *gorm.DB) error { res := tx.Model(&models.Feedback{}). Where("id = ? AND status = 'reviewing' AND claimed_by = ?", feedbackID, adminID). Updates(map[string]interface{}{ "status": "archived", "archived_by": adminID, "archived_at": now, "claimed_by": nil, "claimed_at": nil, "updated_at": now, }) if res.RowsAffected == 0 { return ErrNotClaimed } return s.repo.CreateAction(ctx, tx, &models.ModerationAction{ FeedbackID: &feedbackID, AdminID: adminID, ActionType: "archive", Success: true, CreatedAt: now, }) }) } ``` --- ### Task A.5.6: 写 category_service.go 按 report / feedback 分类读取逻辑写,最简单(仅 GET 启用中的分类)。 --- ### Task A.5.7: 写 unit tests **Files:** - Create: `backend/services/moderationService/service/{report,feedback,auto_hide,concurrency}_test.go` **关键测试覆盖(spec §7.2):** - 防重复举报 - 自动隐藏触发 - dismiss 4 路径 - claimed_* 字段一致性 - Lua 脚本逻辑 - 并发:100 用户同时举报同对象 → counter INCR 但只触发 1 次 - 并发:2 admin 同时 claim → 1 成功 1 返回 50008 - 自动隐藏 Lua + 5s 短锁交互(errgroup) ```go // report_service_test.go 示例 func TestClaimReport_Concurrent(t *testing.T) { // 启动 2 个 goroutine 同时 claim 同一 report // 期望:1 个成功(affected=1),1 个失败(affected=0) } func TestDismissPathA_ClearsClaimedBy(t *testing.T) { // 路径 A 必须 SET claimed_by=NULL(pending 状态必为 NULL,chk_reports_claimed_pair) } func TestWithdrawReport_OnlyPending(t *testing.T) { // 非 pending 状态 withdrawn 0 行匹配 → 返回 50019 } func TestReportService_DuplicateCheck(t *testing.T) { // 同 reporter+target+type pending 重复提交 → 50004 } ``` - [ ] **Step 1: 写所有测试** - [ ] **Step 2: 跑测试** ```bash cd backend/services/moderationService go test ./service/... -v -race # 期望:全部 PASS ``` --- ## 阶段 A.6:Client 层 ### Task A.6.1: 写 redis_client.go **Files:** - Create: `backend/services/moderationService/client/redis_client.go` 参考 `aiChatService/main.go` 初始化 Redis 客户端: ```go func NewRedisClient(host string, port int, password string, db int) *redis.Client { return redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", host, port), Password: password, DB: db, }) } ``` ### Task A.6.2: 写 asset_client.go + user_client.go + notification_client.go **Files:** - Create: `backend/services/moderationService/client/{asset,user,notification}_client.go` - **asset_client.go**:Dubbo 调用 `assetService.GetAssetRPC`(参考 `socialService/client/user_rpc_client.go`) - **user_client.go**:Dubbo 调用 `userService.GetUserRPC` - **notification_client.go**:v1.5 stub — 仅打日志,阶段 7.1 替换为真实现 ```go // notification_client.go (stub) type NotificationClient struct{} func (c *NotificationClient) SendReportAutoHidden(ctx context.Context, userID int64, targetType string, targetID int64) error { logger.Sugar.Infow("STUB: would send report_auto_hidden_notice", "user_id", userID, "target_type", targetType, "target_id", targetID) return nil } // ... 6 个方法的 stub ``` --- ## 阶段 A.7:Provider 层 ### Task A.7.1: 写 moderation_provider.go **Files:** - Create: `backend/services/moderationService/provider/moderation_provider.go` ```go package provider import ( "context" pb "github.com/topfans/backend/pkg/proto/moderation" "github.com/topfans/backend/services/moderationService/service" ) type ModerationProvider struct { pb.UnimplementedModerationServiceServer report *service.ReportService feedback *service.FeedbackService category *service.CategoryService } func NewModerationProvider(r *service.ReportService, f *service.FeedbackService, c *service.CategoryService) *ModerationProvider { return &ModerationProvider{report: r, feedback: f, category: c} } func (p *ModerationProvider) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) { return p.report.SubmitReport(ctx, req) } func (p *ModerationProvider) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) { return p.report.ListMyReports(ctx, req) } func (p *ModerationProvider) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) { return p.report.GetReport(ctx, req) } func (p *ModerationProvider) GetReportCategories(ctx context.Context, req *pb.GetReportCategoriesRequest) (*pb.GetReportCategoriesResponse, error) { return p.category.GetReportCategories(ctx, req) } func (p *ModerationProvider) SubmitFeedback(ctx context.Context, req *pb.SubmitFeedbackRequest) (*pb.SubmitFeedbackResponse, error) { return p.feedback.SubmitFeedback(ctx, req) } // ... 其他 RPC 方法映射 ``` --- ## 阶段 A.8:main.go 与启动 ### Task A.8.1: 完善 main.go **Files:** - Modify: `backend/services/moderationService/main.go` 参考 `socialService/main.go`,加: - 端口 20011 - `notificationServiceURL=tri://localhost:20008` - 注册所有 client(asset/user/notification/redis) - 初始化 TxHelper / Config / AutoHideService / ReportService / FeedbackService / CategoryService - 注册 Provider 到 Dubbo - 暴露 `promhttp.Handler()` 在 `:20011/metrics` ### Task A.8.2: 完善 start.sh **Files:** - Create: `backend/services/moderationService/start.sh` 按 spec §阶段 2.1 模板(已复制到总计划文档)。 ### Task A.8.3: 编译 + 启动验证 - [ ] **Step 1: 编译** ```bash cd backend/services/moderationService ./start.sh # build + start # 期望:build 成功,服务监听 20011 ``` - [ ] **Step 2: metrics 端点验证** ```bash curl http://localhost:20011/metrics | head -30 # 期望:promhttp 输出 ``` --- ## 阶段 A.9:Gateway 集成 ### Task A.9.1: 写 moderation_client.go **Files:** - Create: `backend/gateway/client/moderation_client.go` 参考 `backend/gateway/client/social_client.go`: ```go type ModerationRPCClient struct { cli pb.ModerationServiceClient } func NewModerationRPCClient(cli *client.Client) (*ModerationRPCClient, error) { pbCli, err := cli.ModerationServiceClient() if err != nil { return nil, err } return &ModerationRPCClient{cli: pbCli}, nil } func (c *ModerationRPCClient) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) { return c.cli.SubmitReport(ctx, req) } ``` ### Task A.9.2: 写 DTO + Converter **Files:** - Create: `backend/gateway/dto/moderation_dto.go` - Create: `backend/gateway/dto/moderation_converter.go` ```go // moderation_dto.go type SubmitReportRequestDTO struct { TargetType string `json:"target_type" binding:"required,oneof=asset user_profile"` TargetID int64 `json:"target_id" binding:"required,gt=0"` CategoryCode string `json:"category_code" binding:"required,max=50"` Description string `json:"description" binding:"max=500"` IsAnonymous bool `json:"is_anonymous"` EvidenceKeys []string `json:"evidence_keys" binding:"max=5,dive,max=255"` } type SubmitReportResponseDTO struct { Code int32 `json:"code"` Data struct { ReportID int64 `json:"report_id"` Status string `json:"status"` AutoHidden bool `json:"auto_hidden"` TargetHidden bool `json:"target_hidden"` ClaimedBy int64 `json:"claimed_by"` ClaimedAt int64 `json:"claimed_at"` CreatedAt int64 `json:"created_at"` } `json:"data"` Message string `json:"message"` } ``` ### Task A.9.3: 写 Controller **Files:** - Create: `backend/gateway/controller/moderation_controller.go` 参考 `backend/gateway/controller/social_controller.go`: ```go type ModerationController struct { client *moderationClient.ModerationRPCClient } func NewModerationController(cli *client.Client) (*ModerationController, error) { rpcClient, err := moderationClient.NewModerationRPCClient(cli) if err != nil { return nil, err } return &ModerationController{client: rpcClient}, nil } func (c *ModerationController) SubmitReport(ctx *gin.Context) { var req dto.SubmitReportRequestDTO if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(400, dto.ErrorResponse{Code: 50005, Message: "参数错误: " + err.Error()}) return } reporterID := middleware.GetUserID(ctx) ip := ctx.ClientIP() deviceFP := ctx.GetHeader("X-Device-Fingerprint") resp, err := c.client.SubmitReport(ctx, converter.ToRPCSubmitReport(&req, reporterID, ip, deviceFP)) if err != nil { // 错误码映射(参考 spec §7) ctx.JSON(500, dto.ErrorResponse{Code: 50000, Message: err.Error()}) return } ctx.JSON(200, converter.FromRPCSubmitReport(resp)) } ``` ### Task A.9.4: 在 router.go 注册路由 **Files:** - Modify: `backend/gateway/router/router.go` ```go // 客户端路由 v1.GET("/moderation/report-categories", moderationController.GetReportCategories) v1.GET("/moderation/feedback-categories", moderationController.GetFeedbackCategories) v1.POST("/moderation/reports", moderationController.SubmitReport) v1.GET("/moderation/reports", moderationController.ListMyReports) v1.GET("/moderation/reports/:id", moderationController.GetReportDetail) v1.POST("/moderation/feedbacks", moderationController.SubmitFeedback) v1.GET("/moderation/feedbacks", moderationController.ListMyFeedbacks) v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail) ``` **注意:** 仅客户端路由(`/api/v1/moderation/*`)。后台路由 `/api/admin/moderation/*` 由 FastAPI 处理,不在本仓。 ### Task A.9.5: 启动 gateway,curl 验证 ```bash # 启动 moderationService + gateway curl -X POST http://localhost:8080/api/v1/moderation/reports \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"target_type":"asset","target_id":12345,"category_code":"pornographic","description":"test","is_anonymous":false,"evidence_keys":[]}' # 期望:200 + report_id ``` --- ## 阶段 A.10:Docker + Makefile ### Task A.10.1: Dockerfile **Files:** - Modify: `docker/Dockerfile.services` 加 moderationservice 构建阶段。 ### Task A.10.2: docker-compose **Files:** - Modify: `docker/docker-compose.local.yml` - Modify: `docker/docker-compose.prod.yml` 加服务定义(端口 20011 + env)。 ### Task A.10.3: Makefile **Files:** - Modify: `Makefile` 加: ```makefile start-moderationservice: cd backend/services/moderationService && ./start.sh ``` --- ## 自审检查清单(Plan A) 完成后逐项验证: - [ ] 9 个序列 `last_value >= MAX(id)`(CLAUDE.md 强制) - [ ] 所有手动 INSERT 都带 `SELECT setval` - [ ] `chk_reports_status` 含 `'withdrawn'` / `'archived'` - [ ] `chk_moderation_actions_action_type` 含 `'autohide_noop'` / `'withdraw'` / `'force_release'` - [ ] `chk_mts_last_action_type` 含 `'warn_cleared'` - [ ] `uk_reports_reporter_target_pending` 唯一索引存在 - [ ] 所有终态迁移清 `claimed_by=NULL, claimed_at=NULL` - [ ] Lua 脚本并发 + 锁测试通过 - [ ] Gateway 路由 `/api/v1/moderation/*` 注册成功 - [ ] `go test ./... -race` 全部 PASS - [ ] `moderationService` 启动 + `/metrics` 端点正常 --- ## 执行 **本子计划 A 由 subagent 派发执行:** 1. 创建 worktree(如需要) 2. 派发 subagent 实施 Task A.1.1 ~ A.10.3 3. 每阶段完成后 review 4. 全部完成后回到主计划执行 Plan B/C/D