# 举报与反馈系统实现计划 > **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:数据库 Schema(topfans 主仓) ### 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: 暂不 commit(CLAUDE.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** --- ## 阶段 2:Go 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 # 期望:全部 PASS(claimed_* 一致性测试、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: 启动 gateway,curl 验证** ```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 --- ## 阶段 3:TopFans-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: 写 Handler(FastAPI 路由) **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` + pytest;admin 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-timeline(moderation_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 同 report,1 成功 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)。 **请选择执行方式。**