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