1074 lines
42 KiB
Markdown
1074 lines
42 KiB
Markdown
# 举报与反馈系统实现计划
|
||
|
||
> **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)。
|
||
|
||
**请选择执行方式。**
|