topfans/docs/superpowers/plans/2026-06-17-moderation-report-feedback-system.md
2026-06-22 17:19:48 +08:00

1074 lines
42 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.

# 举报与反馈系统实现计划
> **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.
**Goal:** 在 TopFans 平台新增内容举报与用户反馈两大工单系统(客户端提交 + 后台审核处理 + 自动隐藏阈值闭环)
**Architecture:**
- 独立 Go Dubbo 微服务 `moderationService`(端口 20010承载客户端 API + 自动隐藏阈值触发
- 共享 PostgreSQL `top-fans` 库 9 张表Redis 计数OSS 证据图上传
- `TopFans-activity-admin` 后台FastAPI + Vue3独立读写共享库做审核动作
- 业务表软删除(`is_active` + `deleted_at`)承担下架/封禁,`moderation_target_status` 仅做审计 + 警告状态
**Tech Stack:** Go 1.21+ / Dubbo-go / GORM / PostgreSQL 16 / Redis 7 / FastAPI / SQLAlchemy / Vue3 + Element Plus / uni-app + uView 2.x
**参考 spec:** `docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md` (v2.5)
**子计划拆分建议**scope check本计划跨 2 个仓库、5 种技术栈、11 阶段。建议在执行时按仓库拆分为 4 个独立执行计划:
| 子计划 | 阶段 | 仓库 | 工作目录 |
|--------|------|------|---------|
| A: 主仓数据库 + Go 微服务 | 1, 2, 6 | topfans 主仓 | `TopFansByGithub` |
| B: 后台 API + 前端 | 3, 4 | TopFans-activity-admin | `TopFans-activity-admin` |
| C: 客户端集成 | 5 | topfans 主仓 `frontend/` | `TopFansByGithub` |
| D: 通知 + 测试 | 7 | 两仓并行 | - |
**约束(来自 CLAUDE.md + 设计文档):**
- ✅ 任何手动指定 ID 的 INSERT 必须 `SELECT setval('xxx_id_seq', (SELECT MAX(id) FROM xxx))`
- ✅ 不主动 commit等用户明确指示
- ✅ 错误处理统一错误码50001-50019+ 友好提示 + 日志
- ✅ 自审修复后做整体回归检查
---
## 文件结构总览
### A. topfans 主仓 — 新建文件
| 路径 | 职责 |
|------|------|
| `backend/migrations/2026_06_11_011_moderation_tables.sql` | 9 张表 + 索引 + 种子数据 + 9 个序列同步 |
| `backend/proto/moderation.proto` | RPC 接口定义 |
| `backend/pkg/proto/moderation/moderation.pb.go` | proto 生成(编译产物) |
| `backend/pkg/proto/moderation/moderation.triple.go` | proto 生成(编译产物) |
| `backend/pkg/models/moderation.go` | GORM 模型(共享 pkg 模式) |
| `backend/services/moderationService/main.go` | Dubbo 启动入口(端口 20010 |
| `backend/services/moderationService/start.sh` | 启动脚本 |
| `backend/services/moderationService/configs/dubbo.yaml` | Dubbo 配置 |
| `backend/services/moderationService/config/moderation_config.go` | 业务配置 |
| `backend/services/moderationService/repository/moderation_repository.go` | 仓储层 |
| `backend/services/moderationService/repository/moderation_repository_test.go` | 仓储单测 |
| `backend/services/moderationService/service/report_service.go` | 举报业务(含事务内双重/三重写入) |
| `backend/services/moderationService/service/feedback_service.go` | 反馈业务 |
| `backend/services/moderationService/service/category_service.go` | 分类维护 |
| `backend/services/moderationService/service/auto_hide_service.go` | Redis Lua 自动隐藏 |
| `backend/services/moderationService/service/transaction_helper.go` | `WithTx(ctx, fn)` 封装 |
| `backend/services/moderationService/service/lua/auto_hide.lua` | Lua 脚本(`//go:embed` |
| `backend/services/moderationService/client/asset_client.go` | 验证目标存在 |
| `backend/services/moderationService/client/user_client.go` | 验证用户/封禁查询 |
| `backend/services/moderationService/client/notification_client.go` | 发通知v1.5 stub → 阶段 7 替换) |
| `backend/services/moderationService/client/redis_client.go` | Redis 客户端 |
| `backend/services/moderationService/provider/moderation_provider.go` | Dubbo 服务注册 |
| `backend/services/moderationService/service/report_service_test.go` | 状态机 + claimed_* 一致性测试 |
| `backend/services/moderationService/service/feedback_service_test.go` | 状态机测试 |
| `backend/services/moderationService/service/auto_hide_service_test.go` | Lua 测试 |
| `backend/services/moderationService/service/concurrency_test.go` | 并发场景 |
| `backend/gateway/controller/moderation_controller.go` | HTTP 控制器(客户端路由) |
| `backend/gateway/dto/moderation_dto.go` | DTO |
| `backend/gateway/dto/moderation_converter.go` | RPC ↔ DTO 映射 |
| `backend/gateway/client/moderation_client.go` | Gateway 端 Dubbo 客户端 |
| `frontend/utils/api.js` (末尾追加) | 8 个客户端 API 方法 |
| `frontend/components/ReportModal.vue` | 举报弹窗 |
| `frontend/pages/profile/feedback.vue` | 意见反馈页 |
| `frontend/pages/profile/myReports.vue` | 我的举报列表 |
| `frontend/pages/profile/myFeedbacks.vue` | 我的反馈列表 |
### B. topfans 主仓 — 修改文件
| 路径 | 改动 |
|------|------|
| `backend/services/assetService/client/oss/config.go` | `GetUploadDir()` switch 补 `report` / `feedback` 两个 case |
| `backend/services/assetService/client/oss/asset_controller.go` | 白名单扩为 4 个值;`InitMintOrder` 调用按 type 分支跳过 |
| `backend/gateway/router/router.go` | 注册 `/api/v1/moderation/*`(仅客户端路由) |
| `backend/go.work` | 加入 `moderationService` |
| `docker/Dockerfile.services` | 加 `moderationservice` 构建行 |
| `docker/docker-compose.local.yml` + `docker-compose.prod.yml` | 加服务定义 |
| `Makefile` | 加 `start-moderationservice` 目标 |
| `frontend/pages/profile/profile.vue` | 菜单项追加"我的举报"/"我的反馈" |
| `frontend/components/NftDetailModal.vue` 等 | "..."菜单接入 ReportModal |
| `frontend/pages/user/userProfile.vue` | "..."菜单接入 ReportModal |
### C. TopFans-activity-admin 仓 — 新建文件
| 路径 | 职责 |
|------|------|
| `backend/models/moderation.py` | SQLAlchemy ORM |
| `backend/schemas/moderation.py` | Pydantic 模型 |
| `backend/crud/moderation_crud.py` | 数据库操作 |
| `backend/handlers/moderation_admin.py` | `/api/admin/moderation/*` 路由 |
| `backend/handlers/test_moderation_admin.py` | FastAPI 单元测试 |
| `frontend/src/api/moderation.js` | axios 封装 |
| `frontend/src/views/moderation/ReportList.vue` | 举报工单列表 |
| `frontend/src/views/moderation/ReportDetail.vue` | 举报详情 |
| `frontend/src/views/moderation/FeedbackList.vue` | 反馈列表 |
| `frontend/src/views/moderation/FeedbackDetail.vue` | 反馈详情 |
| `frontend/src/views/moderation/ReportCategoryConfig.vue` | 举报分类 CRUD |
| `frontend/src/views/moderation/FeedbackCategoryConfig.vue` | 反馈分类 CRUD |
| `frontend/src/views/moderation/Dashboard.vue` | 审核看板(可选) |
### D. TopFans-activity-admin 仓 — 修改文件
| 路径 | 改动 |
|------|------|
| `backend/router/__init__.py` | 注册 `/api/admin/moderation/*` |
| `frontend/src/router/index.js` | 注册 `/moderation/*` 路由 |
| `frontend/src/layout/Layout.vue` | 菜单注册 |
---
## 阶段 1数据库 Schematopfans 主仓)
### Task 1.1: 创建迁移文件
**Files:**
- Create: `backend/migrations/2026_06_11_011_moderation_tables.sql`
**前置:**
- 已有 `2026_06_08_010_statistic_partitions_initial.sql`;新文件按三位编号递增到 011
- 注意:`2026_06_16_001_create_notifications.sql` 已存在,本 plan 用 011 不冲突(不同日期段)
- [ ] **Step 1: 写完整 SQL 迁移**
按 spec §3 写入 9 张表的 CREATE TABLE + 索引 + 种子数据 + 9 个序列同步。
**关键 SQL 模板**(完整内容按 spec §3.2-3.10 复制9 张表全部带 `COMMENT ON TABLE` 文档注释):
```sql
-- ============================================================================
-- 举报与反馈系统 (spec: docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md v2.5)
-- 9 张表 + 索引 + 种子数据 + 序列同步
-- 所有 *_at 字段均为 Unix 毫秒 (BIGINT),与 Go time.Now().UnixMilli() 一致
-- ============================================================================
-- 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));
```
按相同模式写 `feedback_categories` / `reports` / `report_evidence` / `feedbacks` / `feedback_evidence` / `moderation_actions` / `moderation_target_status` / `admin_audit_logs` 8 张表 + 各自序列同步(**每张表末尾必须 `SELECT setval`** — CLAUDE.md 强制)。
**关键约束清单**(必填):
- `reports.chk_reports_status``'withdrawn'` / `'archived'`spec §3.4 v2.4 V2 修复)
- `moderation_actions.chk_moderation_actions_action_type``'autohide_noop'` / `'withdraw'` / `'force_release'`v2.4 V3
- `moderation_actions.chk_moderation_actions_admin_id``(admin_id >= 0)`v2.2 B4 系统自动 sentinel
- `moderation_target_status.chk_mts_last_action_type``'warn_cleared'`v2.4 V4
- `chk_mts_warn_fields` 第 3 子句支持"历史警告已清除"v2.7 H2
- `uk_reports_reporter_target_pending` 唯一索引(防重复)
**关键索引**
- `idx_reports_status_created (status, created_at DESC)`
- `idx_reports_triggered_autohide (target_type, target_id, created_at DESC) WHERE triggered_auto_hide=TRUE`v2.5 W2
- `idx_moderation_actions_target (target_type, target_id, created_at DESC) WHERE target_type IS NOT NULL`v1.7 H1 修复)
- `idx_feedbacks_claimed_by (claimed_by, status, created_at DESC) WHERE claimed_by IS NOT NULL`
- [ ] **Step 2: 验证 SQL 语法**
```bash
# 用 docker 起临时 PG 验证
docker run -d --name pg-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
# 期望:所有 CREATE/INSERT/setval 都成功,无 CHECK 违例
docker rm -f pg-verify
```
- [ ] **Step 3: 验证 9 个序列都同步**
```sql
SELECT sequencename, last_value,
(SELECT MAX(id) FROM report_categories) AS table_max_id
FROM pg_sequences WHERE sequencename LIKE '%moderation%' OR sequencename LIKE '%reports%' OR ...;
-- 所有 last_value >= MAX(id)
```
- [ ] **Step 4: 暂不 commitCLAUDE.md 规定不主动 commit**
---
### Task 1.2: 写 integration test 验证约束
**Files:**
- Create: `backend/services/moderationService/repository/moderation_repository_test.go`(前置创建空文件)
- [ ] **Step 1: 写测试用例覆盖关键 CHECK 约束**
使用 testcontainers-go 起临时 PG参考 spec §7.4 隔离策略),加载阶段 1.1 迁移后跑:
```go
func TestReports_WithdrawnStatus_Allowed(t *testing.T) {
// 测试 chk_reports_status 包含 'withdrawn'
db.Exec(`UPDATE reports SET status='withdrawn' WHERE id=?`, id)
// 期望:无 CHECK 违例
}
func TestReports_DuplicatePending_Blocked(t *testing.T) {
// 测试 uk_reports_reporter_target_pending 唯一索引
db.Exec(`INSERT INTO reports (...) VALUES (...)`)
db.Exec(`INSERT INTO reports (...) VALUES (...)`) // 同 reporter+target pending
// 期望:第二次 INSERT 失败 unique violation
}
func TestModerationActions_AdminIDZero_Allowed(t *testing.T) {
// 测试 chk_moderation_actions_admin_id (admin_id >= 0)
db.Exec(`INSERT INTO moderation_actions (..., admin_id=0, ...)`)
// 期望成功0 是 sentinel
}
func TestMTS_WarnClearedPreservesHistory(t *testing.T) {
// 测试 chk_mts_warn_fields 第 3 子句
db.Exec(`UPDATE moderation_target_status SET is_warned=FALSE, warn_count=3, last_warned_at=$now WHERE target_id=?`)
// 期望:成功(保留 warn_count + last_warned_at
}
```
- [ ] **Step 2: 跑测试**
```bash
cd backend/services/moderationService
go test ./repository/... -run TestReports -v
```
- [ ] **Step 3: 暂不 commit**
---
## 阶段 2Go moderationService 微服务
### Task 2.1: 创建服务目录骨架
**Files:**
- Create: `backend/services/moderationService/main.go` 等所有空文件 + `go.mod`
- Modify: `backend/go.work`
**参考模式:** 复制 `backend/services/socialService/` 全部结构,改服务名为 `moderation-service`
- [ ] **Step 1: 创建目录与 go.mod**
```bash
cd backend/services
cp -r socialService moderationService
cd moderationService
# 替换所有 "socialService" / "social" / "socialService" 为 "moderationService" / "moderation"
# 端口 20002 → 20010
# Dubbo 服务名 SocialService → ModerationService
```
- [ ] **Step 2: 修改 go.work**
`backend/go.work` 中追加:
```
./services/moderationService
```
- [ ] **Step 3: 编译验证**
```bash
cd backend/services/moderationService
go mod tidy
go build -o moderationService main.go
# 期望:编译通过
```
---
### Task 2.2: 写 proto 定义
**Files:**
- Create: `backend/proto/moderation.proto`
**关键 RPC**(按 spec §5.1 客户端 API
- `GetReportCategories` / `GetFeedbackCategories`
- `SubmitReport(SubmitReportRequest) returns (SubmitReportResponse)`
- `ListMyReports(ListMyReportsRequest) returns (ListMyReportsResponse)`
- `GetReport(GetReportRequest) returns (GetReportResponse)`
- `SubmitFeedback / ListMyFeedbacks / GetFeedback`
**关键字段**
```proto
message SubmitReportRequest {
string target_type = 1; // asset | user_profile
int64 target_id = 2;
string category_code = 3;
string description = 4; // <= 500
bool is_anonymous = 5;
repeated string evidence_keys = 6; // OSS keys, <= 5
}
message SubmitReportResponse {
int64 report_id = 1;
string status = 2; // pending | auto_hidden
bool auto_hidden = 3;
bool target_hidden = 4; // 本次 auto-hide 是否执行了 UPDATE
int64 claimed_by = 5; // 新工单始终 0
int64 claimed_at = 6;
int64 created_at = 7;
}
```
- [ ] **Step 1: 写 proto 文件**(按 spec §5.1 完整字段)
- [ ] **Step 2: 编译生成 .pb.go 和 .triple.go**
```bash
cd backend
# 参考现有 proto 编译流程
make proto-gen # 或按 gen-swagger.sh 同等脚本
```
- [ ] **Step 3: 验证生成产物**
```bash
ls backend/pkg/proto/moderation/
# 期望moderation.pb.go + moderation.triple.go 都在
```
---
### Task 2.3: 写 ORM 模型
**Files:**
- Create: `backend/pkg/models/moderation.go`
**关键模型**(按 spec §3 表结构):
```go
type Report struct {
ID int64 `gorm:"primaryKey"`
ReporterID int64
StarID *int64
TargetType string // asset | user_profile
TargetID int64
TargetSnapshot datatypes.JSON
TriggeredAutoHide bool
CategoryCode string
Description string
IsAnonymous bool
Status string // pending | reviewing | auto_hidden | resolved | dismissed | withdrawn
IsAutoHidden bool
ClaimedBy *int64
ClaimedAt *int64
ResolvedAction *string
ResolvedBy *int64
ResolvedAt *int64
ResolutionNote *string
CreatedAt int64
UpdatedAt int64
}
// 同模式ReportEvidence / Feedback / FeedbackEvidence / ModerationAction / ModerationTargetStatus / AdminAuditLog
```
**注意:** `*int64` 表示可空指针,对应 NULL 列。
---
### Task 2.4: 写仓储层 + 单测
**Files:**
- Create: `backend/services/moderationService/repository/moderation_repository.go`
**关键方法**
- `CreateReport(ctx, tx, report) error`
- `GetReportByID(ctx, id) (*Report, error)`
- `ClaimReport(ctx, tx, id, adminID, now) (claimed bool, error)` —— 用 affected rows 判断抢锁
- `ReleaseReport(ctx, tx, id, adminID, now) error`
- `DismissReportPathA(ctx, tx, id, adminID, now) error` —— pending → dismissed 快速通道
- `DismissReportPathB/C/D(ctx, tx, id, adminID, now, restore bool) error`
- `ResolveReport(ctx, tx, id, action, adminID, note, now) error`
- `ListReports(ctx, filters) ([]*Report, error)`
- `BulkDismissReports(ctx, tx, ids, adminID, reason, now) (successCount int, error)`
- `ForceReleaseReport(ctx, tx, id, operatorAdminID, reason, now) error`
- 同模式:`feedback_repository.go`
**关键 SQL 模式**
```go
// Claim - 用 affected rows 判定
res := tx.Model(&Report{}).
Where("id = ? AND status IN ?", id, []string{"pending", "auto_hidden"}).
Updates(map[string]interface{}{"status": "reviewing", "claimed_by": adminID, "claimed_at": now})
if res.Error != nil { return false, res.Error }
return res.RowsAffected == 1, nil
```
- [ ] **Step 1: 写仓储层**
- [ ] **Step 2: 写仓储测试**testcontainers 启临时 PG
- [ ] **Step 3: 跑测试**
```bash
cd backend/services/moderationService
go test ./repository/... -v
# 期望:全部 PASS
```
---
### Task 2.5: 写 Service 层
**Files:**
- Create: `backend/services/moderationService/service/{report,feedback,category,auto_hide,transaction_helper}_service.go`
- Create: `backend/services/moderationService/service/lua/auto_hide.lua`
**关键方法**
**`report_service.go`** — `SubmitReport`spec §6.1 完整 7 步):
```go
func (s *ReportService) SubmitReport(ctx, req) (*SubmitReportResponse, error) {
// 0. 限流:每用户/IP/device 每天 30/200/200 次
// 1. 验证分类、target_type、description、evidence
// 2. 验证目标存在assetClient.GetAssetRPC / userClient.GetUserRPC
// 2.5 自举报拦截reporter_id != owner_uid(target)
// 3. 防重复uk_reports_reporter_target_pending
// 4. 写 reports (status='pending', claimed_by/claimed_at=NULL)
// 5. 写 report_evidence
// 6. Redis Lua 计数 → 触发自动隐藏
// → 事务内:业务表软删除 + reports UPDATE auto_hidden + mts UPSERT + moderation_actions 流水
// 7. 返回 SubmitReportResponse
}
```
**`auto_hide_service.go`** — Lua 脚本spec §6.4
```lua
-- 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}
```
使用 `//go:embed` 加载:
```go
//go:embed lua/auto_hide.lua
var autoHideLuaScript string
```
**`auto_hide_service.go`** — 锁管理spec §6.4
```go
func (s *AutoHideService) tryAutoHide(ctx, targetType, targetID, triggerReportID) error {
ok, _ := s.redis.SetNX(ctx, "mod:report:lock:"+targetType+":"+targetID, "1", 5*time.Second)
if !ok { return nil } // 5s 内已有并发请求
defer s.redis.Del(ctx, "mod:report:lock:"+targetType+":"+targetID)
triggered, count := s.redis.Eval(ctx, autoHideLuaScript, ...).Result()
if triggered.(int64) != 1 { return nil }
// 事务内:业务表软删除 + reports UPDATE + mts UPSERT + moderation_actions 流水
return s.txHelper.WithTx(ctx, func(tx *gorm.DB) error {
// ① 业务表软删除(按 target_type 路由)
var affected int64
if targetType == "asset" {
tx.Model(&models.Asset{}).Where("id = ? AND is_active = true", targetID).
Updates(map[string]interface{}{"is_active": false, "deleted_at": now})
affected = tx.RowsAffected
} else { ... }
// ② reports UPDATE pending → auto_hidden
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),
})
// ③ mts UPSERT
tx.Clauses(clause.OnConflict{...}).Create(&models.ModerationTargetStatus{...})
// ③.5 写 moderation_actions 流水
actionType := "takedown"
if affected == 0 { actionType = "autohide_noop" }
tx.Create(&models.ModerationAction{
ReportID: triggerReportID, AdminID: 0, ActionType: actionType,
TargetType: targetType, TargetID: targetID,
Note: "auto-hide threshold reached", Success: true, CreatedAt: now,
})
return nil
})
}
```
**`feedback_service.go`** — 状态机 + claimed_* 一致性spec §6.3
- `SubmitFeedback` / `ListMyFeedbacks` / `GetFeedback`
- `ClaimFeedback` (认领) / `ReleaseFeedback` (释放,清 claimed_*)
- `ReplyFeedback` (终态,清 claimed_*) / `CloseFeedback` (终态,清 claimed_*) / `ArchiveFeedback` (终态,清 claimed_*)
**关键一致性**spec §4.2 chk_feedbacks_claimed_pair所有终态迁移必须同时清 `claimed_by=NULL, claimed_at=NULL`
- [ ] **Step 1: 写 transaction_helper.go**
```go
func (h *TxHelper) WithTx(ctx context.Context, fn func(*gorm.DB) error) error {
return h.db.WithContext(ctx).Transaction(fn)
}
```
- [ ] **Step 2: 写 report_service.go**
- [ ] **Step 3: 写 feedback_service.go**
- [ ] **Step 4: 写 auto_hide_service.go + Lua 脚本**
- [ ] **Step 5: 写 category_service.go**
- [ ] **Step 6: 跑 service 测试**
```bash
cd backend/services/moderationService
go test ./service/... -v
# 期望:全部 PASSclaimed_* 一致性测试、Lua 测试、并发测试都覆盖)
```
---
### Task 2.6: 写 Client 层RPC 客户端)
**Files:**
- Create: `backend/services/moderationService/client/{asset,user,notification,redis}_client.go`
**关键实现**
- `asset_client.go` — Dubbo 调用 `assetService.GetAssetRPC` 验证目标存在
- `user_client.go` — Dubbo 调用 `userService.GetUserRPC`
- `notification_client.go` — Dubbo 调用 `notificationService.SendNotification`(阶段 7.1 替换 stub 为真实现)
- `redis_client.go` — 初始化 Redis 客户端(参考 `aiChatService/main.go`
**参考:** `backend/services/socialService/client/user_rpc_client.go`
---
### Task 2.7: 写 Provider 层Dubbo 注册)
**Files:**
- Create: `backend/services/moderationService/provider/moderation_provider.go`
**关键实现**(参考 `social_provider.go`
```go
type ModerationProvider struct {
pb.UnimplementedModerationServiceServer
reportSvc *service.ReportService
feedbackSvc *service.FeedbackService
categorySvc *service.CategoryService
}
func (p *ModerationProvider) SubmitReport(ctx, req) (*pb.SubmitReportResponse, error) {
return p.reportSvc.SubmitReport(ctx, req)
}
// ... 其他 RPC 方法映射
```
- [ ] **Step 1: 写 provider**
- [ ] **Step 2: 在 main.go 注册 provider**
---
### Task 2.8: 完善 main.go 与启动脚本
**Files:**
- Modify: `backend/services/moderationService/main.go`
- Create: `backend/services/moderationService/start.sh`
- Create: `backend/services/moderationService/configs/dubbo.yaml`
**main.go** — 参考 `socialService/main.go`,加:
- 端口 20010
- `notificationServiceURL=tri://localhost:20008`(不指定 moderationService 自指)
- `DUBBO_USER_SERVICE_URL` / `DUBBO_ASSET_SERVICE_URL` / `DUBBO_NOTIFICATION_SERVICE_URL`
- 注册 Prometheus `/metrics`
- 注册 13+ 业务指标spec §11.3
**start.sh** — spec §10 阶段 2.1 模板PORT/DB/REDIS/DUBBO 全 env
- [ ] **Step 1: 写 main.go + start.sh + dubbo.yaml**
- [ ] **Step 2: 编译验证**
```bash
cd backend/services/moderationService
./start.sh # 期望build + start 成功,监听 20010
```
- [ ] **Step 3: metrics 端点验证**
```bash
curl http://localhost:20010/metrics | head -20
# 期望promhttp 输出 + 自定义指标
```
---
### Task 2.9: Gateway 集成HTTP 路由 + 客户端)
**Files:**
- Create: `backend/gateway/{controller,client,dto}/moderation_*.go`
- Modify: `backend/gateway/router/router.go`
**关键实现**(参考 `backend/gateway/controller/social_controller.go`
**`moderation_controller.go`**
```go
type ModerationController struct {
client *moderationClient.Client
}
func NewModerationController(cli *client.Client) (*ModerationController, error) {
moderationCli, err := moderationClient.NewModerationClient(cli)
if err != nil { return nil, err }
return &ModerationController{client: moderationCli}, 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: "描述过长(>500 字)"})
return
}
resp, err := c.client.SubmitReport(ctx, converter.ToRPCSubmitReport(req))
if err != nil { /* 错误码映射 */ }
ctx.JSON(200, converter.FromRPCSubmitReport(resp))
}
```
**`router.go`** 新增spec §阶段 2.10
```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)
```
- [ ] **Step 1: 写 controller + dto + converter + client**
- [ ] **Step 2: 在 router.go 注册路由**
- [ ] **Step 3: 启动 gatewaycurl 验证**
```bash
curl -X POST http://localhost:8080/api/v1/moderation/reports \
-H "Authorization: Bearer $TOKEN" \
-d '{"target_type":"asset","target_id":12345,"category_code":"pornographic","description":"test","is_anonymous":false,"evidence_keys":[]}'
# 期望:返回 report_id
```
---
### Task 2.10: 完善 main.go / docker / Makefile
**Files:**
- Modify: `docker/Dockerfile.services` / `docker/docker-compose.local.yml` / `docker/docker-compose.prod.yml` / `Makefile`
**关键改动**
- Dockerfile 加 `moderationservice` 构建阶段
- docker-compose 加服务定义(端口 20010、env
- Makefile 加 `start-moderationservice` target
---
## 阶段 3TopFans-activity-admin 后台 FastAPI
### Task 3.1: 创建模型 / Schema / CRUD
**Files:**
- Create: `backend/models/moderation.py`
- Create: `backend/schemas/moderation.py`
- Create: `backend/crud/moderation_crud.py`
**关键模型**9 张表对应 SQLAlchemy ORM按 spec §3 表结构):
```python
class Report(Base):
__tablename__ = "reports"
id = Column(BigInteger, primary_key=True)
reporter_id = Column(BigInteger, nullable=False)
star_id = Column(BigInteger)
target_type = Column(String(30), nullable=False)
target_id = Column(BigInteger, nullable=False)
target_snapshot = Column(JSON, nullable=False)
triggered_auto_hide = Column(Boolean, default=False)
# ... 其他字段
```
**关键 CRUD 函数**
- `list_reports(filters, page) -> (rows, total)`
- `get_report_detail(id) -> Report`
- `claim_report(id, admin_id, now) -> bool`(用 affected rows 抢锁)
- `release_report(id, admin_id, now)`
- `execute_action(id, action, admin_id, note, now)` —— 4 条 dismiss 路径 + takedown/ban/warn/restore
- `bulk_dismiss_reports(ids, admin_id, reason, now) -> int`
- `force_release_report(id, operator_admin_id, reason, now)`
- `clear_warn(user_id, admin_id, reason, now)` —— warn_cleared 流程
- 同模式:`feedback_crud.py`
- [ ] **Step 1: 写 ORM**
- [ ] **Step 2: 写 Pydantic Schema**
- [ ] **Step 3: 写 CRUD 函数**
---
### Task 3.2: 写 HandlerFastAPI 路由)
**Files:**
- Create: `backend/handlers/moderation_admin.py`
**关键端点**spec §5.2
- `GET /api/admin/moderation/reports` —— 列表filter: status/category/target_type/keyword/page/page_size
- `GET /api/admin/moderation/reports/{id}` —— 详情
- `POST /api/admin/moderation/reports/{id}/claim` —— 认领
- `POST /api/admin/moderation/reports/{id}/release` —— 释放
- `POST /api/admin/moderation/reports/{id}/actions` —— 审核动作
- `POST /api/admin/moderation/reports/bulk-dismiss` —— 批量驳回
- `POST /api/admin/moderation/reports/{id}/force-release` —— 强制释放
- `POST /api/admin/moderation/users/{id}/clear-warn` —— 清除警告
- `GET/POST/PUT/DELETE /api/admin/moderation/report-categories` —— 分类 CRUD
- 同模式feedback endpoints
- `GET /api/admin/moderation/stats` —— 看板
**关键依赖**
- `Depends(verify_token)` —— 复用现有 JWT 校验
- 解析 token → `(admin_id, role, exp)``admin_id` 写入 `moderation_actions.admin_id` / `admin_audit_logs.admin_id`
**关键错误处理**50008 vs 50009 vs 50014 区分 — spec §7
```python
# claim 0 行匹配时二次 SELECT 查 status
result = await db.execute(update(Report).where(Report.id == id, Report.status.in_(['pending','auto_hidden'])).values(status='reviewing', ...))
if result.rowcount == 0:
cur = await db.execute(select(Report).where(Report.id == id))
row = cur.scalar_one_or_none()
if row is None:
raise HTTPException(404, detail={"code": 50007, "message": "工单不存在"})
if row.status == 'reviewing' and row.claimed_by != admin_id:
raise HTTPException(409, detail={"code": 50008, "message": "工单已被他人认领", "claimed_by": row.claimed_by, "claimed_at": row.claimed_at})
if row.status in ['resolved','dismissed']:
raise HTTPException(400, detail={"code": 50009, "message": "工单已结案"})
```
- [ ] **Step 1: 写 handlers**
- [ ] **Step 2: 在 router/__init__.py 注册**
```python
from .moderation_admin import router as moderation_admin_router
admin_router.include_router(moderation_admin_router)
```
- [ ] **Step 3: 启动 FastAPI 验证**
```bash
cd TopFans-activity-admin
uvicorn backend.main:app --reload
curl http://localhost:8000/api/admin/moderation/reports -H "Authorization: Bearer $ADMIN_TOKEN"
# 期望:返回分页列表
```
---
### Task 3.3: 写单元测试
**Files:**
- Create: `backend/handlers/test_moderation_admin.py`
**关键测试**spec §7.3
- 列表/详情/认领/动作 happy path
- dismiss 4 路径分支覆盖
- 鉴权测试(无 token → 401
- 50008 vs 50009 区分
- Fixture`httpx.AsyncClient` + pytestadmin token 用现有 `make_test_token()`
- [ ] **Step 1: 写测试**
- [ ] **Step 2: 跑测试**
```bash
cd TopFans-activity-admin
pytest backend/handlers/test_moderation_admin.py -v
```
---
## 阶段 4后台前端 Vue3
### Task 4.1: API 封装
**Files:**
- Create: `frontend/src/api/moderation.js`
**关键函数**
```javascript
import request from '@/utils/request'
export function getReports(params) { return request({ url: '/api/admin/moderation/reports', method: 'get', params }) }
export function getReportDetail(id) { return request({ url: `/api/admin/moderation/reports/${id}`, method: 'get' }) }
export function claimReport(id) { return request({ url: `/api/admin/moderation/reports/${id}/claim`, method: 'post' }) }
export function releaseReport(id) { return request({ url: `/api/admin/moderation/reports/${id}/release`, method: 'post' }) }
export function executeAction(id, payload) { return request({ url: `/api/admin/moderation/reports/${id}/actions`, method: 'post', data: payload }) }
export function bulkDismissReports(ids, reason) { return request({ url: '/api/admin/moderation/reports/bulk-dismiss', method: 'post', data: { report_ids: ids, reason } }) }
export function forceReleaseReport(id, reason) { return request({ url: `/api/admin/moderation/reports/${id}/force-release`, method: 'post', data: { reason } }) }
// 同模式feedback API + categories API
```
**注意**:复用 `utils/request.js` 的 axios 拦截器(已含 JWT 注入)。
---
### Task 4.2: 举报工单列表页
**Files:**
- Create: `frontend/src/views/moderation/ReportList.vue`
**关键 UI**Element Plus
- 筛选栏:状态 select + 分类 select + 目标类型 select + 关键词 input + 搜索按钮
- el-table 列表report_id / target_type / target_id / category_code / status (el-tag) / claimed_by / created_at
- 分页el-pagination
- 行操作:详情按钮(跳转 detail 页)
---
### Task 4.3: 举报详情页 + 审核动作
**Files:**
- Create: `frontend/src/views/moderation/ReportDetail.vue`
**关键 UI**
- 顶部信息卡片report_id / status / target_snapshot (JSON 展示) / 举报人 / 分类 / 描述
- 证据图el-image preview
- 流水时间线el-timelinemoderation_actions 列表)
- 动作按钮组(按 status 显隐):
- `pending` / `auto_hidden`:认领
- `reviewing`(本人认领):下架/封禁/警告/驳回/释放
- 任何状态:详情查看
**关键交互**
- "认领"按钮 → `claimReport` → 成功后切换到 reviewing 模式,按钮组切换
- "执行动作" → 弹 el-dialog 输入 action/note/send_notification → `executeAction`
- 4 条 dismiss 路径在 UI 上对应不同选项("驳回(不恢复)"/"驳回(恢复隐藏对象)" 等)
---
### Task 4.4: 反馈列表/详情/分类管理页面
**Files:**
- Create: `frontend/src/views/moderation/{FeedbackList,FeedbackDetail,ReportCategoryConfig,FeedbackCategoryConfig,Dashboard}.vue`
按相同模式实现。Dashboard 可选spec §4 阶段 4.2 标记 optional
---
### Task 4.5: 路由注册 + 菜单
**Files:**
- Modify: `frontend/src/router/index.js`
- Modify: `frontend/src/layout/Layout.vue`
**router**
```javascript
{
path: '/moderation',
component: Layout,
children: [
{ path: 'reports', component: () => import('@/views/moderation/ReportList.vue'), meta: { title: '举报工单' } },
{ path: 'reports/:id', component: () => import('@/views/moderation/ReportDetail.vue'), meta: { title: '举报详情' }, hidden: true },
{ path: 'feedbacks', component: () => import('@/views/moderation/FeedbackList.vue'), meta: { title: '反馈工单' } },
{ path: 'feedbacks/:id', component: () => import('@/views/moderation/FeedbackDetail.vue'), meta: { title: '反馈详情' }, hidden: true },
{ path: 'categories/report', component: () => import('@/views/moderation/ReportCategoryConfig.vue'), meta: { title: '举报分类' } },
{ path: 'categories/feedback', component: () => import('@/views/moderation/FeedbackCategoryConfig.vue'), meta: { title: '反馈分类' } },
]
}
```
---
## 阶段 5客户端 uni-app
### Task 5.1: API 方法追加
**Files:**
- Modify: `frontend/utils/api.js`(末尾追加 8 个方法,不新建文件)
```javascript
export function getReportCategoriesApi() { return uni.request({ url: '/api/v1/moderation/report-categories', method: 'GET' }) }
export function submitReportApi(payload) { return uni.request({ url: '/api/v1/moderation/reports', method: 'POST', data: payload }) }
export function getMyReportsApi(params) { return uni.request({ url: '/api/v1/moderation/reports', method: 'GET', data: params }) }
export function getMyReportDetailApi(id) { return uni.request({ url: `/api/v1/moderation/reports/${id}`, method: 'GET' }) }
export function getFeedbackCategoriesApi() { return uni.request({ url: '/api/v1/moderation/feedback-categories', method: 'GET' }) }
export function submitFeedbackApi(payload) { return uni.request({ url: '/api/v1/moderation/feedbacks', method: 'POST', data: payload }) }
export function getMyFeedbacksApi(params) { return uni.request({ url: '/api/v1/moderation/feedbacks', method: 'GET', data: params }) }
export function getMyFeedbackDetailApi(id) { return uni.request({ url: `/api/v1/moderation/feedbacks/${id}`, method: 'GET' }) }
```
---
### Task 5.2: ReportModal 通用举报弹窗
**Files:**
- Create: `frontend/components/ReportModal.vue`
**关键实现**uView 2.x
- props: `targetType: 'asset' | 'user_profile'`, `targetId: Number`, `targetName: String`
- 表单u-form 包含分类 radio + 描述 textarea + u-upload (max 5) + 匿名 switch
- 提交:
- 调用 `submitReportApi`
- 双分支 toast
- `res.data.target_hidden === true``uni.showToast({ title: '已自动隐藏,等待审核', icon: 'none' })`
- 否则 → `uni.showToast({ title: '举报已提交', icon: 'success' })`
- 错误码处理50004/50011/50012 等):用 u-toast 显示 message
---
### Task 5.3: 接入到藏品详情 / 用户主页
**Files:**
- Modify: `frontend/components/NftDetailModal.vue` 等藏品详情组件
- Modify: `frontend/pages/user/userProfile.vue`
- 在 "..." 菜单加入"举报"选项,点击弹出 `ReportModal`
---
### Task 5.4: 意见反馈页 + 我的举报/反馈列表
**Files:**
- Create: `frontend/pages/profile/feedback.vue`
- Create: `frontend/pages/profile/myReports.vue`
- Create: `frontend/pages/profile/myFeedbacks.vue`
- Modify: `frontend/pages/profile/profile.vue`(菜单追加)
---
## 阶段 7通知模板 + 单元测试
### Task 7.1: 通知模板注册
**Files:**
- Modify: `backend/services/notificationService/model/notification.go``notification_repository.go`(按现有模板管理方式)
**6 个模板**spec §8
- `report_auto_hidden_notice` — 被举报方"内容已被自动隐藏"
- `report_resolved` — 举报人"举报已处理"
- `report_takedown_notice` — 被举报方"内容已被下架"
- `report_ban_notice` — 被举报用户"账号已被封禁"
- `report_warn_notice` — 被举报用户"已被警告"
- `feedback_replied` — 反馈人"反馈已回复"
**模板字段**`{reporter_nickname, target_type, target_id, action, reason, related_url}`
- [ ] **Step 1: 写 6 个模板到 notificationService**
- [ ] **Step 2: 在 moderationService `client/notification_client.go` 替换 v1.5 stub 为真实现**
---
### Task 7.2: Go 单元测试
**Files:**
- Create/Modify: `backend/services/moderationService/service/{report,feedback,auto_hide,concurrency}_test.go`
**关键测试覆盖**spec §7.2
- `report_service_test.go`防重复、自动隐藏触发、dismiss 4 路径、claimed_* 一致性
- `feedback_service_test.go`:状态机 + claimed_* 一致性
- `auto_hide_service_test.go`Lua 脚本逻辑
- `concurrency_test.go`
- 100 用户同时举报同对象counter INCR 但只触发 1 次自动隐藏
- 2 个审核员同时 claim 同 report1 成功 1 返回 50008
- 自动隐藏 Lua + 5s 短锁交互errgroup
- [ ] **Step 1: 写所有测试**
- [ ] **Step 2: 跑测试**
```bash
cd backend/services/moderationService
go test ./... -v -race
# 期望:全部 PASS
```
---
### Task 7.3: 集成测试testcontainers
**Files:**
- Create: `backend/services/moderationService/integration/`
**关键场景**spec §7.4
- 提交举报 → 后台审核 → 状态变更 → 通知
- 自动隐藏5 个独立用户举报 → 自动隐藏 + 状态表写入
- dismiss 4 路径覆盖
- 跨服务事务回滚:业务表软删除成功但 mts UPSERT 失败,验证整体回滚
**隔离策略**testcontainers-go 启临时 PG 16 + Redis 7业务表自动跑阶段 1.1 迁移Redis key 加 `test:mod:report:*` 前缀。
---
## 自审检查清单
完成后逐项验证:
- [ ] 所有手动 INSERT 都带 `SELECT setval`
- [ ] 9 个序列 `last_value >= MAX(id)`
- [ ] `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`
- [ ] OSS 白名单扩为 4 个值avatar/asset/report/feedback
- [ ] `asset_controller.go` `InitMintOrder` 按 type 分支
- [ ] 错误码 50008 vs 50009 vs 50014 在 handler 区分
- [ ] Lua 脚本并发 + 锁测试通过
- [ ] 6 个通知模板已注册
- [ ] Gateway 路由仅 `/api/v1/moderation/*`(后台 `/api/admin/*` 由 FastAPI 处理)
---
## 执行选项
**Plan complete and saved to `docs/superpowers/plans/2026-06-17-moderation-report-feedback-system.md`.**
由于本计划跨 2 个仓库、5 种技术栈、11 阶段,建议按以下两种方式之一执行:
**选项 1: 子计划拆分执行(推荐)**
按仓库/技术栈拆分为 4 个独立执行计划:
- Plan A (本仓 DB + Go service + Gateway):阶段 1, 2, 2.10
- Plan B (TopFans-activity-admin 后台 + 前端):阶段 3, 4
- Plan C (本仓 uni-app 客户端):阶段 5
- Plan D (通知 + 测试):阶段 7
每个 Plan 独立由 subagent-driven-development 派发执行。
**选项 2: 整体 Inline 执行**
按本计划 11 阶段顺序执行in this session with checkpoints
**请选择执行方式。**