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

42 KiB
Raw Blame History

举报与反馈系统实现计划

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 文档注释):

-- ============================================================================
-- 举报与反馈系统 (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=TRUEv2.5 W2

  • idx_moderation_actions_target (target_type, target_id, created_at DESC) WHERE target_type IS NOT NULLv1.7 H1 修复)

  • idx_feedbacks_claimed_by (claimed_by, status, created_at DESC) WHERE claimed_by IS NOT NULL

  • Step 2: 验证 SQL 语法

# 用 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 个序列都同步
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 迁移后跑:

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: 跑测试
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
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: 编译验证
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

关键字段

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

cd backend
# 参考现有 proto 编译流程
make proto-gen  # 或按 gen-swagger.sh 同等脚本
  • Step 3: 验证生成产物
ls backend/pkg/proto/moderation/
# 期望moderation.pb.go + moderation.triple.go 都在

Task 2.3: 写 ORM 模型

Files:

  • Create: backend/pkg/models/moderation.go

关键模型(按 spec §3 表结构):

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 模式

// 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: 跑测试

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.goSubmitReportspec §6.1 完整 7 步):

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

-- 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:embed lua/auto_hide.lua
var autoHideLuaScript string

auto_hide_service.go — 锁管理spec §6.4

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
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 测试

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

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: 编译验证

cd backend/services/moderationService
./start.sh  # 期望build + start 成功,监听 20010
  • Step 3: metrics 端点验证
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

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

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 验证

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 表结构):

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

# 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 注册

from .moderation_admin import router as moderation_admin_router
admin_router.include_router(moderation_admin_router)
  • Step 3: 启动 FastAPI 验证
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 区分

  • Fixturehttpx.AsyncClient + pytestadmin token 用现有 make_test_token()

  • Step 1: 写测试

  • Step 2: 跑测试

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

关键函数

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

关键 UIElement 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

{
  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 个方法,不新建文件)
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 === trueuni.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.gonotification_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.goLua 脚本逻辑

  • concurrency_test.go

    • 100 用户同时举报同对象counter INCR 但只触发 1 次自动隐藏
    • 2 个审核员同时 claim 同 report1 成功 1 返回 50008
    • 自动隐藏 Lua + 5s 短锁交互errgroup
  • Step 1: 写所有测试

  • Step 2: 跑测试

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

请选择执行方式。