topfans/docs/superpowers/plans/2026-06-17-plan-a-db-go-moderation-service.md
2026-06-22 17:19:48 +08:00

2055 lines
72 KiB
Markdown
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.

# 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.2proto 定义 + 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.3moderationService 微服务骨架
### 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 直接)
// 此方法假定调用方已锁定 statuspath 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.5Service 层
### 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.45s 锁覆盖整个 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: &note,
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 逐个 DELspec §6.4 v2.2 B1DEL 不支持通配)
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. 写 reportsstatus='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=11 个失败affected=0
}
func TestDismissPathA_ClearsClaimedBy(t *testing.T) {
// 路径 A 必须 SET claimed_by=NULLpending 状态必为 NULLchk_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.6Client 层
### 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.7Provider 层
### 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.8main.go 与启动
### Task A.8.1: 完善 main.go
**Files:**
- Modify: `backend/services/moderationService/main.go`
参考 `socialService/main.go`,加:
- 端口 20011
- `notificationServiceURL=tri://localhost:20008`
- 注册所有 clientasset/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.9Gateway 集成
### 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: 启动 gatewaycurl 验证
```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.10Docker + 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