1609 lines
120 KiB
Markdown
1609 lines
120 KiB
Markdown
# 举报与反馈系统设计
|
||
|
||
> **状态**:设计已确认 ✅
|
||
> **创建时间**:2026-06-11
|
||
> **作者**:Claude
|
||
> **目标**:在 TopFans 平台新增内容举报与用户反馈两大工单系统,支撑运营/审核员通过独立 web 后台对违规内容(数字藏品/用户/AI 描述词)和用户反馈(BUG/咨询/合作/建议)进行审核处理
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [背景与目标](#1-背景与目标)
|
||
2. [架构概览](#2-架构概览)
|
||
3. [数据模型](#3-数据模型)
|
||
4. [状态机](#4-状态机)
|
||
5. [API 设计](#5-api-设计)
|
||
6. [关键流程](#6-关键流程)
|
||
7. [错误码](#7-错误码)
|
||
8. [通知机制](#8-通知机制)
|
||
9. [安全与防护](#9-安全与防护)
|
||
10. [实施步骤](#10-实施步骤)
|
||
11. [附录](#11-附录)
|
||
|
||
---
|
||
|
||
## 1. 背景与目标
|
||
|
||
### 1.1 背景
|
||
|
||
TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
|
||
- 用户对违规内容(藏品/用户/AI 描述词)的举报通道
|
||
- 用户对平台体验问题的反馈通道
|
||
- 运营/审核员对举报和反馈的审核处理后台
|
||
|
||
### 1.2 目标
|
||
|
||
- 提供客户端(uni-app)举报与反馈的提交入口
|
||
- 提供运营 web 后台(基于 `D:\shanghai\TopFans-activity`)的工单审核界面
|
||
- 支持"举报达到阈值自动隐藏 + 管理员审核动作(标记状态)"的闭环
|
||
- 设计可扩展、易维护、易观测
|
||
|
||
### 1.3 范围
|
||
|
||
**包含**:
|
||
- 客户端提交接口(uni-app 端)
|
||
- 审核员后台(Vue3 + Element Plus)
|
||
- 数据模型与迁移
|
||
- 状态机、自动隐藏、动作流水
|
||
- 通知机制
|
||
|
||
**不包含(YAGNI)**:
|
||
- 举报人/被举报人间的对话沟通
|
||
- 申诉/上诉流程
|
||
- 举报人积分/奖励体系
|
||
- 复杂的多级管理员(仅一种角色)
|
||
- 工单 SLA 监控告警
|
||
|
||
---
|
||
|
||
## 2. 架构概览
|
||
|
||
### 2.1 整体架构
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ 客户端 (uni-app) │
|
||
│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │
|
||
│ │ 举报弹窗 (藏品/用户/描述词)│ │ 我的 → 意见反馈 │ │
|
||
│ └────────────┬────────────┘ └────────────┬─────────────────┘ │
|
||
└───────────────┼──────────────────────────────┼──────────────────┘
|
||
│ REST │
|
||
▼ ▼
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ topfans gateway (Go, 已有) │
|
||
│ /api/v1/moderation/* 客户端 API │
|
||
└───────────────────────────┬──────────────────────────────────────┘
|
||
│ Dubbo RPC
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ topfans moderationService (Go, 新建微服务) │
|
||
│ - ReportService / FeedbackService / CategoryService │
|
||
│ - 写 PostgreSQL reports/feedbacks/categories/状态表 │
|
||
│ - Redis 计数 (自动隐藏阈值) │
|
||
│ - 触发自动隐藏:业务表软删除 + UPDATE reports + UPSERT moderation_target_status│
|
||
└────────┬─────────────────────────────────────────┬───────────────┘
|
||
│ 共享 PG 库 │
|
||
│ │
|
||
▼ ▼
|
||
┌────────────────────────────┐ ┌──────────────────────────────────┐
|
||
│ PostgreSQL (topfans 库) │ │ Redis (topfans 已有实例) │
|
||
│ - report_categories │ │ - mod:report:counter:* │
|
||
│ - feedback_categories │ │ - mod:report:user:* │
|
||
│ - reports │ │ - mod:report:lock:* │
|
||
│ - report_evidence │ └──────────────────────────────────┘
|
||
│ - feedbacks │
|
||
│ - feedback_evidence │
|
||
│ - moderation_target_status│ ← 审计追溯 + 警告状态表(下架/封禁由业务表 is_active/deleted_at 软删除承担)
|
||
│ - moderation_actions │ │
|
||
│ - admin_audit_logs │ │
|
||
└────────┬───────────────────┘
|
||
│ 共享 PG 库 (直接读写,不调对方接口)
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ TopFans-Activity 后台 (FastAPI + Vue3, 已有项目) │
|
||
│ D:\shanghai\TopFans-activity │
|
||
│ - backend/handlers/moderation_admin.py (后台 API) │
|
||
│ - backend/crud/moderation_crud.py │
|
||
│ - backend/schemas/moderation.py │
|
||
│ - frontend/src/views/moderation/ (举报/反馈工单 UI) │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 模块职责
|
||
|
||
| 模块 | 职责 |
|
||
|------|------|
|
||
| `ReportService` (Go) | 接收举报、查询我的举报、触发自动隐藏 |
|
||
| `FeedbackService` (Go) | 接收反馈、查询我的反馈 |
|
||
| `CategoryService` (Go+FastAPI) | 维护分类(动态配置),客户端读取 + 后台 CRUD |
|
||
| `AutoHideExecutor` (Go) | Redis 计数 + 触发自动隐藏(写 reports + 状态表) |
|
||
| 后台 Moderation Admin (FastAPI) | 审核员认领/动作/分类管理,**只读写 PostgreSQL,不操作 Redis** |
|
||
| `moderation_target_status` 表 | 审计追溯 + 警告状态;下架/封禁由业务表 `is_active` + `deleted_at` 软删除承担 |
|
||
|
||
**关键边界**:
|
||
- `TopFans-Activity` 后台**不调用** `moderationService` 的任何 RPC / Dubbo / HTTP 接口,**不操作 Redis**;仅通过共享 PostgreSQL 完成所有读写
|
||
- 唯一的跨服务依赖是:客户端 API 通过 gateway → moderationService(Dubbo RPC),这是项目既有的调用模式
|
||
- 业务下架/封禁的"实际生效"完全通过业务表自带的 `is_active` + `deleted_at` 软删除字段实现(读路径 `WHERE is_active=TRUE AND deleted_at IS NULL`),`moderation_target_status` 仅承担审计追溯 + 用户警告状态,不参与运行时过滤
|
||
- 如后台需查询"实时计数"等 Redis 状态,由 Go service 异步定时把 Redis 数据回写到 PostgreSQL 一张快照表(`moderation_counter_snapshot`),后台查快照表即可——**这是唯一允许的"Redis → PG"数据流,且单向**
|
||
|
||
### 2.3 复用现有资产
|
||
|
||
- **OSS 上传**:举报证据图、反馈截图复用 `assetService` 的 OSS 签名接口(`GET /api/v1/assets/oss/signature?type=asset`)
|
||
- **认证**:客户端 JWT 用 topfans userService;后台 JWT 用 `TopFans-activity` 已有的 `verify_token`
|
||
- **通知中心**:处理结果通过 topfans `notificationService` 模板下发(新增 6 个模板)
|
||
- **数据迁移**:遵循 topfans 现有 `backend/migrations/00x_*.sql` 命名规范
|
||
- **序列同步**:手动指定 ID 时必须 `SELECT setval('table_id_seq', MAX(id))`(CLAUDE.md 强制规范)
|
||
|
||
---
|
||
|
||
## 3. 数据模型
|
||
|
||
> **全局约定**:本设计所有 `*_at` 时间戳字段(`created_at` / `updated_at` / `claimed_at` / `resolved_at` / `last_warned_at` 等)均为 **Unix 毫秒** (`BIGINT`),与 Go `time.Now().UnixMilli()` 一致。SQL 中用 `EXTRACT(EPOCH FROM NOW())*1000` 计算(PG 隐式 `numeric→bigint` 转换)。
|
||
|
||
### 3.1 核心表
|
||
|
||
```
|
||
report_categories 举报分类(动态配置)
|
||
feedback_categories 反馈分类(动态配置)
|
||
reports 举报工单主表
|
||
report_evidence 举报证据图(一对多)
|
||
feedbacks 反馈工单主表
|
||
feedback_evidence 反馈截图(一对多)
|
||
moderation_actions 审核动作流水
|
||
moderation_target_status 共享"对象受管控状态"表(审计 + 警告;下架/封禁由业务表软删除承担)
|
||
admin_audit_logs 管理员操作日志
|
||
```
|
||
|
||
### 3.2 `report_categories` 举报分类
|
||
|
||
```sql
|
||
CREATE SEQUENCE report_categories_id_seq START WITH 10000 OWNED BY report_categories.id;
|
||
|
||
-- 示例:Unix 毫秒(参见 3 节约定)。`EXTRACT(EPOCH FROM NOW())*1000` 产生 `numeric`,PG 隐式转为 `bigint`
|
||
|
||
-- 举报分类字典(动态配置,后台 CRUD;客户端 GET 只读 enabled=TRUE 行)
|
||
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)
|
||
);
|
||
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));
|
||
```
|
||
|
||
### 3.3 `feedback_categories` 反馈分类
|
||
|
||
```sql
|
||
CREATE SEQUENCE feedback_categories_id_seq START WITH 10000 OWNED BY feedback_categories.id;
|
||
|
||
-- 反馈分类字典(动态配置)
|
||
CREATE TABLE feedback_categories (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
code VARCHAR(50) NOT NULL UNIQUE,
|
||
name VARCHAR(50) NOT NULL,
|
||
description VARCHAR(200),
|
||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL
|
||
);
|
||
|
||
INSERT INTO feedback_categories (code, name, description, sort_order, created_at, updated_at) VALUES
|
||
('bug', 'BUG 报告', '使用中遇到的问题', 1, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
|
||
('consult', '使用咨询', '不知道怎么用', 2, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
|
||
('business', '内容合作', '合作/商务联系', 3, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
|
||
('suggestion', '功能建议', '希望增加什么功能', 4, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000);
|
||
|
||
SELECT setval('feedback_categories_id_seq', (SELECT MAX(id) FROM feedback_categories));
|
||
```
|
||
|
||
### 3.4 `reports` 举报工单
|
||
|
||
```sql
|
||
CREATE SEQUENCE reports_id_seq START WITH 10000 OWNED BY reports.id;
|
||
|
||
-- 举报工单主表(5 个状态:pending/reviewing/auto_hidden/resolved/dismissed)
|
||
CREATE TABLE reports (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
reporter_id BIGINT NOT NULL,
|
||
star_id BIGINT,
|
||
target_type VARCHAR(30) NOT NULL, -- asset | user_profile(v1.4 砍掉 description_word:AI 描述词即 assets.description 字段,举报 AI 描述词走 asset 路径)
|
||
target_id BIGINT NOT NULL,
|
||
|
||
target_snapshot JSONB NOT NULL,
|
||
-- v2.4 schema 定义(必填字段):
|
||
-- target_type='asset' → {"asset_id": BIGINT, "owner_uid": BIGINT, "name": VARCHAR, "cover_url": VARCHAR, "description": TEXT, "star_id": BIGINT, "snapshot_at": BIGINT}
|
||
-- target_type='user_profile' → {"user_id": BIGINT, "nickname": VARCHAR, "avatar_url": VARCHAR, "star_id": BIGINT, "snapshot_at": BIGINT}
|
||
-- `snapshot_at` = Unix 毫秒(提交时刻快照,后续 star binding / ownership 变更不回溯)
|
||
-- `target_owner_uid_at_submit` 已 DROP(v2.4 移除,v2.3 列为死列——target_snapshot 内已有 owner_uid 字段;通知路由改为通过 target_snapshot.owner_uid 读取)
|
||
triggered_auto_hide BOOLEAN NOT NULL DEFAULT FALSE,
|
||
-- v2.4:仅触发 auto-hide 阈值的那个 report 置 TRUE;同 target 其它 pending report 升级为 auto_hidden 时保持 FALSE
|
||
-- 6.1 step 6 ② UPDATE 显式 `triggered_auto_hide = (id = $trigger_report_id)`
|
||
-- 用于审计 "哪个 report 是阈值触发者" vs "哪些是连带升级"
|
||
|
||
category_code VARCHAR(50) NOT NULL,
|
||
description VARCHAR(500),
|
||
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
|
||
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||
-- pending / reviewing / auto_hidden / resolved / dismissed
|
||
is_auto_hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||
-- 标记本工单是否曾经因阈值被自动隐藏:自动隐藏触发时置 TRUE;后
|
||
-- 续 auto_hidden → reviewing → resolved/dismissed 保持 TRUE 不变,
|
||
-- 供审计追溯"该工单经历过自动隐藏流程"。不要在状态机迁移时重置。
|
||
|
||
claimed_by BIGINT, -- 当前审核认领人(reviewing 期间非空,用于 50008 "已被他人认领" 返回认领人身份)
|
||
claimed_at BIGINT,
|
||
|
||
resolved_action VARCHAR(20), -- takedown | ban | warn | dismiss(动作名,区别于状态名 dismissed)
|
||
resolved_by BIGINT,
|
||
resolved_at BIGINT,
|
||
resolution_note VARCHAR(500),
|
||
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL,
|
||
|
||
CONSTRAINT fk_reports_category FOREIGN KEY (category_code)
|
||
REFERENCES report_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||
CONSTRAINT chk_reports_status
|
||
CHECK (status IN ('pending','reviewing','auto_hidden','resolved','dismissed','withdrawn','archived')),
|
||
-- v2.4 加 'withdrawn'(客户端 DELETE 撤回)和 'archived'(6.5 cron 90 天 auto-archive)
|
||
CONSTRAINT chk_reports_target_type
|
||
CHECK (target_type IN ('asset','user_profile')),
|
||
CONSTRAINT chk_reports_resolved_action
|
||
CHECK (resolved_action IS NULL OR resolved_action IN ('takedown','ban','warn','dismiss')),
|
||
CONSTRAINT chk_reports_claimed_pair
|
||
CHECK (
|
||
(status = 'reviewing' AND claimed_by IS NOT NULL AND claimed_at IS NOT NULL)
|
||
OR (status <> 'reviewing' AND claimed_by IS NULL AND claimed_at IS NULL)
|
||
),
|
||
CONSTRAINT chk_reports_resolved_fields
|
||
CHECK (
|
||
(status IN ('resolved','dismissed')
|
||
AND resolved_action IS NOT NULL
|
||
AND resolved_by IS NOT NULL
|
||
AND resolved_at IS NOT NULL)
|
||
OR
|
||
(status IN ('pending','reviewing','auto_hidden')
|
||
AND resolved_action IS NULL
|
||
AND resolved_by IS NULL
|
||
AND resolved_at IS NULL)
|
||
)
|
||
);
|
||
-- 状态机迁移 + 后台列表(status 必带);created_at DESC 倒序扫描
|
||
CREATE INDEX idx_reports_status_created ON reports(status, created_at DESC);
|
||
-- "某 target 的所有举报" 反查(用于统计/审计)
|
||
CREATE INDEX idx_reports_target ON reports(target_type, target_id, status);
|
||
-- "我的举报" 列表
|
||
CREATE INDEX idx_reports_reporter ON reports(reporter_id, created_at DESC);
|
||
-- 后台按分类筛选(11.3 监控维度 category_code)
|
||
CREATE INDEX idx_reports_category ON reports(category_code, status, created_at DESC);
|
||
-- 后台按明星筛选
|
||
CREATE INDEX idx_reports_star ON reports(star_id, status, created_at DESC)
|
||
WHERE star_id IS NOT NULL;
|
||
-- 阈值触发者查询(v2.5 新增):"哪个 report 触发了 asset X 的 auto-hide" / "30 天内阈值触发列表"
|
||
CREATE INDEX idx_reports_triggered_autohide
|
||
ON reports(target_type, target_id, created_at DESC)
|
||
WHERE triggered_auto_hide = TRUE;
|
||
|
||
-- 防重复:同一举报人对同一对象在未结案前只能有一条
|
||
CREATE UNIQUE INDEX uk_reports_reporter_target_pending
|
||
ON reports(reporter_id, target_type, target_id)
|
||
WHERE status IN ('pending', 'reviewing', 'auto_hidden');
|
||
```
|
||
|
||
### 3.5 `report_evidence` 举报证据图
|
||
|
||
```sql
|
||
CREATE SEQUENCE report_evidence_id_seq START WITH 10000 OWNED BY report_evidence.id;
|
||
|
||
CREATE TABLE report_evidence (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
report_id BIGINT NOT NULL,
|
||
oss_key VARCHAR(255) NOT NULL,
|
||
oss_url VARCHAR(500),
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at BIGINT NOT NULL,
|
||
CONSTRAINT fk_report_evidence_report
|
||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
|
||
);
|
||
CREATE INDEX idx_report_evidence_report ON report_evidence(report_id, sort_order);
|
||
```
|
||
|
||
### 3.6 `feedbacks` 反馈工单
|
||
|
||
```sql
|
||
CREATE SEQUENCE feedbacks_id_seq START WITH 10000 OWNED BY feedbacks.id;
|
||
|
||
CREATE TABLE feedbacks (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL,
|
||
star_id BIGINT,
|
||
category_code VARCHAR(50) NOT NULL,
|
||
title VARCHAR(100) NOT NULL,
|
||
content TEXT NOT NULL,
|
||
contact VARCHAR(320), -- RFC 5321 完整邮箱地址最大长度 64 (local) + 1 (@) + 255 (domain) = 320;兼容手机/微信/邮箱
|
||
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
|
||
|
||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||
-- pending / reviewing / replied / closed / archived
|
||
|
||
claimed_by BIGINT, -- 当前审核认领人(reviewing 期间非空)
|
||
claimed_at BIGINT,
|
||
replied_by BIGINT,
|
||
replied_at BIGINT,
|
||
reply_content TEXT,
|
||
closed_by BIGINT, -- 终态 close 操作人
|
||
closed_at BIGINT,
|
||
archived_by BIGINT, -- 终态 archive 操作人
|
||
archived_at BIGINT,
|
||
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL,
|
||
|
||
CONSTRAINT fk_feedbacks_category FOREIGN KEY (category_code)
|
||
REFERENCES feedback_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||
CONSTRAINT chk_feedbacks_status
|
||
CHECK (status IN ('pending','reviewing','replied','closed','archived')),
|
||
CONSTRAINT chk_feedbacks_claimed_pair
|
||
CHECK (
|
||
(status = 'reviewing' AND claimed_by IS NOT NULL AND claimed_at IS NOT NULL)
|
||
OR (status <> 'reviewing' AND claimed_by IS NULL AND claimed_at IS NULL)
|
||
),
|
||
CONSTRAINT chk_feedbacks_replied_fields
|
||
CHECK (
|
||
(status = 'replied'
|
||
AND replied_by IS NOT NULL
|
||
AND replied_at IS NOT NULL
|
||
AND reply_content IS NOT NULL)
|
||
OR
|
||
(status <> 'replied'
|
||
AND replied_by IS NULL
|
||
AND replied_at IS NULL
|
||
AND reply_content IS NULL)
|
||
),
|
||
CONSTRAINT chk_feedbacks_closed_fields
|
||
CHECK (
|
||
(status = 'closed' AND closed_by IS NOT NULL AND closed_at IS NOT NULL)
|
||
OR (status <> 'closed' AND closed_by IS NULL AND closed_at IS NULL)
|
||
),
|
||
CONSTRAINT chk_feedbacks_archived_fields
|
||
CHECK (
|
||
(status = 'archived' AND archived_by IS NOT NULL AND archived_at IS NOT NULL)
|
||
OR (status <> 'archived' AND archived_by IS NULL AND archived_at IS NULL)
|
||
)
|
||
);
|
||
CREATE INDEX idx_feedbacks_status_created ON feedbacks(status, created_at DESC);
|
||
CREATE INDEX idx_feedbacks_user ON feedbacks(user_id, created_at DESC);
|
||
CREATE INDEX idx_feedbacks_category ON feedbacks(category_code, status);
|
||
CREATE INDEX idx_feedbacks_claimed_by ON feedbacks(claimed_by, status, created_at DESC)
|
||
WHERE claimed_by IS NOT NULL;
|
||
CREATE INDEX idx_feedbacks_star ON feedbacks(star_id, status, created_at DESC)
|
||
WHERE star_id IS NOT NULL;
|
||
```
|
||
|
||
### 3.7 `feedback_evidence` 反馈截图
|
||
|
||
```sql
|
||
CREATE SEQUENCE feedback_evidence_id_seq START WITH 10000 OWNED BY feedback_evidence.id;
|
||
|
||
CREATE TABLE feedback_evidence (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
feedback_id BIGINT NOT NULL,
|
||
oss_key VARCHAR(255) NOT NULL,
|
||
oss_url VARCHAR(500),
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at BIGINT NOT NULL,
|
||
CONSTRAINT fk_feedback_evidence_feedback
|
||
FOREIGN KEY (feedback_id) REFERENCES feedbacks(id) ON DELETE CASCADE
|
||
);
|
||
CREATE INDEX idx_feedback_evidence_feedback ON feedback_evidence(feedback_id, sort_order);
|
||
```
|
||
|
||
### 3.8 `moderation_actions` 审核动作流水
|
||
|
||
```sql
|
||
CREATE SEQUENCE moderation_actions_id_seq START WITH 10000 OWNED BY moderation_actions.id;
|
||
|
||
-- 审核动作流水(永久保留,审计追溯;admin 任何操作都需写一行)
|
||
CREATE TABLE moderation_actions (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
report_id BIGINT,
|
||
feedback_id BIGINT,
|
||
admin_id BIGINT NOT NULL,
|
||
action_type VARCHAR(30) NOT NULL, -- takedown | restore | ban | warn | dismiss | reply | close | archive
|
||
target_type VARCHAR(30), -- asset | user_profile(与 reports/feedbacks.target_type 统一)
|
||
target_id BIGINT,
|
||
note VARCHAR(500),
|
||
success BOOLEAN NOT NULL,
|
||
error_message VARCHAR(500),
|
||
created_at BIGINT NOT NULL,
|
||
CONSTRAINT chk_moderation_actions_xor
|
||
CHECK (
|
||
(report_id IS NOT NULL) <> (feedback_id IS NOT NULL)
|
||
OR (report_id IS NULL AND feedback_id IS NULL)
|
||
),
|
||
CONSTRAINT chk_moderation_actions_admin_id
|
||
CHECK (admin_id >= 0), -- 0 保留为"系统自动" sentinel(auto-hide);真实 admin 必 ≥ 1
|
||
CONSTRAINT chk_moderation_actions_action_type
|
||
CHECK (action_type IN (
|
||
'takedown','restore','ban','warn','dismiss','reply','close','archive',
|
||
'autohide_noop', -- v2.4:auto-hide 时目标已 is_active=false 的 no-op 审计
|
||
'withdraw', -- v2.4:客户端 DELETE 撤回
|
||
'force_release' -- v2.4:admin crash 后强制释放他人认领
|
||
)),
|
||
CONSTRAINT chk_moderation_actions_target_pair
|
||
CHECK (
|
||
(target_type IS NULL AND target_id IS NULL)
|
||
OR (target_type IS NOT NULL AND target_id IS NOT NULL
|
||
AND target_type IN ('asset','user_profile'))
|
||
),
|
||
CONSTRAINT chk_moderation_actions_success_msg
|
||
CHECK ((success = TRUE AND error_message IS NULL) OR (success = FALSE AND error_message IS NOT NULL))
|
||
);
|
||
CREATE INDEX idx_moderation_actions_report ON moderation_actions(report_id, created_at DESC);
|
||
CREATE INDEX idx_moderation_actions_feedback ON moderation_actions(feedback_id, created_at DESC);
|
||
CREATE INDEX idx_moderation_actions_admin ON moderation_actions(admin_id, created_at DESC);
|
||
-- "某 target 的所有动作历史" 反查(如 audit trail / 后台详情页加载流水)
|
||
-- 必须 target_type 前缀:`target_id` 跨类型不唯一(users.id 与 assets.id 数值会冲突)
|
||
CREATE INDEX idx_moderation_actions_target
|
||
ON moderation_actions(target_type, target_id, created_at DESC)
|
||
WHERE target_type IS NOT NULL;
|
||
```
|
||
|
||
### 3.9 `moderation_target_status` 共享"对象受管控状态"
|
||
|
||
```sql
|
||
CREATE SEQUENCE moderation_target_status_id_seq START WITH 10000 OWNED BY moderation_target_status.id;
|
||
|
||
-- 对象受管控状态(审计 + 警告;下架/封禁由业务表 is_active + deleted_at 软删除承担)
|
||
CREATE TABLE moderation_target_status (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
target_type VARCHAR(30) NOT NULL, -- asset | user_profile(与 reports.target_type 统一)
|
||
target_id BIGINT NOT NULL,
|
||
|
||
-- ⚠️ 不存 is_hidden / is_banned 字段:项目统一通过业务表自带的
|
||
-- `is_active` + `deleted_at` 软删除字段表达"下架/封禁"状态。
|
||
-- 审核动作(takedown/ban/auto-hide)的"实际生效"由 moderationService
|
||
-- 在业务表上执行标准软删除(UPDATE ... SET is_active=false, deleted_at=now);
|
||
-- dismiss 自动隐藏工单时执行恢复(SET is_active=true, deleted_at=NULL)。
|
||
is_warned BOOLEAN NOT NULL DEFAULT FALSE, -- 仅 user_profile:仍活跃但已被警告(软状态,不阻塞登录)
|
||
warn_count INT NOT NULL DEFAULT 0, -- 仅 user_profile:累计警告次数
|
||
last_warned_at BIGINT, -- 仅 user_profile:最近一次警告时间戳
|
||
|
||
last_action_type VARCHAR(30) NOT NULL, -- takedown | ban | warn | restore | autohide | dismiss(最近一次动作)
|
||
reason VARCHAR(200),
|
||
source VARCHAR(30) NOT NULL, -- auto | admin
|
||
source_report_id BIGINT, -- auto 必填;admin 可空(多工单聚合场景)
|
||
operator_admin_id BIGINT,
|
||
|
||
created_at BIGINT NOT NULL,
|
||
updated_at BIGINT NOT NULL,
|
||
|
||
CONSTRAINT uk_moderation_target UNIQUE (target_type, target_id),
|
||
CONSTRAINT chk_mts_target_type
|
||
CHECK (target_type IN ('asset','user_profile')),
|
||
CONSTRAINT chk_mts_source
|
||
CHECK (source IN ('auto','admin')),
|
||
CONSTRAINT chk_mts_source_report_id
|
||
CHECK (source <> 'auto' OR source_report_id IS NOT NULL),
|
||
CONSTRAINT chk_mts_last_action_type
|
||
CHECK (last_action_type IN ('takedown','ban','warn','restore','autohide','dismiss','warn_cleared')),
|
||
-- v2.4 加 'warn_cleared'(v2.2 B2 warn_cleared 流程——否则 v2.2 warn_cleared UPDATE 直接被 CHECK 拒)
|
||
CONSTRAINT chk_mts_warn_fields
|
||
CHECK (
|
||
(target_type = 'user_profile'
|
||
-- 三种合法状态:从未警告 / 当前警告中 / 历史警告已清除(保留计数与时间戳)
|
||
AND (
|
||
(is_warned = FALSE AND warn_count = 0 AND last_warned_at IS NULL)
|
||
OR (is_warned = TRUE AND warn_count >= 1 AND last_warned_at IS NOT NULL)
|
||
OR (is_warned = FALSE AND warn_count >= 1 AND last_warned_at IS NOT NULL)
|
||
))
|
||
OR (target_type = 'asset'
|
||
AND is_warned = FALSE AND warn_count = 0 AND last_warned_at IS NULL)
|
||
)
|
||
);
|
||
CREATE INDEX idx_mts_warned_user ON moderation_target_status(target_id)
|
||
WHERE is_warned = TRUE AND target_type = 'user_profile';
|
||
CREATE INDEX idx_mts_source_report ON moderation_target_status(source_report_id, target_type, target_id)
|
||
WHERE source_report_id IS NOT NULL;
|
||
```
|
||
|
||
**写入方(双重写入)**:
|
||
- `moderationService` (Go) — 自动隐藏/解除/警告时:
|
||
1. **业务表软删除/恢复/警告计数**(按 target_type 路由到对应表):
|
||
- `target_type='asset'` → `UPDATE assets SET is_active=false, deleted_at=$now WHERE id=$id`(隐藏/下架;AI 描述词作为 `assets.description` 字段的一部分同表软删除)
|
||
- `target_type='user_profile'` ban/takedown → `UPDATE users SET is_active=false, deleted_at=$now WHERE id=$id`
|
||
- `target_type='user_profile'` warn → `UPDATE moderation_target_status SET is_warned=true, warn_count=warn_count+1, last_warned_at=$now`(或同步加到 users.warn_count)
|
||
- dismiss(自动隐藏已隐藏时)→ `UPDATE ... SET is_active=true, deleted_at=NULL WHERE id=$id`(恢复)
|
||
2. **moderation_target_status UPSERT**(审计追溯 + last_action_type)
|
||
- `TopFans-Activity` 后台 (FastAPI) — 审核员手动下架/封禁/警告时同样执行"业务表 + 状态表"双重写入(事务保证一致,失败时回滚)
|
||
|
||
**读取方(业务表自带软删除字段已足够,`moderation_target_status` 仅用于审计/警告)**:
|
||
- `assetService` 藏品查询/详情/列表:**`WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL`**(沿用既有软删除过滤,无须 JOIN `moderation_target_status`;AI 描述词即 `assets.description`,随 asset 一起过滤)
|
||
- `userService` 登录/操作校验:**`WHERE users.is_active = TRUE AND users.deleted_at IS NULL`**
|
||
- 警告提示(仅用户主页展示):`LEFT JOIN moderation_target_status ON target_type='user_profile' AND target_id=users.id WHERE COALESCE(is_warned, false) = true` 拉取最近一次警告原因/时间
|
||
|
||
**目标对象 target_type 定义与位置**:
|
||
| target_type | 对应表 | target_id 含义 |
|
||
|-------------|--------|----------------|
|
||
| `asset` | `assets` | assets.id(包含名称、描述(含 AI 描述词)、素材、属性等所有字段)|
|
||
| `user_profile` | `users` | users.id(用户主页违规、头像、昵称)|
|
||
|
||
> **统一规则**:所有 moderation_* 表与 `moderation_target_status` 都在 `public` schema(与 `assets` / `users` 业务表同 schema),无跨 schema 访问——`moderationService` 直连 `topfans` 库 `public` schema 即可完成所有读写;业务服务读路径也只在 `public` 内做软删除过滤(无须 JOIN `moderation_target_status`)。
|
||
|
||
**孤儿记录清理(target 被删除时)**:
|
||
- `moderation_target_status` 与业务表(assets/users)跨服务,无外键约束,应用层负责清理
|
||
- 在 `assetService` / `userService` 的删除逻辑中加清理钩子:
|
||
```go
|
||
// 伪代码
|
||
defer func() {
|
||
moderationService.DeleteTargetStatus(ctx, targetType, targetID)
|
||
}()
|
||
```
|
||
- 定期清理脚本(每日 1 次):扫描 `moderation_target_status` 中 `target_type='asset'` 且 `target_id` 在 `assets` 表不存在的记录,**写一条 `restore` 审计记录**并清理 `moderation_target_status` 孤儿行(不操作业务表)
|
||
- 详情/历史记录保留在 `moderation_actions` 流水表中供审计追溯
|
||
|
||
### 3.10 `admin_audit_logs` 管理员操作日志
|
||
|
||
```sql
|
||
CREATE SEQUENCE admin_audit_logs_id_seq START WITH 10000 OWNED BY admin_audit_logs.id;
|
||
|
||
CREATE TABLE admin_audit_logs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
admin_id BIGINT NOT NULL,
|
||
action VARCHAR(50) NOT NULL,
|
||
resource_type VARCHAR(30),
|
||
resource_id BIGINT,
|
||
ip VARCHAR(500), -- 支持 IPv6 (45 字符) + X-Forwarded-For 多级代理链(实测 5 级 IPv6 代理链 ≈ 230 字符)
|
||
user_agent VARCHAR(500),
|
||
extra JSONB,
|
||
created_at BIGINT NOT NULL,
|
||
CONSTRAINT chk_admin_audit_logs_resource_pair
|
||
CHECK (
|
||
(resource_type IS NULL AND resource_id IS NULL)
|
||
OR (resource_type IS NOT NULL AND resource_id IS NOT NULL)
|
||
)
|
||
);
|
||
CREATE INDEX idx_admin_audit_logs_admin ON admin_audit_logs(admin_id, created_at DESC);
|
||
CREATE INDEX idx_admin_audit_logs_resource
|
||
ON admin_audit_logs(resource_type, resource_id, created_at DESC)
|
||
WHERE resource_id IS NOT NULL;
|
||
```
|
||
|
||
### 3.11 Redis 数据结构
|
||
|
||
| Key 模式 | 类型 | 用途 | TTL |
|
||
|----------|------|------|-----|
|
||
| `mod:report:counter:{target_type}:{target_id}` | String (INCR) | 自动隐藏阈值计数(独立用户数)| 7 天 |
|
||
| `mod:report:user:{target_type}:{target_id}:{user_id}` | String | 防重复(仅独立用户才计数)| 24h |
|
||
| `mod:report:lock:{target_type}:{target_id}` | String | 防并发触发自动隐藏 | 5s |
|
||
| `mod:auto_hide_threshold` | String (缓存) | 自动隐藏阈值 N(默认 5) | 永久 |
|
||
|
||
### 3.12 序列同步规范
|
||
|
||
遵循 CLAUDE.md 规范:所有手动指定 ID 的 INSERT 末尾必须 `SELECT setval('table_id_seq', (SELECT MAX(id) FROM table))`。
|
||
|
||
新表起始值:`CREATE SEQUENCE table_id_seq START WITH 10000;` 预留测试数据空间。
|
||
|
||
---
|
||
|
||
## 4. 状态机
|
||
|
||
### 4.1 举报状态机
|
||
|
||
```
|
||
┌──────────────────────────────────┐
|
||
│ pending │
|
||
│ (待处理) │
|
||
└─┬──────┬──────┬─────────┬───────┘
|
||
管理员认领 │ │ 阈值 N│快速驳回 │ 释放认领(回退)
|
||
▼ ▼ 触发 ▼ ▲
|
||
┌──────────┐ ┌─────────────┐ ┌────────────┐
|
||
│ reviewing│ │ auto_hidden │ │ dismissed │
|
||
│ (审核中) │ │(已自动隐藏) │ │ (已驳回) │
|
||
└─┬──┬──┬──┘ └──────┬──────┘ └────────────┘
|
||
│ │ │ │ 管理员认领
|
||
下架/封禁/警告│ │驳回│解除隐藏 ▼
|
||
▼ │ ▼ ┌──────────┐
|
||
┌─────┐ │ ┌──────────────┐ │(回到 reviewing)
|
||
│resolved│ │ dismissed │ │
|
||
│(已处理)│ │ (已驳回) │ │
|
||
└─────┘ │ └──────────────┘ │
|
||
│ │
|
||
└─────→ resolved/dismissed
|
||
```
|
||
|
||
> **图示说明**:本 ASCII 图简化展示主要迁移;完整 9 行迁移矩阵见下表。图中"释放认领"回退到 pending;"快速驳回"是 `pending → dismissed` 快速通道;"解除隐藏"是 `auto_hidden → reviewing → dismissed (恢复)`。
|
||
|
||
| 状态 | 含义 | 进入 | 退出 |
|
||
|------|------|------|------|
|
||
| `pending` | 新提交待处理 | 创建 | 认领 OR 自动隐藏 |
|
||
| `reviewing` | 管理员已认领 | 认领 | 做出处理动作 |
|
||
| `auto_hidden` | 触发自动隐藏 | 阈值达成 | 管理员认领并做最终裁决 |
|
||
| `resolved` | 已处理 | takedown/ban/warn | 终态 |
|
||
| `dismissed` | 已驳回 | dismiss | 终态 |
|
||
|
||
**状态机合法迁移矩阵**:
|
||
|
||
| From | To | 触发者 | 触发条件 | 副作用 |
|
||
|------|----|--------|----------|--------|
|
||
| (init) | pending | 客户端/系统 | 提交举报 | 写 reports |
|
||
| pending | reviewing | 后台 | 管理员点击"认领" | 抢锁 UPDATE |
|
||
| pending | auto_hidden | 系统 | Redis counter ≥ threshold | 业务表软删除 + 写 reports + UPSERT mts(last_action_type='autohide') |
|
||
| auto_hidden | reviewing | 后台 | 管理员点击"认领" | 抢锁 UPDATE(被举报对象保持软删除:is_active=false;**is_auto_hidden 保持 TRUE 不变**)|
|
||
| reviewing | resolved | 后台 | takedown/ban/warn | 写 mts(last_action_type 对应) + 通知被举报方 |
|
||
| reviewing | dismissed | 后台 | dismiss 且非 auto_hidden | UPSERT mts(last_action_type='dismiss') + 通知举报人 |
|
||
| reviewing | dismissed | 后台 | dismiss 且曾 auto_hidden | 业务表软删除恢复 + UPSERT mts(last_action_type='restore') + 通知举报人 |
|
||
| reviewing | dismissed | 后台 | dismiss 且曾 auto_hidden 但**保留隐藏** | UPSERT mts(last_action_type='dismiss')(不恢复业务表,保留 auto_hide 状态)+ 通知举报人 |
|
||
| pending | dismissed | 后台 | dismiss 无需 review(重复/明显误报快速通道) | UPDATE reports SET status='dismissed', resolved_action='dismiss', claimed_by=NULL, claimed_at=NULL, resolved_by=$admin_id, resolved_at=$now, updated_at=$now + UPSERT mts(last_action_type='dismiss') + 通知举报人 |
|
||
| reviewing | pending | 后台 | 释放认领(admin 主动放弃) | UPDATE reports SET status='pending', claimed_by=NULL, claimed_at=NULL, updated_at=$now;is_auto_hidden 标志保持不变(auto_hidden→reviewing→pending 仍保持业务表软删除)|
|
||
| resolved | (终态) | - | - |
|
||
| resolved | resolved (restore/unban) | 后台 | **解除 ban/takedown 误判**:把已 `resolved` (resolved_action='ban' or 'takedown') 的工单对应的 `users.is_active=true, deleted_at=NULL` 恢复 | **v2.5 CTE 条件写 mts**(避免无 op 时误导审计):<br>```sql<br>WITH user_unbanned AS (<br> UPDATE users SET is_active=true, deleted_at=NULL<br> WHERE id=$id AND is_active=false<br> RETURNING id<br>), report_updated AS (<br> UPDATE reports SET resolved_action='restore',<br> resolution_note='unban: ' \|\| $note,<br> resolved_by=$admin_id, resolved_at=$now<br> WHERE id=$id AND status='resolved' AND resolved_action IN ('ban','takedown')<br> RETURNING id<br>)<br>-- **关键:仅当 user_unbanned=1(实际解了 user)才写 mts restore**<br>INSERT INTO moderation_target_status (target_type, target_id, last_action_type, source, operator_admin_id, reason, created_at, updated_at)<br>SELECT 'user_profile', $id, 'restore', 'admin', $admin_id, 'unban: ' \|\| $note, $now, $now<br>WHERE EXISTS (SELECT 1 FROM user_unbanned);<br>-- 写流水(无论 user 是否真解,都记此次 admin 动作)<br>INSERT INTO moderation_actions (report_id, admin_id, action_type, target_type, target_id, note, success, created_at)<br>VALUES ($id, $admin_id, 'restore', 'user_profile', $id,<br> 'unban: ' \|\| $note \|\| ' (user_unbanned=' \|\| (SELECT count(*) FROM user_unbanned) \|\| ')', TRUE, $now);<br>```<br>行为分支:<br>- user_unbanned=1(实际解 user)+ report_updated=1 → 完整 unban,mts+流水双写<br>- user_unbanned=0(user 已 active)+ report_updated=1 → **只写流水,不写 mts**(避免 "明明不是我解的" 误导审计)<br>- report_updated=0(status 不在 ['ban','takedown'])→ 50014 状态机非法迁移 |
|
||
| pending | withdrawn | 客户端 | 撤回误报(`DELETE /api/v1/moderation/reports/{id}`,仅 reporter_id=本人)| UPDATE reports SET status='withdrawn', updated_at=$now WHERE id=$id AND status='pending' AND reporter_id=$current_user; 写 moderation_actions(action_type='withdraw', admin_id=$current_user, report_id=$id)|
|
||
| pending | archived | 系统 cron | 6.5 cron 90 天未处理自动 archive | UPDATE reports SET status='archived', updated_at=$now, resolution_note='auto-archived: pending > 90 days' WHERE status='pending' AND created_at < now-90d; 6.5 cron SQL 完整版见阶段 6.5 | - |
|
||
| dismissed | (终态) | - | - | - |
|
||
|
||
> **pending → dismissed 快速通道**:用于"同一用户对同一对象重复举报"或"明显不在分类范围内的误报"——管理员可在不认领的情况下直接驳回;状态机仍记录在迁移矩阵中,与 6.2 流程保持一致。
|
||
|
||
**并发认领防护**:
|
||
```sql
|
||
UPDATE reports
|
||
SET status = 'reviewing', updated_at = ?
|
||
WHERE id = ? AND status IN ('pending', 'auto_hidden')
|
||
```
|
||
用 affected rows = 1 判断是否抢锁成功。
|
||
|
||
**自动隐藏幂等性**:
|
||
- 自动隐藏触发时,上层 SQL 加幂等保护:
|
||
```sql
|
||
UPDATE reports
|
||
SET status = 'auto_hidden', is_auto_hidden = true
|
||
WHERE target_type = ? AND target_id = ? AND status = 'pending';
|
||
```
|
||
- 仅将仍是 `pending` 的工单升级为 `auto_hidden`,避免重复 INCR 后覆盖 reviewing/resolved 等状态
|
||
- `moderation_target_status` 用 `UPSERT ... ON CONFLICT (target_type, target_id) DO UPDATE`,天然幂等
|
||
|
||
### 4.2 反馈状态机
|
||
|
||
```
|
||
┌────────────┐
|
||
│ pending │
|
||
└─────┬──────┘
|
||
│ 认领
|
||
▼
|
||
┌────────────────┐
|
||
│ reviewing │
|
||
└─┬──────┬────┬──┘
|
||
│ │ │
|
||
已回复 │ │关闭│ 归档
|
||
▼ ▼ ▼
|
||
┌────────┐ ┌────────┐ ┌────────┐
|
||
│replied │ │closed │ │archived│
|
||
└────────┘ └────────┘ └────────┘
|
||
```
|
||
|
||
| 状态 | 进入 | 备注 |
|
||
|------|------|------|
|
||
| `pending` | 创建 | 初始 |
|
||
| `reviewing` | 认领 | |
|
||
| `replied` | 填写 `reply_content` | **终态,不可再认领/关闭/归档**(4.2 状态机无出边;YAGNI 不含 reopen 流程)。如需追加信息,用户须创建新反馈 |
|
||
| `closed` | 点击关闭 | **终态**,重复/无效(仅从 `reviewing` 入口,与 6.3 close SQL 一致)|
|
||
| `archived` | 归档 | **终态**,已处理保留记录(仅从 `reviewing` 入口;v2.0 archive SQL 不接受 `replied` 分支)|
|
||
|
||
---
|
||
|
||
## 5. API 设计
|
||
|
||
### 5.1 客户端 API(uni-app 端调用,路径前缀 `/api/v1/moderation`)
|
||
|
||
| Method & Path | 说明 |
|
||
|---------------|------|
|
||
| `GET /api/v1/moderation/report-categories` | 获取启用中的举报分类 |
|
||
| `POST /api/v1/moderation/reports` | 提交举报 |
|
||
| `GET /api/v1/moderation/reports?status=&page=&page_size=` | 我的举报记录 |
|
||
| `GET /api/v1/moderation/reports/{id}` | 查看举报进度与结果 |
|
||
| `GET /api/v1/moderation/feedback-categories` | 获取反馈分类 |
|
||
| `POST /api/v1/moderation/feedbacks` | 提交反馈 |
|
||
| `GET /api/v1/moderation/feedbacks?status=&page=&page_size=` | 我的反馈记录 |
|
||
| `GET /api/v1/moderation/feedbacks/{id}` | 查看反馈详情(含回复)|
|
||
|
||
**提交举报请求体**:
|
||
```json
|
||
{
|
||
"target_type": "asset",
|
||
"target_id": 12345,
|
||
"category_code": "pornographic",
|
||
"description": "图片含有裸露内容",
|
||
"is_anonymous": true,
|
||
"evidence_keys": ["report/2026/06/11/uuid1.png"]
|
||
}
|
||
```
|
||
|
||
**响应**:
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"data": {
|
||
"report_id": 67890,
|
||
"status": "auto_hidden",
|
||
"auto_hidden": true,
|
||
"target_hidden": true,
|
||
"claimed_by": null,
|
||
"claimed_at": null,
|
||
"created_at": 1718123456789
|
||
}
|
||
}
|
||
```
|
||
|
||
> **字段说明**:
|
||
> - `status` 反映提交后**实际 DB 状态**(可能为 `pending` 或 `auto_hidden`,与 `auto_hidden` 布尔联动)
|
||
> - `auto_hidden` 布尔冗余于 `status=='auto_hidden'`,保留为前端易读字段
|
||
> - `target_hidden` 表示"本次 auto-hide **是否执行了** UPDATE assets/users SET is_active=false"(**不是**"目标当前是否被隐藏");当目标已因前次下架/封禁 `is_active=false` 时,本次 auto-hide 是 no-op,`target_hidden=false` 但 `status='auto_hidden'`
|
||
> - `claimed_by` / `claimed_at` 新工单始终为 `null`(与 50008 响应体字段命名一致)
|
||
> - `created_at` / `claimed_at` 为 Unix 毫秒(参见 3 节约定)
|
||
|
||
**错误响应**:
|
||
```json
|
||
{ "code": 50004, "message": "您已举报过该对象(未结案,同一举报人对同一对象同一类型只能有一条未结案举报)" }
|
||
```
|
||
|
||
> **客户端编辑/撤回**(v2.4 修订):
|
||
> - `PUT /api/v1/moderation/reports/{id}` body `{description: "...", category_code: "...", evidence_keys: [...]}`——v2.4 允许改 **`description` + `category_code` + `evidence_keys`**(之前 v2.3 只允许改 description,category_code 误选无解);仅当 `status='pending'` 且 `reporter_id=current_user`;返回 50019("非 pending 状态不可编辑")否则
|
||
> - `DELETE /api/v1/moderation/reports/{id}`——软删除:`UPDATE reports SET status='withdrawn', updated_at=$now WHERE id=$id AND status='pending' AND reporter_id=$current_user`;**v2.4 不增 `withdrawn_at` 列**(与 `updated_at` 冗余;`status='withdrawn'` + `updated_at` 已携带完整时间信息);写 `moderation_actions.action_type='withdraw'`(v2.4 新增此 action_type)
|
||
> - 用途:账号被盗 / 误点错分类 / 撤销误报
|
||
|
||
> **证据上传**:客户端先调用 `GET /api/v1/assets/oss/signature?type=asset` 直传 OSS,再把返回的 `key` 提交给本接口。
|
||
|
||
**提交反馈请求体**(`POST /api/v1/moderation/feedbacks`):
|
||
```json
|
||
{
|
||
"category_code": "bug",
|
||
"title": "反馈标题",
|
||
"content": "详细描述",
|
||
"contact": "user@example.com",
|
||
"is_anonymous": false,
|
||
"evidence_keys": ["feedback/2026/06/11/uuid1.png"]
|
||
}
|
||
```
|
||
|
||
**响应**:
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"data": {
|
||
"feedback_id": 12345,
|
||
"status": "pending",
|
||
"claimed_by": null,
|
||
"claimed_at": null,
|
||
"created_at": 1718123456789
|
||
}
|
||
}
|
||
```
|
||
|
||
> **字段说明**:
|
||
> - `feedbacks` 没有 `auto_hidden` 机制(无阈值触发),新工单 status 始终为 `pending`
|
||
> - `claimed_by` / `claimed_at` 新反馈始终为 `null`(admin 认领后 GET 详情才非 null)
|
||
> - `created_at` 为 Unix 毫秒(参见 3 节约定)
|
||
|
||
### 5.2 后台 API(Vue3 后台调用,路径前缀 `/api/admin/moderation`)
|
||
|
||
| Method & Path | 说明 |
|
||
|---------------|------|
|
||
| `GET /api/admin/moderation/reports?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`**(v2.3)| 批量驳回 spam:body `{report_ids: [1,2,3...], reason: "spam_ring"}`,幂等返回成功数(防御多账号 spam 攻击)|
|
||
| **`POST /api/admin/moderation/reports/{id}/force-release`**(v2.3)| 强制释放他人认领(admin crash 后回收用),写 `moderation_actions.note='force_release: <reason>'` |
|
||
| **`POST /api/admin/moderation/reports/{id}/force-release`** SQL(v2.5 完整化)| 6.2 子段新增 ——<br>```sql<br>-- 1. 释放认领(不限 claimed_by,允许任何 admin 强制回收)<br>UPDATE reports<br>SET status='pending', claimed_by=NULL, claimed_at=NULL, updated_at=$now<br>WHERE id=$id AND status='reviewing';<br>-- 0 rows → 50009(status 已被 dismiss/resolve)<br>-- 2. 写流水(v2.5:v2.3 只说 "写 note" 但没 SQL)<br>INSERT INTO moderation_actions<br> (report_id, admin_id, action_type, target_type, target_id,<br> note, success, created_at)<br>VALUES<br> ($id, $operator_admin_id, 'force_release', $target_type, $target_id,<br> 'force_release: ' \|\| $reason, TRUE, $now);<br>-- 3. 写 admin_audit_logs(v2.5 补全 v2.3 缺漏)<br>INSERT INTO admin_audit_logs<br> (admin_id, action, resource_type, resource_id, ip, user_agent, extra, created_at)<br>VALUES<br> ($operator_admin_id, 'force_release', 'report', $id, $ip, $ua,<br> jsonb_build_object('previous_claimed_by', $previous_claimed_by, 'reason', $reason),<br> $now);<br>-- 4. 通知原 claimer(v2.5 补全)<br>-- `report_force_released_notice` 模板发给原 claimer:"工单 X 已被 admin ${operator} 强制释放"<br>```<br>注:force-release 是 "打破锁",任何 admin 都可执行;原 claimer 不需要同意(事故响应)|
|
||
| `GET /api/admin/moderation/feedbacks?status=&category=&keyword=&page=&page_size=` | 反馈列表 |
|
||
| `GET /api/admin/moderation/feedbacks/{id}` | 反馈详情 |
|
||
| `POST /api/admin/moderation/feedbacks/{id}/claim` | 认领反馈 |
|
||
| `POST /api/admin/moderation/feedbacks/{id}/release` | 释放认领 |
|
||
| `POST /api/admin/moderation/feedbacks/{id}/reply` | 回复(→ replied)|
|
||
| `POST /api/admin/moderation/feedbacks/{id}/close` | 关闭 |
|
||
| `POST /api/admin/moderation/feedbacks/{id}/archive` | 归档 |
|
||
| `GET/POST/PUT/DELETE /api/admin/moderation/report-categories` | 举报分类 CRUD |
|
||
| `GET/POST/PUT/DELETE /api/admin/moderation/feedback-categories` | 反馈分类 CRUD |
|
||
| `GET /api/admin/moderation/stats` | 看板:今日待处理/平均处理时长/分类分布 |
|
||
|
||
**执行审核动作请求**:
|
||
```json
|
||
{
|
||
"action": "takedown",
|
||
"note": "图片含裸露,已下架",
|
||
"send_notification": true
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 关键流程
|
||
|
||
### 6.1 用户提交举报(含自动隐藏)
|
||
|
||
```
|
||
用户点击"举报"
|
||
│
|
||
▼
|
||
uni-app → POST /api/v1/moderation/reports
|
||
│
|
||
▼
|
||
gateway → moderationService.SubmitReport()
|
||
│
|
||
├─ 0. 限流检查:每用户每天 ≤ 30 次(Redis INCR `mod:rl:report:user:{user_id}:{yyyymmdd}`)
|
||
│ 超限 → 返回 50012
|
||
├─ 1. 验证:target_type 合法、分类启用、description ≤ 500 字、evidence ≤ 5 张
|
||
├─ 2. 验证:目标对象存在(assetService.GetAssetRPC / userService.GetUserRPC)
|
||
├─ 2.5 自举报拦截:target_type='asset' → reporter_id != asset.owner_uid;target_type='user_profile' → reporter_id != target_id(即不能举报自己);命中则返回 50011
|
||
├─ 3. 防重复检查:同 (reporter, target, type) 在 pending/reviewing/auto_hidden 已有?
|
||
│ 有 → 返回 50004
|
||
├─ 4. 写入 reports 表(status='pending',snapshot 复用步骤 2 拿到的对象信息)
|
||
│ **必填 NULL**:`claimed_by=NULL, claimed_at=NULL`(`chk_reports_claimed_pair` 约束 `status<>'reviewing'` 时必须 NULL,**不可省略**——包括 `pending` 和 `auto_hidden` 状态)
|
||
│ **`star_id` 路由查询**(与目标对象 star 关联):
|
||
│ - `target_type='asset'` → `SELECT star_id FROM assets WHERE id=$target_id`,设 `reports.star_id`
|
||
│ - `target_type='user_profile'` → `SELECT identity_id FROM users WHERE id=$target_id`(或 NULL,取决于用户是否绑定明星)
|
||
│ - 目标对象不存在时 `star_id` 保持 NULL
|
||
│ - 注:`star_id` 是 denormalized 副本,跨表无 FK(参见 3.4 注释)
|
||
├─ 5. 写 report_evidence(OSS keys,oss_url 由 OSS 签名接口的 response 预填)
|
||
├─ 6. Redis Lua 计数(独立用户才 INCR):
|
||
│ 首次计数 → 检查 counter >= threshold (默认 5)
|
||
│ └─ 达到 → 触发自动隐藏(**事务内双重写入**):
|
||
│ ① 业务表软删除(按 target_type 路由):
|
||
│ - target_type='asset' → UPDATE assets SET is_active=false, deleted_at=$now WHERE id=$id AND is_active=true
|
||
│ - target_type='user_profile' → UPDATE users SET is_active=false, deleted_at=$now WHERE id=$id AND is_active=true
|
||
│ - **v2.3 幂等性区分**:当 affected=0(目标已 `is_active=false`,no-op)—— 记入 `moderation_actions.action_type='autohide_noop'`,主指标 `moderation.report.auto_hidden.count` 不虚增(独立指标 `moderation.report.auto_hidden.noop.count` 记 no-op 次数)
|
||
│ ② UPDATE reports WHERE target_type=? AND target_id=? AND status='pending'
|
||
│ SET status='auto_hidden', is_auto_hidden=true,
|
||
│ triggered_auto_hide=(id=$trigger_report_id)
|
||
│ (仅 pending 升级为 auto_hidden,避免覆盖 reviewing/resolved;**v2.4 区分阈值触发者**:
|
||
│ `triggered_auto_hide` 字段——阈值跨过的那个 report 置 TRUE,其它连带升级的报告置 FALSE,
|
||
│ 便于审计 "哪个是触发者" vs "哪些是连带")
|
||
│ ③ UPSERT moderation_target_status
|
||
│ (target_type, target_id, last_action_type='autohide', source='auto', source_report_id=...)
|
||
│ ③.5 写 moderation_actions 流水(永久审计)—— **v2.4 改 conditional**:
|
||
│ IF ① 业务表 UPDATE affected_rows > 0 THEN
|
||
│ -- 实际执行了软删除
|
||
│ INSERT INTO moderation_actions
|
||
│ (report_id, admin_id, action_type, target_type, target_id,
|
||
│ note, success, created_at)
|
||
│ VALUES
|
||
│ ($report_id, 0, 'takedown', $target_type, $target_id,
|
||
│ 'auto-hide threshold reached', TRUE, $now);
|
||
│ ELSE
|
||
│ -- no-op(目标已 is_active=false),记独立 action_type 不虚增主指标
|
||
│ INSERT INTO moderation_actions
|
||
│ (report_id, admin_id, action_type, target_type, target_id,
|
||
│ note, success, created_at)
|
||
│ VALUES
|
||
│ ($report_id, 0, 'autohide_noop', $target_type, $target_id,
|
||
│ 'auto-hide no-op: target already is_active=false', TRUE, $now);
|
||
│ -- 注:admin_id=0 表示"系统自动"(v2.2 B4 CHECK admin_id>=0)
|
||
│ -- mts.last_action_type='autohide' 区分自动/人工来源
|
||
│ ④ 发通知给被举报方:"您的内容被多人举报已自动隐藏"
|
||
├─ 7. 返回 {report_id, status, auto_hidden, target_hidden, created_at}
|
||
```
|
||
|
||
### 6.2 后台审核流程
|
||
|
||
```
|
||
审核员登录后台 → 列表页 (筛选 status=pending OR auto_hidden)
|
||
│
|
||
▼
|
||
详情 → GET /api/admin/moderation/reports/{id}
|
||
│
|
||
▼
|
||
认领 → POST /api/admin/moderation/reports/{id}/claim
|
||
│ UPDATE reports SET status='reviewing', updated_at=now
|
||
│ WHERE id=? AND status IN ('pending','auto_hidden')
|
||
│ affected=1 才返回成功
|
||
│
|
||
▼
|
||
[可选] 释放认领 → POST /api/admin/moderation/reports/{id}/release
|
||
UPDATE reports SET status='pending', claimed_by=NULL, claimed_at=NULL,
|
||
updated_at=$now
|
||
WHERE id=? AND status='reviewing' AND claimed_by=$admin_id
|
||
-- 释放后回到 pending 工单池;如原状态是 auto_hidden 则仍保持业务表软删除
|
||
│
|
||
▼
|
||
查看证据、目标快照、举报人、流水
|
||
│
|
||
▼
|
||
执行动作 → POST /api/admin/moderation/reports/{id}/actions
|
||
│
|
||
├─ takedown(藏品/描述词/用户内容):
|
||
│ **事务内双重写入**:
|
||
│ ① 业务表软删除(按 target_type 路由到对应表):
|
||
│ - target_type='asset' → UPDATE assets SET is_active=false, deleted_at=$now WHERE id=$id AND is_active=true
|
||
│ - target_type='user_profile' → UPDATE users SET is_active=false, deleted_at=$now WHERE id=$id AND is_active=true
|
||
│ ② UPSERT moderation_target_status (last_action_type='takedown', source='admin', operator_admin_id=...)
|
||
│ ③ UPDATE reports SET status='resolved', resolved_action='takedown', ...
|
||
│ 发通知给被举报方
|
||
│
|
||
├─ ban(用户):
|
||
│ **事务内双重写入**(与 takedown 结构一致):
|
||
│ ① 业务表软删除(仅 `target_type='user_profile'`):
|
||
│ - UPDATE users SET is_active=false, deleted_at=$now WHERE id=$id AND is_active=true
|
||
│ ② UPSERT moderation_target_status (last_action_type='ban', source='admin', operator_admin_id=...)
|
||
│ ③ UPDATE reports SET status='resolved', resolved_action='ban', ...
|
||
│ 行为等同 takedown 但语义标记为"封禁账号"——区别仅在通知文案(短信 + 站内信)+ 强制踢下线(清空 session/JWT 黑名单)
|
||
│
|
||
├─ warn(用户,不阻塞登录):
|
||
│ **不软删除 user**,仅写入警告状态(不修改 users.is_active):
|
||
│ ① UPSERT moderation_target_status
|
||
│ SET is_warned=true, warn_count=warn_count+1, last_warned_at=$now,
|
||
│ last_action_type='warn', source='admin', operator_admin_id=...
|
||
│ ② UPDATE reports SET status='resolved', resolved_action='warn', ...
|
||
│ 发站内信给用户
|
||
│
|
||
│ **warn_cleared 流程**(清除警告但保留历史计数+时间戳;用于"申诉成功""警告过期"等场景):
|
||
│ - 适用:admin 主动解除某用户的警告状态,但保留 `warn_count` 累计和 `last_warned_at` 时间戳作为历史档案
|
||
│ - **端点**:`POST /api/admin/moderation/users/{id}/clear-warn` body `{ reason: "...", send_notification: true }`
|
||
│ - **事务内双重写入**:
|
||
│ ① mts UPDATE:
|
||
│ ```sql
|
||
│ UPDATE moderation_target_status
|
||
│ SET is_warned=FALSE, last_action_type='warn_cleared',
|
||
│ operator_admin_id=$admin_id, reason=$reason, updated_at=$now
|
||
│ WHERE target_type='user_profile' AND target_id=$id AND is_warned=TRUE
|
||
│ ```
|
||
│ ② 审计流水(`moderation_actions` + `admin_audit_logs`,**不关联任何 report/feedback**):
|
||
│ ```sql
|
||
│ INSERT INTO moderation_actions
|
||
│ (report_id, feedback_id, admin_id, action_type, target_type, target_id,
|
||
│ note, success, created_at)
|
||
│ VALUES
|
||
│ (NULL, NULL, $admin_id, 'restore', 'user_profile', $id,
|
||
│ 'warn_cleared: ' || $reason, TRUE, $now);
|
||
│ INSERT INTO admin_audit_logs
|
||
│ (admin_id, action, resource_type, resource_id, ip, user_agent, extra, created_at)
|
||
│ VALUES
|
||
│ ($admin_id, 'clear_warn', 'user', $id, $ip, $ua,
|
||
│ jsonb_build_object('warn_count', $prev_warn_count, 'last_warned_at', $prev_last_warned_at),
|
||
│ $now);
|
||
│ ```
|
||
│ - 满足 `chk_mts_warn_fields` 第 3 子句(`is_warned=FALSE AND warn_count>=1 AND last_warned_at IS NOT NULL`)
|
||
│ - 注:此流程不影响 `reports` 状态机(仅清 mts 警告字段)
|
||
│ - **v2.2 CHECK 放宽**:`chk_moderation_actions_xor` 允许 `report_id` 和 `feedback_id` 同时为 NULL(用于 warn_cleared 这类无 ticket 关联的审计行)——改为 `(report_id IS NOT NULL) <> (feedback_id IS NOT NULL) OR (report_id IS NULL AND feedback_id IS NULL)`(新增 OR 分支)
|
||
│
|
||
├─ dismiss(驳回,三种迁移路径,见 4.1 状态机矩阵):
|
||
│ **路径 A:pending → dismissed(快速通道,无需 review)**
|
||
│ - 适用场景:明显误报 / 分类错误 / 重复举报
|
||
│ - 权限:任意审核员(无需 claimed_by;`reports.claimed_by IS NULL`)
|
||
│ - 操作:
|
||
│ ① UPDATE reports SET status='dismissed', resolved_action='dismiss',
|
||
│ claimed_by=NULL, claimed_at=NULL,
|
||
│ resolved_by=$admin_id, resolved_at=$now, updated_at=$now
|
||
│ WHERE id=$id AND status='pending'
|
||
│ (**必填 NULL**:`chk_reports_claimed_pair` 约束 `status<>'reviewing'` 时 `claimed_by/claimed_at` 必须 NULL;与 6.1 step 4 INSERT 一致)
|
||
│ ② UPSERT moderation_target_status SET last_action_type='dismiss', source='admin'
|
||
│ (**前提**:mts.last_action_type != 'autohide';若原状态是 auto_hide,则跳过 UPSERT 保留审计)
|
||
│ ③ 发通知给举报人
|
||
│
|
||
│ **路径 B:reviewing → dismissed(普通驳回,从未触发自动隐藏)**
|
||
│ - 适用场景:审核员 review 后认为不违规
|
||
│ - 前置:审核员已认领(status='reviewing', claimed_by=本人)
|
||
│ - 操作:
|
||
│ ① UPSERT moderation_target_status SET last_action_type='dismiss', source='admin'
|
||
│ ② UPDATE reports SET status='dismissed', resolved_action='dismiss', ...
|
||
│ ③ 发通知给举报人
|
||
│
|
||
│ **路径 C:auto_hidden → reviewing → dismissed(恢复被自动隐藏的对象)**
|
||
│ - 适用场景:审核员 review 后判定为误报,需恢复对象
|
||
│ - 前置:审核员已认领;原状态 is_auto_hidden=TRUE
|
||
│ - **事务内三重写入 + Redis 计数重置**:
|
||
│ ① 业务表软删除恢复(按 target_type 路由):
|
||
│ - target_type='asset' → UPDATE assets SET is_active=true, deleted_at=NULL WHERE id=$id AND is_active=false
|
||
│ - target_type='user_profile' → UPDATE users SET is_active=true, deleted_at=NULL WHERE id=$id AND is_active=false
|
||
│ ② UPSERT moderation_target_status SET last_action_type='restore', source='admin'
|
||
│ ②.5 **v2.3 批量清理同 target 老 auto_hidden 工单**(防数据膨胀):
|
||
│ UPDATE reports SET status='dismissed', resolved_action='dismiss',
|
||
│ resolved_by=$admin_id, resolved_at=$now, updated_at=$now,
|
||
│ resolution_note='bulk cleanup after path C restore'
|
||
│ WHERE target_type=$target_type AND target_id=$target_id
|
||
│ AND status='auto_hidden' AND id != $report_id
|
||
│ —— 避免"admin 判决不违规后老 auto_hidden 工单僵尸"
|
||
│ ③ UPDATE reports SET status='dismissed', resolved_action='dismiss', ...
|
||
│ ④ **Redis 计数重置**(admin 判决"不违规"应清零风险信号):
|
||
│ - `DEL mod:report:counter:{target_type}:{target_id}`(counter 归零)
|
||
│ - `SCAN MATCH mod:report:user:{target_type}:{target_id}:* | DEL`(user_marker 通配清理——**Redis DEL 不支持通配**,必须 SCAN 出 keys 再逐个 DEL;伪代码见下)
|
||
│ - `DEL mod:report:lock:{target_type}:{target_id}`(释放锁,幂等)
|
||
│ ```go
|
||
│ // Go 伪代码(moderationService 通过 Redis 客户端批量清理 user_marker)
|
||
│ iter := redis.Scan(ctx, 0, "mod:report:user:"+tt+":"+tid+":*", 100).Iterator()
|
||
│ for iter.Next(ctx) {
|
||
│ redis.Del(ctx, iter.Val())
|
||
│ }
|
||
│ ```
|
||
│ ⑤ 发通知给举报人 + 被举报方
|
||
│ - **设计决策**:path C 主动重置计数 = "admin 否定所有过往举报";如 admin 想保留历史风险信号(保守派),用 path D 不重置
|
||
│
|
||
│ **路径 D:auto_hidden → reviewing → dismissed(不恢复,保留隐藏状态)**
|
||
│ - 适用场景:审核员 review 后虽认定不违规但认为内容有争议,保留隐藏
|
||
│ - 前置:审核员已认领;原状态 is_auto_hidden=TRUE
|
||
│ - 操作:
|
||
│ ① UPSERT moderation_target_status SET last_action_type='dismiss', source='admin'
|
||
│ (**不恢复业务表软删除**;业务表的 `is_active=false, deleted_at=$now` 保持原状;mts 仅审计 last_action_type='dismiss')
|
||
│ ② UPDATE reports SET status='dismissed', resolved_action='dismiss', ...
|
||
│ ③ 发通知给举报人
|
||
│ - **审计说明**:`last_action_type='dismiss'` 会**覆盖**之前的 `last_action_type='autohide'` 记录。完整的 auto_hide → review → dismiss 历史需通过 `moderation_actions` 表按 `report_id` + `created_at` 时序回溯;`moderation_target_status` 仅保留"最近一次动作"快照。
|
||
│
|
||
│ 共同写 moderation_actions 流水 + admin_audit_logs
|
||
│
|
||
└─ 写 moderation_actions 流水 + admin_audit_logs
|
||
```
|
||
|
||
### 6.3 反馈流程
|
||
|
||
```
|
||
用户提交反馈 → POST /api/v1/moderation/feedbacks
|
||
│ moderationService 写 feedbacks (status='pending')
|
||
│ 限流:每用户每天 ≤ 5 条(Redis INCR `mod:rl:feedback:user:{user_id}:{yyyymmdd}`,超限返回 50013)
|
||
▼
|
||
后台列表 → GET /api/admin/moderation/feedbacks
|
||
▼
|
||
认领 → POST /api/admin/moderation/feedbacks/{id}/claim
|
||
│ UPDATE feedbacks SET status='reviewing', updated_at=now
|
||
│ WHERE id=? AND status='pending'(并发认领防护同举报)
|
||
│ -- 0 行匹配时由 handler 区分错误码:
|
||
│ -- - 此前 status='reviewing' 且 claimed_by<>本人 → 50008(已被他人认领)
|
||
│ -- - 此前 status IN ('replied','closed','archived') → 50009(已结案/终态)
|
||
▼
|
||
├── [可选] 释放 → POST /api/admin/moderation/feedbacks/{id}/release
|
||
│ UPDATE feedbacks SET status='pending', claimed_by=NULL, claimed_at=NULL
|
||
│ WHERE id=? AND status='reviewing' AND claimed_by=$admin_id -- 仅本人可释放
|
||
│
|
||
├── 回复 → POST /api/admin/moderation/feedbacks/{id}/reply
|
||
│ body: { content: "..." }
|
||
│ UPDATE feedbacks SET status='replied', reply_content=?, replied_by=?, replied_at=now,
|
||
│ claimed_by=NULL, claimed_at=NULL -- 终态清认领人,避免与 `replied_by` 重复
|
||
│ 发通知给反馈人
|
||
│
|
||
├── 关闭 → POST .../close
|
||
│ UPDATE feedbacks SET status='closed', closed_by=$admin_id, closed_at=$now,
|
||
│ claimed_by=NULL, claimed_at=NULL -- 终态清认领人(chk_feedbacks_claimed_pair 强约束)
|
||
│ WHERE id=? AND status='reviewing' AND claimed_by=$admin_id
|
||
│ -- close 必须在 reviewing 状态(管理员已认领);与 dismiss-pending 快速通道不同,反馈不允许跳过 reviewing 直接关闭
|
||
│
|
||
└── 归档 → POST .../archive
|
||
UPDATE feedbacks SET status='archived', archived_by=$admin_id, archived_at=$now,
|
||
claimed_by=NULL, claimed_at=NULL
|
||
WHERE id=? AND status='reviewing' AND claimed_by=$admin_id
|
||
-- 仅本人可归档;reviewing 状态必 claimed_by 非空(chk_feedbacks_claimed_pair 约束)
|
||
-- 注意:archive 仅从 reviewing 入口;replied 已是终态不可再 archive(与 4.2 状态机"replied | 终态"一致)
|
||
```
|
||
|
||
### 6.4 自动隐藏 Lua 脚本
|
||
|
||
```lua
|
||
-- KEYS[1]=counter, KEYS[2]=user_marker
|
||
-- ARGV[1]=threshold, ARGV[2]=ttl_counter, ARGV[3]=ttl_marker
|
||
-- 并发防护由应用层在调用前获取 5s 短锁 `mod:report:lock:{target_type}:{target_id}`(见 9.1)
|
||
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}
|
||
```
|
||
|
||
返回 `{triggered, count}`:triggered=1 时上层触发自动隐藏。`lock` 不在脚本内,由应用层**完整生命周期**管理:
|
||
|
||
```go
|
||
// 伪代码
|
||
ok, _ := redis.SetNX(ctx, "mod:report:lock:"+targetType+":"+targetID, "1", 5*time.Second)
|
||
if !ok { return } // 5s 内已有并发请求在跑,本次直接放弃
|
||
defer redis.Del(ctx, "mod:report:lock:"+targetType+":"+targetID) // 显式释放(5s TTL 兜底)
|
||
|
||
triggered, count := redis.Eval(ctx, luaScript, ...)
|
||
// 若 triggered=1:进入"事务内双重写入"(业务表软删除 + reports UPDATE + mts UPSERT)
|
||
// 任何一步失败:整体事务回滚,defer 仍会 DEL 锁
|
||
```
|
||
|
||
**锁的语义**:
|
||
- 锁覆盖**整个自动隐藏流程**(Lua 计数 + 业务表软删除 + 事务回滚),不是单次 EVAL
|
||
- `defer DEL` 是显式释放,正常路径在 ~100ms 内完成,远小于 5s TTL
|
||
- 5s TTL 是**兜底**——若服务崩溃未 DEL,5s 后自动失效,下次请求可重入
|
||
- `mod:report:lock:*` 与 `mod:report:counter:*` 的 key 命名层级一致,便于 Redis 监控(`redis-cli --scan --pattern 'mod:report:*'`)
|
||
|
||
---
|
||
|
||
## 7. 错误码
|
||
|
||
| 码 | 含义 | HTTP |
|
||
|----|------|------|
|
||
| 50001 | 举报分类不存在或已停用 | 400 |
|
||
| 50002 | 反馈分类不存在或已停用 | 400 |
|
||
| 50003 | 目标对象不存在 | 404 |
|
||
| 50004 | 您已举报过该对象(未结案,同一举报人对同一对象同一类型只能有一条未结案举报)| 429 |
|
||
| 50005 | 描述过长(>500 字)| 400 |
|
||
| 50006 | 证据图超限(>5 张)| 400 |
|
||
| 50007 | 工单不存在 | 404 |
|
||
| 50008 | 工单已被他人认领 | 409 |
|
||
| | **响应体**:`{ code, message, claimed_by, claimed_at }`——`claimed_by` 是 admin_id(与 `reports`/`feedbacks` 列名一致),`claimed_at` 是 Unix 毫秒(参见 3 节约定);后台可二次查询 admin nickname 用于 UI 展示"已被 X 认领" | | |
|
||
| 50009 | 工单已结案(终态不可再操作,如对 `resolved`/`dismissed`/`archived` 工单再次操作)| 400 |
|
||
| 50010 | 管理员权限不足 | 403 |
|
||
| 50011 | 不能举报自己 | 400 |
|
||
| 50012 | 提交过于频繁(限流)| 429 |
|
||
| | **响应体**:`{ code, message, scope: "user"|"ip"|"device", limit: 30|200 }`(v2.5 新增)—— 前端可显示 "今日已提交 X 次(user 限 30/ip 限 200/device 限 200)",精准定位哪个维度超限 | | |
|
||
| 50013 | 反馈每日提交超限 | 429 |
|
||
| 50014 | 状态机非法迁移(如 `pending → archived` 跳过 reviewing)| 409 |
|
||
| | **响应体**:`{ code, message, current_status: "pending"|"reviewing"|"auto_hidden"|"resolved"|"dismissed", suggested_action: "re_claim"|"wait_for_admin"|"contact_support" }`(v2.5 新增)—— race 后 admin A 拿到的不是 50008("已被他人认领")而是 50014("工单已被释放或结案,请 re-claim 后再操作"),前端按 `current_status` 智能提示 | | |
|
||
| 50015 | 管理员未登录 / Token 失效 | 401 |
|
||
| **50016**(v2.3)| 举报对象类型不支持(`target_type` 不在白名单 `{'asset','user_profile'}`)| 400 |
|
||
| **50017**(v2.3)| 回复 / 备注 必填路径(path C/D/E 选 dismiss/unban 时需填 `reason`)| 400 |
|
||
| **50018**(v2.3)| 证据图超限(单图 > 5MB / MIME 非 png/jpeg)| 400 |
|
||
| **50019**(v2.3)| 举报不可编辑(仅 `status='pending'` 可改 `description`)| 409 |
|
||
|
||
> **50008 vs 50009 判定顺序**(v2.3 明确):
|
||
> - 客户端发 claim/reply/close/archive,0 行匹配时 handler 二次 `SELECT` 查当前状态:
|
||
> 1. 记录不存在(`id` 未找到)→ 50007 "工单不存在"
|
||
> 2. `status='reviewing'` 且 `claimed_by<>本人` → 50008 "工单已被他人认领"(响应体含 `claimed_by` / `claimed_at`,见 50008 行)
|
||
> 3. `status IN ('replied','closed','archived')`(反馈)/ `('resolved','dismissed')`(举报)→ 50009 "工单已结案"
|
||
> 4. `status='reviewing'` 且 `claimed_by=本人` → 200 幂等成功(re-claim 同人)
|
||
> 5. `status='pending'` + 请求非 claim(如 takedown)→ 50014 状态机非法迁移(pending → resolved 跳过 reviewing)|
|
||
|
||
---
|
||
|
||
## 8. 通知机制
|
||
|
||
复用 `notificationService` 模块,注册 6 个模板:
|
||
|
||
| 事件 | 接收方 | 模板名 | 渠道 |
|
||
|------|--------|--------|------|
|
||
| 举报达到阈值触发自动隐藏 | 被举报方 | `report_auto_hidden_notice` | 站内信 |
|
||
| 举报状态变更为 resolved/dismissed | 举报人 | `report_resolved` | 站内信 + 通知中心 |
|
||
| 举报成立 + takedown | 被举报方 | `report_takedown_notice` | 站内信 |
|
||
| 举报成立 + ban | 被举报用户 | `report_ban_notice` | 站内信 + 短信 |
|
||
| 举报成立 + warn | 被举报用户 | `report_warn_notice` | 站内信 |
|
||
| 反馈被回复 | 反馈人 | `feedback_replied` | 站内信 + 通知中心 |
|
||
|
||
> 模板字段:`{reporter_nickname, target_type, target_id, action, reason, related_url}`
|
||
>
|
||
> **模板复用说明**:`report_resolved` 同时覆盖 `resolved` 和 `dismissed` 两种终态(消息体根据 `resolved_action` 区分"已下架/已封禁/已驳回");如未来需文案差异化,可拆分为 `report_resolved_notice` + `report_dismissed_notice` 两模板(v1.8 评估)。
|
||
|
||
---
|
||
|
||
## 9. 安全与防护
|
||
|
||
### 9.1 防护机制
|
||
|
||
| 机制 | 实现 |
|
||
|------|------|
|
||
| 防重复举报 | DB 局部唯一索引 `uk_reports_reporter_target_pending`(结案后允许再次举报)|
|
||
| 防 Redis 计数重复 | Lua 脚本用 `user_marker` SETNX 保证独立用户才 INCR |
|
||
| 防自动隐藏并发 | Redis 短锁 `mod:report:lock:*` 5s + DB 幂等 SQL(仅 pending → auto_hidden)|
|
||
| 证据图校验 | OSS 签名接口限定目录 `report/` 和 `feedback/` 前缀 |
|
||
| **证据图大小/类型**(v2.4 修订)| **moderationService 不能直接校验**(证据图通过 `GET /api/v1/assets/oss/signature?type=asset` 拿 presigned URL 后**客户端直传 OSS**,moderationService 只收到 `key` 字符串,不见字节)。校验在 **OSS bucket policy 层**实施:单图 ≤ 5MB / MIME 限 `image/png` `image/jpeg` / OSS 返回 4xx 时客户端应捕获并**不**提交 report;服务端 9.1 仍 50018 作为**防御纵深**(客户端篡改 key 提交,提交时校验 `evidence_keys` 字符串格式与 `report/` 前缀,但**不**二次校验大小/MIME——OSS 已校验)。监控指标 `moderation.oss.evidence_upload_failed.count`(OSS 4xx 响应)|
|
||
| 描述长度 | 服务端校验 ≤ 500 字(DB 字段 VARCHAR(500))|
|
||
| 证据图数量 | 服务端校验 ≤ 5 张 |
|
||
| 匿名保护 | `is_anonymous=true` 时:`reporter_id` 仍记录但 API 响应中不返回给被举报方(**仅对被举报方匿名**);**后台所有审核员可见** `reporter_id`(1.3 范围"仅一种角色"——不存在超级管理员;如需审核员侧也匿名,需解除 YAGNI 限制并新增 super_admin 角色)|
|
||
| 自举报拦截 | 提交举报时校验 `reporter_id != owner_id(target)`,命中则返回 50011 |
|
||
| **IP 限流**(v2.3)| 每 IP 每天 ≤ 200 次举报(Redis 计数 `mod:rl:report:ip:{ip}:{yyyymmdd}`,TTL 36h);与 user_id 限流 `AND` 语义(任一超限即 50012)—— 防御多账号 spam 攻击 |
|
||
| **Device fingerprint 限流**(v2.3)| 每设备指纹每天 ≤ 200 次举报(`mod:rl:report:device:{fp}:{yyyymmdd}`);同上 `AND` 语义 |
|
||
| **star_id stale 容忍**(v2.3)| `reports.star_id` 是 denormalized 副本,跨表无 FK;star binding 转移后 reports.star_id 不回溯更新 |
|
||
| 全局限流(举报) | 每用户每天 ≤ 30 次举报(Redis 计数 `mod:rl:report:user:{user_id}:{yyyymmdd}`,TTL 36h);超限返回 50012 |
|
||
| 全局限流(反馈) | 每用户每天 ≤ 5 条反馈(Redis 计数 `mod:rl:feedback:user:{user_id}:{yyyymmdd}`,TTL 36h);超限返回 50013 |
|
||
| **Claim 超时自动释放**(v2.3)| 15 分钟未操作的 `reviewing` 工单由 cron 任务自动回退到 `pending`:`UPDATE reports SET status='pending', claimed_by=NULL, claimed_at=NULL WHERE status='reviewing' AND claimed_at < now() - INTERVAL '15 minutes'`;监控指标 `moderation.reports.claim_timeout.count` |
|
||
|
||
### 9.2 数据隔离
|
||
|
||
- 客户端 JWT 仅可看自己的举报/反馈(`reporter_id = current_user_id`)
|
||
- 后台 JWT 走 `TopFans-activity` 现有 `verify_token`,所有接口都需 `Depends(verify_token)`
|
||
- 后续如要分级权限(仅超管可恢复隐藏等),在 JWT payload 加 `role` 字段
|
||
|
||
### 9.3 数据保留
|
||
|
||
- `reports` / `feedbacks` 永久保留(合规需要)
|
||
- `moderation_actions` 永久保留(审计)
|
||
- `moderation_target_status` UPSERT 保留最新状态,历史可查 `moderation_actions`
|
||
- **PII 匿名化**(v2.3 新增):`feedbacks.contact` 字段(邮箱/手机/微信)在 `status IN ('closed','archived')` 后 **90 天** 自动匿名化为 `NULL`,由每日 cron 执行:`UPDATE feedbacks SET contact=NULL WHERE status IN ('closed','archived') AND updated_at < now() - INTERVAL '90 days' AND contact IS NOT NULL`;监控 `moderation.feedback.pii_anonymized.count`;与 GDPR / 中国《个人信息保护法》"最小必要 + 限期保存"原则一致
|
||
|
||
---
|
||
|
||
## 10. 实施步骤
|
||
|
||
### 阶段 1:数据库(topfans 库)
|
||
- 1.1 写迁移 `backend/migrations/2026_06_11_011_moderation_tables.sql`(接续 `2026_06_08_010_statistic_partitions_initial.sql` 编号 010,次日首个新文件应为 011)
|
||
- 严格遵守 `YYYY_MM_DD_NNN_` 三位编号递增约定
|
||
- 1.2 包含 9 张表 + 索引 + 默认分类种子数据
|
||
- 1.3 末尾必须 `SELECT setval('xxx_id_seq', (SELECT MAX(id) FROM xxx))`(CLAUDE.md 强制)
|
||
- 1.4 测试数据脚本(可选):在 `backend/scripts/` 加 Go 脚本生成额外测试数据;如 SQL 迁移已含种子数据则跳过
|
||
|
||
### 阶段 2:Go moderationService(topfans)
|
||
- 2.1 `backend/services/moderationService/` 新建目录
|
||
- `main.go` / `config/` / `repository/` / `service/` / `provider/` / `client/` / `start.sh` / `configs/dubbo.yaml` / `go.mod`
|
||
- 复用 socialService 类似的分层
|
||
- 端口:`PORT=20010`(接续 aiChatService 20008 + 2)
|
||
- `start.sh` 模板(参考 `services/socialService/start.sh`):
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
export SERVICE_NAME=moderation-service
|
||
export PORT=${PORT:-20010}
|
||
export DB_HOST=${DB_HOST:-localhost}
|
||
export DB_PORT=${DB_PORT:-15432}
|
||
export DB_USER=${DB_USER:-postgres}
|
||
export DB_PASSWORD=${DB_PASSWORD:-123456}
|
||
export DB_NAME=${DB_NAME:-top-fans}
|
||
export DB_SSLMODE=${DB_SSLMODE:-disable}
|
||
export REDIS_HOST=${REDIS_HOST:-localhost}
|
||
export REDIS_PORT=${REDIS_PORT:-6379}
|
||
export REDIS_PASSWORD=${REDIS_PASSWORD:-123456}
|
||
export REDIS_DB=${REDIS_DB:-0}
|
||
export DUBBO_USER_SERVICE_URL=${DUBBO_USER_SERVICE_URL:-tri://localhost:20000}
|
||
export DUBBO_ASSET_SERVICE_URL=${DUBBO_ASSET_SERVICE_URL:-tri://localhost:20003}
|
||
export DUBBO_NOTIFICATION_SERVICE_URL=${DUBBO_NOTIFICATION_SERVICE_URL:-tri://localhost:20008}
|
||
exec ./moderation-service
|
||
```
|
||
- `configs/dubbo.yaml` 模板(参考 `services/socialService/configs/dubbo.yaml`):
|
||
```yaml
|
||
application:
|
||
name: moderation-service
|
||
protocol:
|
||
name: tri
|
||
port: 20010
|
||
registry:
|
||
address: nacos://${NACOS_ADDR:-localhost:8848}
|
||
```
|
||
- 2.2 `backend/proto/moderation/moderation.proto`(项目根 `proto/` 集中管理)
|
||
- `SubmitReportRequest/Response`
|
||
- `ListMyReportsRequest/Response`
|
||
- `GetReportRequest/Response`
|
||
- `GetReportCategoriesRequest/Response`
|
||
- `SubmitFeedbackRequest/Response`
|
||
- `ListMyFeedbacksRequest/Response`
|
||
- `GetFeedbackRequest/Response`
|
||
- `GetFeedbackCategoriesRequest/Response`
|
||
- 2.3 `backend/pkg/proto/moderation/` 编译生成 `moderation.pb.go` + `moderation.triple.go`(参考 `pkg/proto/social/` 已生成的产物;`.triple.go` 是 Dubbo Triple 协议 stub,缺它 RPC 客户端无法生成)
|
||
- 2.4 `backend/pkg/models/moderation.go` ORM 模型(socialService 共享 pkg 模式)
|
||
- 2.5 `backend/services/moderationService/repository/moderation_repository.go` + `moderation_repository_test.go`(testcontainers 集成测试)
|
||
- 2.6 `backend/services/moderationService/service/`:
|
||
- `report_service.go`(含 6.2 事务内双重/三重写入 helper)
|
||
- `feedback_service.go`
|
||
- `category_service.go`
|
||
- `auto_hide_service.go`(含 Redis Lua 脚本 `//go:embed` 加载)
|
||
- `transaction_helper.go`(封装 `WithTx(ctx, func(tx *gorm.DB) error)`,业务表+mts+reports 原子性保证)
|
||
- 2.7 `backend/services/moderationService/client/`:
|
||
- `asset_client.go`(验证目标存在)
|
||
- `user_client.go`(验证用户/封禁查询)
|
||
- `notification_client.go` **(v1.5: 阶段 2 仅写 stub,阶段 7.1 替换为真实实现——见 M7)**
|
||
- `redis_client.go`(Redis 客户端初始化,参考 aiChatService `main.go`)
|
||
- 2.8 `backend/services/moderationService/provider/moderation_provider.go` Dubbo 注册(**v1.5: 改名与本服务对齐,**之前误用 `social_provider.go`)
|
||
- 2.9 `backend/go.work` 加入新服务 + `docker/Dockerfile.services` 加 `moderationservice` 构建行 + `docker/docker-compose.local.yml` + `docker-compose.prod.yml` 加服务定义 + `Makefile` 加 `start-moderationservice` 目标
|
||
- 2.10 Gateway 层:
|
||
- `backend/gateway/controller/moderation_controller.go` + `func NewModerationController(cli *client.Client) (*ModerationController, error)` 工厂方法
|
||
- `backend/gateway/dto/moderation_dto.go`
|
||
- `backend/gateway/dto/moderation_converter.go`(RPC pb ↔ HTTP DTO 字段映射)
|
||
- `backend/gateway/client/moderation_client.go`(gateway 端调用本服务的 Dubbo client)
|
||
- `backend/gateway/router/router.go` 注册 `/api/v1/moderation/*`(**仅客户端路由**;后台 `/api/admin/*` 由 FastAPI 处理,见阶段 3)
|
||
- 2.11 服务可观测性:`backend/services/moderationService/main.go` 暴露 `promhttp.Handler()` 在 `:20010/metrics`(注册 5+ 个指标,详见 11.3)
|
||
|
||
**Gateway 路由配置清单**(在 `router.go` 新增):
|
||
```go
|
||
// 举报分类
|
||
v1.GET("/moderation/report-categories", moderationController.GetReportCategories)
|
||
v1.GET("/moderation/feedback-categories", moderationController.GetFeedbackCategories)
|
||
|
||
// 举报
|
||
v1.POST("/moderation/reports", moderationController.SubmitReport)
|
||
v1.GET("/moderation/reports", moderationController.ListMyReports)
|
||
v1.GET("/moderation/reports/:id", moderationController.GetReportDetail)
|
||
|
||
// 反馈
|
||
v1.POST("/moderation/feedbacks", moderationController.SubmitFeedback)
|
||
v1.GET("/moderation/feedbacks", moderationController.ListMyFeedbacks)
|
||
v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail)
|
||
```
|
||
|
||
**Dubbo 服务注册**:`moderationService` 启动时通过 `provider/social_provider.go`(参考 socialService 模式)注册到 ZooKeeper/Nacos;`gateway` 端通过 `client/moderation_client.go` 引用。无需修改其他服务的注册配置。
|
||
|
||
### 阶段 3:TopFans-Activity 后台 API(FastAPI)
|
||
|
||
> **范围说明**:以下产物在外部仓库 `D:\shanghai\TopFans-activity` 实施,本仓(topfans)仅做规范约定,不阻塞本仓 release;DB schema 跨仓库共享 `top-fans` 库,是唯一耦合点。
|
||
|
||
- 3.1 `TopFans-activity/backend/models/moderation.py` SQLAlchemy ORM(与 PG 表结构对应)
|
||
- 3.2 `TopFans-activity/backend/schemas/moderation.py` Pydantic 模型
|
||
- 3.3 `TopFans-activity/backend/crud/moderation_crud.py` 数据库操作
|
||
- `list_reports(filters, page) / get_report_detail(id) / claim_report(id) / execute_action(id, action, ...) / list_categories() / upsert_category() / ...`
|
||
- 3.4 `TopFans-activity/backend/handlers/moderation_admin.py` 路由层
|
||
- 复用现有 `verify_token` 中间件
|
||
- **admin token 解析**:`verify_token(authorization) → (admin_id, role, exp)`;role 当前不做分级校验(仅记录),`admin_id` 写入 `moderation_actions.admin_id` / `admin_audit_logs.admin_id`
|
||
- **后台路由前缀**:`/api/admin/moderation/*`(在 `TopFans-activity/backend/router/__init__.py` 注册,不在本仓 gateway 注册)
|
||
- 3.5 `TopFans-activity/backend/router/__init__.py` 注册路由
|
||
- 3.6 数据库迁移(如 ORM 与 PG 表不一致时):
|
||
- `TopFans-activity/backend/migrations/2026_06_11_001_moderation_init.sql`(用日期前缀保持与本仓命名一致;如果用 SQLAlchemy `create_all` 可省略)
|
||
|
||
### 阶段 4:后台前端 (Vue3)
|
||
- 4.1 `D:\shanghai\TopFans-activity\frontend\src\api\moderation.js` axios 封装(新增文件)
|
||
- 复用既有 `utils/request.js` 的 axios 拦截器(已含 JWT 注入),不需要额外配置
|
||
- `getReports()` / `getReportDetail()` / `claimReport()` / `executeAction()` / ...
|
||
- `getReportCategories()` / `createCategory()` / `updateCategory()` / `deleteCategory()` / ...
|
||
- 4.2 `D:\shanghai\TopFans-activity\frontend\src\views\moderation\`
|
||
- `ReportList.vue` 举报工单列表(筛选:状态/分类/目标类型/关键词)
|
||
- `ReportDetail.vue` 举报详情(证据图、目标快照、举报人、流水)+ 审核动作按钮
|
||
- `FeedbackList.vue` 反馈列表
|
||
- `FeedbackDetail.vue` 反馈详情 + 回复表单
|
||
- `ReportCategoryConfig.vue` 举报分类 CRUD
|
||
- `FeedbackCategoryConfig.vue` 反馈分类 CRUD
|
||
- `Dashboard.vue` 审核看板(可选)
|
||
- 4.3 `D:\shanghai\TopFans-activity\frontend\src\router\index.js` 加路由
|
||
- `/moderation/reports`
|
||
- `/moderation/reports/:id`
|
||
- `/moderation/feedbacks`
|
||
- `/moderation/feedbacks/:id`
|
||
- `/moderation/categories/report`
|
||
- `/moderation/categories/feedback`
|
||
- 4.4 菜单注册(按 `Layout.vue` 现成结构)
|
||
- 4.5 Element Plus 组件复用:el-table / el-form / el-dialog / el-image / el-tag / el-pagination
|
||
|
||
### 阶段 5:客户端 uni-app
|
||
- 5.1 在既有 `D:\shanghai\topfans\frontend\utils\api.js` **末尾追加** 8 个方法(不新建文件):
|
||
- `getReportCategoriesApi()` / `submitReportApi(payload)` / `getMyReportsApi()` / `getMyReportDetailApi(id)`
|
||
- `getFeedbackCategoriesApi()` / `submitFeedbackApi(payload)` / `getMyFeedbacksApi()` / `getMyFeedbackDetailApi(id)`
|
||
- 5.2 `D:\shanghai\topfans\frontend\components\ReportModal.vue` 通用举报弹窗
|
||
- props: `targetType: 'asset' | 'user_profile'`, `targetId: number`, `targetName: string`(类型断言)
|
||
- 表单:分类单选 + 描述 textarea + 证据图上传(最多 5 张)+ 匿名开关
|
||
- 调 `submitReportApi` 后根据返回值双分支提示:
|
||
- `if (res.data.target_hidden)` → `uni.showToast({ title: '已自动隐藏,等待审核', icon: 'none' })`
|
||
- `else` → `uni.showToast({ title: '举报已提交', icon: 'success' })`
|
||
- UI 库:用 `uView 2.x` 的 `u-popup` + `u-form` + `u-upload`(与既有 `frontend/components/` 一致)
|
||
- 5.3 在以下位置接入 `ReportModal`:
|
||
- 藏品详情页 (`NftDetailModal.vue` 等) 长按或"..."菜单
|
||
- 用户主页(粉丝身份下)"..."菜单
|
||
- 描述词展示组件(如果有)的"..."菜单
|
||
- 5.4 `D:\shanghai\topfans\frontend\pages\profile\feedback.vue` 意见反馈页
|
||
- 复用 `ReportModal` 的 evidence 上传组件
|
||
- 表单:分类下拉 + 标题 + 内容 + 联系方式(可选) + 截图
|
||
- 5.5 `D:\shanghai\topfans\frontend\pages\profile\myReports.vue` 我的举报列表
|
||
- 5.6 `D:\shanghai\topfans\frontend\pages\profile\myFeedbacks.vue` 我的反馈列表
|
||
- 5.7 页面间跳转:从"我的" → 各项入口
|
||
- 在 `D:\shanghai\topfans\frontend\pages\profile\profile.vue`(既有"我的"页面)菜单项中追加:
|
||
- "我的举报" → `/pages/profile/myReports`
|
||
- "我的反馈" → `/pages/profile/myFeedbacks`
|
||
- 提交举报/反馈成功后用 `uni.navigateBack()` 返回上一页,不再额外跳转"我的"页(避免多余导航)
|
||
|
||
### 阶段 6:业务服务读路径(沿用既有软删除过滤,无须改业务 SQL)
|
||
- 6.1 `assetService` 藏品查询/详情/列表
|
||
- 既有 SQL 已含 `WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL`,由审核动作产生的下架/封禁已通过软删除自动过滤,**本阶段无须改 SQL**
|
||
- 6.2 `userService` 登录/操作校验
|
||
- 既有 SQL 已含 `WHERE users.is_active = TRUE AND users.deleted_at IS NULL`,由审核动作产生的 ban 已通过软删除自动拦截,**本阶段无须改 SQL**
|
||
- 6.3 描述词(`assets.description` 字段,随 asset 软删除自动过滤)
|
||
- 既有 SQL 已含 `WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL`,**本阶段无须改 SQL**
|
||
- 6.4 用户警告展示(仅前端/详情页需要,**非热路径**)
|
||
- `LEFT JOIN moderation_target_status ON target_type='user_profile' AND target_id=users.id WHERE is_warned = TRUE`
|
||
- 用于在用户主页/控制台展示最近一次警告原因与时间;不阻塞登录
|
||
- 6.5 性能优化(如警告查询变热):
|
||
- Redis 缓存 `mod:warn:cache:{user_id}` TTL 60s
|
||
- 命中即跳过 JOIN
|
||
|
||
### 阶段 6.5:定时任务(v2.3 新增)
|
||
|
||
每日 cron 任务(建议挂载在 K8s CronJob 或 systemd timer):
|
||
|
||
> **步骤顺序约束**(v2.5 文档化,避免 PII 时钟被业务操作重置):
|
||
> 1. **先 PII 匿名化**(用 `closed_at`/`replied_at`/`archived_at` 而非 `updated_at`,与具体业务操作解耦)
|
||
> 2. **后 auto-archive**(status='pending' 状态 90 天未处理)
|
||
> 3. **最后 claim 超时回收**(reviewing 状态 15 分钟未操作)
|
||
>
|
||
> 若一个反馈既 > 90 天 pending 又被 stale-claim 锁定 91 天,链路是:claim 释放 → status='pending' → auto-archive → status='archived'。PII 匿名化时钟从 `archived_at` 起(不是 `created_at`)——admin 调查时间会延长 PII 保留窗口(这是 GDPR/中国《个人信息保护法》"最小必要"原则下可接受的 trade-off)。
|
||
|
||
```sql
|
||
-- 1. PII 匿名化(feedbacks.contact 90 天后清空;v2.5 扩到所有终态 replied/closed/archived)
|
||
UPDATE feedbacks
|
||
SET contact = NULL
|
||
WHERE contact IS NOT NULL
|
||
AND (
|
||
-- 'replied' 没有 closed_at/archived_at,用 replied_at(PII 时钟从回复时刻起 90 天)
|
||
(status = 'replied' AND replied_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
|
||
OR (status = 'closed' AND closed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
|
||
OR (status = 'archived' AND archived_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
|
||
);
|
||
-- v2.5 修复 v2.4 GDPR 漏洞:之前只覆盖 closed/archived,漏了 'replied'(admin 回复后用户的 contact 永不匿名化)
|
||
|
||
-- 2. 过期 pending 工单自动 archive(reports)—— v2.5 补 archived_at + resolution_note
|
||
UPDATE reports
|
||
SET status = 'archived',
|
||
updated_at = EXTRACT(EPOCH FROM NOW()) * 1000,
|
||
resolution_note = 'auto-archived: pending > 90 days'
|
||
WHERE status = 'pending'
|
||
AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
|
||
|
||
-- 2b. 过期 pending 工单自动 archive(feedbacks)—— v2.5 补 archived_by/archived_at(满足 chk_feedbacks_archived_fields)+ 审计行
|
||
UPDATE feedbacks
|
||
SET status = 'archived',
|
||
archived_by = 0, -- 0 = system auto sentinel (与 admin_id CHECK 配合)
|
||
archived_at = EXTRACT(EPOCH FROM NOW()) * 1000,
|
||
updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
|
||
WHERE status = 'pending'
|
||
AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
|
||
|
||
-- 2c. 审计行(v2.5 新增)—— 为刚 archived 的 feedbacks 写 moderation_actions 流水
|
||
INSERT INTO moderation_actions
|
||
(report_id, feedback_id, admin_id, action_type, target_type, target_id,
|
||
note, success, created_at)
|
||
SELECT NULL, id, 0, 'archive', 'user_profile', id,
|
||
'auto-archived by cron: pending > 90 days', TRUE, archived_at
|
||
FROM feedbacks
|
||
WHERE status = 'archived' AND archived_by = 0
|
||
AND archived_at = EXTRACT(EPOCH FROM NOW()) * 1000;
|
||
|
||
-- 3. Claim 超时自动释放(每 5 分钟跑)
|
||
UPDATE reports
|
||
SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
|
||
WHERE status = 'reviewing'
|
||
AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
|
||
-- 同理 feedbacks
|
||
UPDATE feedbacks
|
||
SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
|
||
WHERE status = 'reviewing'
|
||
AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
|
||
```
|
||
|
||
> **监控指标**:
|
||
> - `moderation.feedback.pii_anonymized.count` — 每日匿名化条数
|
||
> - `moderation.reports.auto_archived.count` — 每日 archive 条数
|
||
> - `moderation.reports.claim_timeout.count` — claim 超时回收条数
|
||
|
||
### 阶段 7:通知与测试
|
||
- 7.1 通知模板注册到 `notificationService`(6 个模板:含自动隐藏)
|
||
- 7.2 Go 单元测试
|
||
- `report_service_test.go` 防重复、自动隐藏触发、dismiss 4 路径、**claimed_* 字段一致性**——覆盖 claim/release/dismiss 4 路径各自清零的 CHECK 违例用例
|
||
- `feedback_service_test.go` 状态机 + **claimed_* 字段一致性**——覆盖 claim/release/reply/close/archive 各自清零的 CHECK 违例用例
|
||
- `auto_hide_service_test.go` Lua 脚本逻辑
|
||
- `concurrency_test.go` 并发场景(v1.5 新增):
|
||
- 100 用户同时举报同一对象,counter INCR 200 次但只触发 1 次自动隐藏
|
||
- 2 个审核员同时 claim 同 report,1 成功 1 返回 50008
|
||
- 自动隐藏 Lua 脚本 + 5s 短锁的交互(errgroup 模拟并发)
|
||
- 7.3 FastAPI 单元测试
|
||
- `test_moderation_admin.py` 列表/详情/认领/动作(含 dismiss 4 路径分支覆盖)
|
||
- 鉴权测试(无 token 401)
|
||
- Fixture:用 `httpx.AsyncClient` + pytest;admin token 用 `TopFans-activity` 现有 `make_test_token()`
|
||
- 7.4 集成测试(v1.5 增列隔离策略)
|
||
- **测试 DB 隔离**:
|
||
- 使用 `github.com/testcontainers/testcontainers-go` 启临时 PG 16 + Redis 7 容器(每个测试套件独立实例)
|
||
- 业务表自动跑阶段 1.1 的 migration(不依赖生产 DB)
|
||
- Redis key 全部加测试前缀 `test:mod:report:*` 避免污染
|
||
- **跨服务 mock 策略**:
|
||
- `assetService.GetAssetRPC` / `userService.GetUserRPC` 在单元测试层 mock;集成测试用真起 socialService / assetService 容器
|
||
- `notificationService` 在所有测试层 mock(仅验证调用参数,不真发通知)
|
||
- **端到端场景**:
|
||
- 提交举报 → 后台审核 → 状态变更 → 用户收到通知
|
||
- 自动隐藏:5 个独立用户举报 → 自动隐藏 + 状态表写入
|
||
- dismiss 4 路径覆盖:pending 快速通道 / 普通 reviewing / auto_hidden 恢复 / auto_hidden 不恢复
|
||
- 跨服务事务回滚:模拟业务表软删除成功但 mts UPSERT 失败,验证整体回滚
|
||
- **Lua 脚本测试**:需要真实 Redis(testcontainer),验证 SETNX/INCR/EXPIRE 的边界
|
||
- 7.5 前端联调
|
||
- uni-app → gateway → Go service
|
||
- Vue3 后台 → FastAPI → PG
|
||
- 用 gateway 已暴露的 Swagger UI(`router.go` line 30-33)做端到端联调
|
||
|
||
---
|
||
|
||
## 11. 附录
|
||
|
||
### 11.1 配置文件
|
||
|
||
在 `D:\shanghai\topfans\backend\services\moderationService\config\moderation_config.go`:
|
||
```go
|
||
type ModerationConfig struct {
|
||
AutoHideThreshold int // 默认 5
|
||
CounterTTL time.Duration // 7 天
|
||
UserMarkerTTL time.Duration // 24h
|
||
LockTTL time.Duration // 5s
|
||
MaxDescriptionLen int // 500
|
||
MaxEvidenceCount int // 5
|
||
MaxContactLen int // 320(RFC 5321 完整邮箱地址上限;feedbacks.contact 服务端校验)
|
||
MaxReplyLen int // 2000(feedbacks.reply_content 服务端校验,v2.3)
|
||
MaxEvidenceSize int64 // 5MB 单图上限(v2.3)
|
||
AllowedMimeTypes []string // ["image/png", "image/jpeg"](v2.3)
|
||
RateLimitUserPerDay int // 30(举报)/ 5(反馈)
|
||
RateLimitIPPerDay int // 200(v2.3,IP 限流防多账号 spam)
|
||
RateLimitDevicePerDay int // 200(v2.3,设备指纹限流)
|
||
ClaimTimeout time.Duration // 15 分钟(v2.3,cron 任务自动释放)
|
||
PIIAnonymizeAfter time.Duration // 90 天(v2.3,feedbacks.contact 自动匿名化)
|
||
AutoArchiveAfter time.Duration // 90 天(v2.3,pending 工单自动 archive)
|
||
RedisKeyPrefix string // "mod:report"(生产)| "test:mod:report"(测试,v2.3)
|
||
PathDCounterPolicy string // "reset" | "preserve" | "expire_1h"(v2.3,路径 D 的 Redis counter 行为)
|
||
}
|
||
```
|
||
|
||
### 11.2 跨服务调用清单
|
||
|
||
| 调用方 | 被调服务 | 触发时机 | 失败处理 |
|
||
|--------|----------|----------|----------|
|
||
| moderationService | assetService.GetAssetRPC | 提交举报 `target_type='asset'` 验证目标(含 `assets.description` AI 描述词随表过滤)| 返回 50003 |
|
||
| moderationService | userService.GetUserRPC | 提交举报 `target_type='user_profile'` 验证用户 | 返回 50003 |
|
||
| moderationService | notificationService | 自动隐藏 + 动作结果通知(6 个模板:`report_auto_hidden_notice` / `report_resolved` / `report_takedown_notice` / `report_ban_notice` / `report_warn_notice` / `feedback_replied`)| 仅日志,不影响主流程 |
|
||
| TopFans-Activity 后台 | (无 RPC — 仅读写 `top-fans.public` 库;写 `reports.claimed_by/claimed_at`(claim 写入 / release-dismiss 清零)、`reports.resolved_action/resolved_by/resolved_at/resolution_note`(takedown/ban/warn/dismiss 终态)、`feedbacks.claimed_by/claimed_at`(claim 写入 / release-reply-close-archive 清零)、`feedbacks.replied_by/replied_at/reply_content`(reply 终态)、`feedbacks.closed_by/closed_at`(close 终态)、`feedbacks.archived_by/archived_at`(archive 终态)、`reports.status` / `feedbacks.status` / `moderation_target_status` 全字段;每次 admin 动作同时写 `moderation_actions` + `admin_audit_logs` 流水;与本仓 moderationService 无 RPC 依赖) | - | - |
|
||
| assetService | (无 — 业务表 `WHERE is_active=TRUE AND deleted_at IS NULL` 软删除过滤已覆盖) | - | - |
|
||
| userService | (无 — 业务表 `WHERE is_active=TRUE AND deleted_at IS NULL` 软删除过滤已覆盖) | - | - |
|
||
|
||
### 11.3 监控指标(建议)
|
||
|
||
**业务成功路径**:
|
||
- `moderation.report.submitted.count` (counter, tags: target_type, category_code)
|
||
- `moderation.report.auto_hidden.count` (counter)
|
||
- `moderation.report.resolve.duration` (histogram, 从 created_at 到 resolved_at)
|
||
- `moderation.feedback.reply.duration` (histogram)
|
||
|
||
**失败/异常类**(v1.5 补充,11.2 已声明"通知失败仅日志",必须可观测):
|
||
- `moderation.actions.failed.count` (counter, tags: action_type, error) — 已有
|
||
- `moderation.report.duplicate.count` (counter, tag: target_type) — 防重复触发次数
|
||
- `moderation.auto_hide.lock.contention.count` (counter) — 5s 短锁抢占失败次数(监控 Lua 锁是否合理)
|
||
- `moderation.notification.send_failed.count` (counter, tag: template) — 6 个模板的失败分布
|
||
- `moderation.transaction.rollback.count` (counter, tag: flow=auto_hide|takedown|ban|dismiss) — 事务回滚次数
|
||
|
||
**延迟/SLO 类**(v1.5 补充):
|
||
- `moderation.api.latency` (histogram, tag: endpoint, status) — 客户端 + 后台接口 P50/P95/P99
|
||
- `moderation.dashboard.queue.pending` (gauge) — pending 状态工单数(运营告警:> N 触发告警)
|
||
- `moderation.dashboard.queue.auto_hidden` (gauge) — 待认领 auto_hidden 工单数
|
||
|
||
**运行时(自动注册)**:
|
||
- Go runtime 默认指标(goroutine 数、heap、GC pause)— `collectors.NewGoCollector()` 自动注册
|
||
|
||
**Grafana 看板 + 告警规则**:另行维护(不阻塞本设计)。
|
||
|
||
**v2.3 新增指标**:
|
||
- `moderation.report.auto_hidden.noop.count` (counter) — auto-hide 触发但目标已 `is_active=false` 的 no-op 次数
|
||
- `moderation.actions.auto_hide.count` (counter, tag: target_type) — 系统自动 takedown 次数(与人工 takedown 区分)
|
||
- `moderation.actions.warn_cleared.count` (counter) — warn_cleared 流程触发次数
|
||
- `moderation.redis.counter_reset.count` (counter, tag: path=C|D) — Redis counter 重置监控
|
||
- `moderation.feedback.pii_anonymized.count` (counter) — 每日 PII 匿名化条数
|
||
- `moderation.reports.auto_archived.count` (counter) — 每日 auto-archive 条数
|
||
- `moderation.reports.claim_timeout.count` (counter) — claim 超时自动释放条数
|
||
- `moderation.feedback.terminal_state.claim_attempt.count` (counter, tag: status=replied|closed|archived) — 监控试图认领终态工单次数(验证 50009 区分是否生效)
|
||
- `moderation.api.latency.evidence_upload.p95` (histogram) — 证据上传 P95 延迟
|
||
- `moderation.rate_limit.hit.count` (counter, tag: type=report|feedback, scope=user|ip|device) — 限流触发监控
|
||
|
||
### 11.4 变更历史
|
||
|
||
| 版本 | 日期 | 作者 | 变更内容 |
|
||
|------|------|------|----------|
|
||
| v1.0 | 2026-06-11 | Claude | 初始设计 |
|
||
| v1.1 | 2026-06-11 | Claude | 自审修复:target_type 命名统一、跨 schema JOIN 明确、Lua lock 改应用层、6.1 流程补限流/自举报、6.2 dismiss 路径细化、6.3 反馈补 release 流程 |
|
||
| v1.2 | 2026-06-11 | Claude | 数据库表字段修复:`reports.resolved_action` 动作名 `dismissed`→`dismiss`;6.2 业务 JOIN `target_type='user'`→`'user_profile'`;`moderation_actions.xor` CHECK 改 XOR、补 `action_type`/`target_pair`/`success_msg` 三个 CHECK;`moderation_target_status` 补 `target_type`/`source`/`source_report_id` 三个 CHECK + 审计索引;`report_categories.severity` 加 1-5 CHECK;`admin_audit_logs.ip` 50→100(兼容 IPv6+代理链);`is_auto_hidden` 补迁移语义注释 |
|
||
| v1.3 | 2026-06-11 | Claude | **对齐项目软删除模式**:`moderation_target_status` 去除 `is_hidden` / `is_banned` 字段(项目用 `assets`/`users` 自带的 `is_active` + `deleted_at` 软删除表达下架/封禁),保留 `is_warned`(软状态不阻塞登录),新增 `warn_count`/`last_warned_at`/`last_action_type`;审核动作改为"事务内双重写入"——按 `target_type` 路由到对应业务表做软删除/恢复,同时 UPSERT `moderation_target_status`(审计);6.1 自动隐藏、6.2 takedown/ban/dismiss 全部改写;6.2/阶段 6 业务读路径无需改 SQL(既有软删除过滤已覆盖);状态机迁移表、孤儿清理、跨 schema 说明、架构图模块职责同步更新 |
|
||
| v1.4 | 2026-06-11 | Claude | **自审修复(25 项)**:(C1)砍掉幽灵表 `aichat.ai_descriptions`——确认 `assets.description` 即 AI 描述词,删除 `description_word` 举报类型,移除所有跨 schema 假设;(H1)展开 3.2/3.3 初始数据完整 SQL(消除 `...` 占位符);(H2)9 张表 `CREATE SEQUENCE` 全部加 `OWNED BY`(让 `setval()` 生效,CLAUDE.md 强制);(H3)2.1/2.2 关键边界移除已废弃的 `LEFT JOIN moderation_target_status` 描述;(H4)状态机迁移矩阵补 `pending → dismissed` 快速通道、区分 dismiss 与 dismiss-restore 副作用;(H5 隐含)11.2 调用清单同步去掉 aichat 跨 schema;(H6)`reports`/`feedbacks` 加 `status`/`target_type`/`resolved_action`/`replied/claimed` 4 类 CHECK 约束、终态字段一致性保护;(M1)`moderation_actions.action_type` 枚举 `unhide` → `restore` 与 mts 对齐;(M2)`target_pair` CHECK 限定 target_type 合法值;(M3)`last_action_type` `auto_hide` → `autohide` 命名风格统一;(M4)`chk_mts_warn_fields` 强化要求 `is_warned=TRUE ⇒ warn_count>0`;(M5)`idx_mts_warned_user` partial index 加 `target_type='user_profile'` 限定;(M6)`chk_mts_source_report_id` 放宽 admin 可空(支持多工单聚合警告);(M7)`admin_audit_logs.resource_*` 成对 CHECK;(M8)`idx_admin_audit_logs_resource` 加 `created_at DESC`;(M9)补 `idx_moderation_actions_target` partial index;(M10)补 `idx_reports_category` 索引;(M11)`feedbacks` 加 `claimed_by`/`claimed_at` 字段 + CHECK;(M12)`feedbacks.contact` 100 → 254(RFC 5321 邮箱上限);(M13)阶段 1.2 "8 张表" → "9 张表";(M14)5.2 API 补 `feedbacks/{id}/release` 端点;(M15)错误码 50009/50014 语义区分(终态 vs 非法迁移);(M16)8 节补 `report_auto_hidden_notice` 模板 + 2.3/阶段 7.1 "5 个模板" → "6 个模板";(M17)6.2 ban 路径重构为 ① ② ③ 与 takedown 对齐;(M18)2.1 架构图自动隐藏描述同步业务表软删除;(M19)阶段 2.7 补 `notification_client.go` |
|
||
| v1.5 | 2026-06-11 | Claude | **二轮自审修复(19 项 — 修 v1.4 引入的 4 项回归)**:(H1)6.3 release SQL 改正——清 `claimed_by/claimed_at` 不是 `replied_by`,避免 `chk_feedbacks_claimed_pair` 违例;(H2)6.4 Lua 短锁语义补完——`defer DEL` 显式释放 + 5s TTL 兜底 + 完整生命周期伪代码;(H3)`feedbacks` 补 `closed_by/closed_at/archived_by/archived_at` 4 字段 + 2 个 CHECK 约束(终态字段一致性);(H4)`chk_mts_warn_fields` 放宽支持累计——`is_warned=FALSE` 时 `warn_count >= 0` 允许保留历史计数(语义修正:累计警告次数可在 dismiss 后保留);(H5)11.2 跨服务调用清单"5 个模板"→"6 个模板"残留修正;(H6)6.2 dismiss 流程重构为 4 路径:pending 快速通道 / 普通 reviewing / auto_hidden 恢复 / auto_hidden 不恢复,每条带明确的 SQL 步骤;(H7)错误码 50004 文案修正为"您已举报过该对象"(明确是"同 reporter" 范围);(M1)`admin_audit_logs.ip` 100→500(兼容 IPv6 多级代理链实测 230 字符);(M2)`feedbacks` 补 `idx_feedbacks_claimed_by` 索引;(M3)`reports`/`feedbacks` 补 `star_id` 部分索引;(M4)`idx_mts_warned_user` 简化去掉冗余 `target_type` 列;(M5)`fk_reports_category` / `fk_feedbacks_category` 加 `ON UPDATE CASCADE` 防止 `code` rename 破坏 FK;(M6)阶段 1.1 路径从 Windows 绝对路径 `D:\shanghai\topfans\...` 改为本仓相对路径 + 编号 010→011;(M7)阶段 2.7 `notification_client.go` 标注为 stub 推迟到阶段 7.1 替换为真实实现;(M8)阶段 2.9 补 Dockerfile + docker-compose + Makefile 同步步骤;(M9)阶段 2 补 `transaction_helper.go` / `redis_client.go` / `promhttp.Handler()` / admin JWT 解析说明 / 端口 20010 分配 / `provider/moderation_provider.go` 改名修正;(M10)11.3 监控指标从 5 个扩到 13 个:补失败类(notification / transaction rollback / lock contention / duplicate)+ 延迟类(API latency / queue gauge)+ Go runtime 自动注册;(M11)阶段 7.4 集成测试增列 testcontainers 隔离策略、跨服务 mock 矩阵、Lua 测试需要真 Redis、dismiss 4 路径覆盖、7.2 补 `concurrency_test.go`;(M12)阶段 2.10 标题改为"客户端 Gateway 路由配置清单",阶段 3.4 显式说明后台路由 `/api/admin/moderation/*` 由 FastAPI 注册不在本仓 gateway;11.2 跨服务调用清单去重去幽灵行(合并 assetService 重复行、删除"资产描述词"幽灵行)|
|
||
| v1.6 | 2026-06-11 | Claude | **Polish(9 项 LOW 优化)**:(L1)`feedbacks.contact` 254→320(RFC 5321 完整邮箱地址 64+1+255=320 上限),修正注释;(L2)`idx_moderation_actions_target` 简化去掉冗余 `target_type` 列(WHERE 已限定 NOT NULL)— **v1.7 修正:L2 是错的,回退**;(L3)6.2 路径 A 补"任意审核员"权限说明 + `claimed_by=NULL` 防御性显式清零;(L4)阶段 2.1 补 `start.sh` 完整 env 模板(PORT/DB_*/REDIS_*/DUBBO_*)+ `dubbo.yaml` 模板(application name / tri protocol / nacos registry);(L5)阶段 2.3 显式声明 `.triple.go`(Dubbo Triple 协议 stub,缺它 RPC 客户端无法生成);(L6)阶段 4.1 注明"复用 `utils/request.js` axios 拦截器已含 JWT 注入";阶段 5.1 注明"在既有 `api.js` 末尾追加 8 个方法,不新建文件";阶段 5.2 补 `ReportModal` 双 toast 分支(`target_hidden` true/false)+ uView 2.x UI 库 + props 类型断言;(L8)阶段 5.7 补"我的"页面菜单项追加位置(`pages/profile/profile.vue` 既有"我的"菜单);(L9)5 张核心表加 `COMMENT ON TABLE` 文档注释(report_categories / feedback_categories / reports / moderation_actions / moderation_target_status)|
|
||
| v1.7 | 2026-06-12 | Claude | **三轮自审修复(修 v1.6 L2 引入的 2 项回归 + 4 项遗漏)**:(H1)`idx_moderation_actions_target` 回退 v1.6 L2 简化——`target_id` 跨类型不唯一(`users.id=123` 与 `assets.id=123` 冲突),必须 `target_type` 前缀,索引改为 `(target_type, target_id, created_at DESC)`;(H2)`chk_mts_warn_fields` 加第 3 子句支持"历史警告已清除"(`is_warned=FALSE AND warn_count>=1 AND last_warned_at IS NOT NULL`)——v1.5 H4 允许了"is_warned=FALSE 时 warn_count 可任意"但同时强制 `last_warned_at IS NULL`,导致 dismissal 后无法保留历史警告时间戳;(H3+4)`reports` 表补 `claimed_by`/`claimed_at` 字段 + `chk_reports_claimed_pair` CHECK 约束(与 `feedbacks` 对齐)——v1.5 v1.6 路径 A dismiss SQL 引用 `reports.claimed_by` 但该列不存在,会 SQL 报错;同时为 50008 "已被他人认领" 提供认领人身份;(H3 文档)5.1 示例 50004 错误响应文案与错误码表统一(之前漂移);(H4 状态机)4.1 迁移矩阵补"reviewing → dismissed 保留隐藏"行(v1.5 H6 路径 D 的状态机条目);(M1/M2)6.3 reply 流程补 `claimed_by=NULL, claimed_at=NULL`——终态清认领人避免与 `replied_by` 重复;(M3)8 节补"模板复用说明"——`report_resolved` 同时覆盖 `resolved` 和 `dismissed`(消息体根据 `resolved_action` 区分);(L3 规则)6.1 step 2.5 自举报拦截规则明确——`target_type='asset'` 比 `owner_uid`,`target_type='user_profile'` 比 `target_id`(不能举报自己);(LOW 配置)11.1 `ModerationConfig` 加 `MaxContactLen int // 320` 与 `feedbacks.contact` 字段对齐;(LOW 模板名)11.2 notificationService 行补全 6 个模板名(不仅是"6 个模板");(LOW 约定)3.2 report_categories 前补"所有 *_at 字段均为 Unix 毫秒 (BIGINT)"约定注释 |
|
||
| v1.8 | 2026-06-12 | Claude | **四轮自审修复(v1.7 引入的 2 项文档/流程 bug + 5 项遗漏)**:(H1)6.2 路径 D 注释错误引用 `mts.is_hidden` 字段——v1.3 已删除该字段,改为"业务表 `is_active=false, deleted_at=$now` 保持原状;mts 仅审计 last_action_type='dismiss'";(H2)6.2 路径 D 补审计说明——`last_action_type='dismiss'` 会覆盖 `autohide` 记录,完整历史需通过 `moderation_actions` 表按 `report_id` + `created_at` 时序回溯;(M1)6.3 close/archive 流程补 `claimed_by=NULL, claimed_at=NULL`(**重要**——之前会违反 `chk_feedbacks_claimed_pair`);(M2)6.2 补 reports release SQL 流程(5.2 端点早已存在但缺 SQL);(M3)11.2 TopFans-Activity 行补 claimed_by/claimed_at 读路径说明;(M4)7.2 `report_service_test.go` 加 claimed_* 一致性测试;(M5)50008 响应体补 `claimed_by_admin_id` / `claimed_at` 字段说明;(L1)6.1 step 4 INSERT 显式 `claimed_by=NULL, claimed_at=NULL` 与 dismiss 路径 A 防御性写法对齐;(L2)4.1 矩阵 `pending → dismissed` 行副作用列补 claimed_by 清零;(L3)3.x 全局注释提升"Unix 毫秒 (BIGINT)"约定到章节头(原 v1.7 注释仅在 report_categories 前)|
|
||
| v1.9 | 2026-06-12 | Claude | **五轮自审修复(v1.8 引入的 2 项真实 bug + 4 项遗漏)**:(H1)163 行 v1.7 注释删除重复"Unix 毫秒 (BIGINT)"(v1.8 L3 添加的 142 行全局约定已为 canonical)——保留 PG 隐式 numeric→bigint 转换 caveat 作为本地示例说明;(H2)6.3 close 补"必须在 reviewing 状态"说明(与 dismiss-pending 快速通道的差异);(H3)6.3 archive SQL 去死代码 `OR claimed_by IS NULL` 分支——按 `chk_feedbacks_claimed_pair` 约束 `reviewing`/`replied` 状态 `claimed_by` 必非空,该 OR 分支不可达且误导读者;(H4)4.1 状态机补 `reviewing → pending`(释放认领)行——之前 v1.8 补了 SQL 但矩阵没条目;(H5)4.1 `pending → dismissed` 行副作用补完整 SET(resolved_action/resolved_by/resolved_at/updated_at);(H6)50008 响应体字段名 `claimed_by_admin_id` 重命名为 `claimed_by`——与 `reports`/`feedbacks` 列名一致(避免 API 字段与 DB 字段名漂移);(M1)6.1 step 4 NULL 注释从"防御性写法"升级为"必填 NULL"——`chk_reports_claimed_pair` 约束 `status<>'reviewing'` 时必须 NULL,不可省略;(M2)11.2 跨服务表字段列举完整化——reads/writes 各自列(claim 写入 / release-dismiss 清零 / reply-close-archive 清零);(M3)7.2 测试描述从"claimed_* 字段一致性"具体化——分别枚举 claim/release/dismiss 4 路径(reports)与 claim/release/reply/close/archive(feedbacks)的 CHECK 违例用例;(M4)5.1 提交举报响应 schema 修正——`status` 字段同步实际 DB 状态(`auto_hidden` vs `pending`),补 `claimed_by`/`claimed_at` 字段与 50008 一致 |
|
||
| v2.0 | 2026-06-12 | Claude | **六轮自审修复(v1.9 引入的 1 项真实 CRITICAL bug + 2 项 HIGH + 2 项遗漏)**:(CRITICAL)6.3 archive SQL 去 `'replied'` 分支——v1.8 M1 reply 流程 `claimed_by=NULL, claimed_at=NULL` 联合 v1.9 H3 archive 去 `OR claimed_by IS NULL` 分支,**打破 `replied → archive` 流程**:reply 后 `claimed_by=NULL`,archive SQL `WHERE status IN ('replied','reviewing') AND claimed_by=$admin_id` 0 行匹配。修复:去 `'replied'`,archive 仅从 reviewing 入口(与 4.2 状态机"replied | 终态"一致)。(H-A)4.1 ASCII 状态机图补"释放认领"回退边 + "快速驳回"边 + "解除隐藏"边(之前 v1.5 H6 + v1.7 H4 + v1.8 H2 加的迁移矩阵条目在图中均未体现);(H-C)4.1 `auto_hidden → reviewing` 行补"is_auto_hidden 保持 TRUE 不变"注释(与 v1.7 H4 加的 release 行一致);(H-D)6.2 路径 A 注释从"防御性写法"升级为"必填 NULL"(与 6.1 step 4 M1 措辞统一);(M-B)5.1 响应字段说明补 `target_hidden` 语义——表示"本次 auto-hide 是否执行了 UPDATE"(不是"目标当前是否被隐藏"),auto-hide 幂等时 `target_hidden=false` 但 `status='auto_hidden'`;(M-D)11.2 跨服务表字段列举补全 `resolved_action/resolved_by/resolved_at/resolution_note`(reports)、`replied_by/replied_at/reply_content`(feedbacks)、`closed_by/closed_at`(feedbacks)、`archived_by/archived_at`(feedbacks)、`moderation_actions` + `admin_audit_logs` 流水;(M-1)163 行重复注释重新定位为"示例"标签(与 142 行全局约定差异化)|
|
||
| v2.1 | 2026-06-12 | Claude | **七轮深审修复(联合回归 + 业务用例组合发现 3 项 CRITICAL + 4 项 HIGH)**:(CRITICAL #1)6.2 路径 C dismiss-restore 补 Redis counter 重置(`DEL mod:report:counter:*` + `DEL mod:report:user:{type}:{id}:*` + `DEL mod:report:lock:*`)——admin 判决"不违规"必须清零风险信号,否则新举报立即再次 auto-hide 推翻 admin 判决;path D 不重置(保守派选项);(CRITICAL #2)4.2 反馈状态机表 `replied/closed/archived` 全部明示"**终态**" + 6.3 claim SQL 补 0 行匹配的 50008 vs 50009 区分注释(replied 终态后认领应返回 50009"工单已结案"而非 50008"已被他人认领");(CRITICAL #3)6.1 step 6 补 `③.5` 写 `moderation_actions` 流水——auto-hide 时 `action_type='takedown'` + `admin_id=0`(系统自动),`mts.last_action_type='autohide'` 与 `moderation_actions.action_type='takedown'` 命名空间分离;(H1)5.1 补提交反馈请求体 + 响应 schema(之前只有 report submit);(H2)6.1 step 4 文档化 `star_id` 路由查询(`asset.star_id` / `user.identity_id`)——`star_id` 是 denormalized 副本,跨表无 FK;(H3)6.2 补 `warn_cleared` 流程 SQL——`UPDATE mts SET is_warned=FALSE WHERE is_warned=TRUE` 保留 `warn_count` + `last_warned_at`,激活 `chk_mts_warn_fields` 第 3 子句(之前是死代码)|
|
||
| v2.2 | 2026-06-12 | Claude | **八轮深审修复(v2.1 引入的 6 项 HIGH 真实 bug)**:(B1)6.2 路径 C Redis 通配 `DEL mod:report:user:*` **不可执行**——Redis DEL 不支持通配;改为 `SCAN MATCH ... | DEL` 迭代 + Go 伪代码(之前 H1 changelog 写的通配符描述有误);(B2)6.2 warn_cleared 加 `POST /api/admin/moderation/users/{id}/clear-warn` 端点 + 完整事务内双重写入(mts UPDATE + `moderation_actions` + `admin_audit_logs` 三张表)——之前是死代码(v2.1 H3 文档化了 SQL 但无触发 API);(B3)`chk_moderation_actions_xor` 放宽——加 OR 分支 `(report_id IS NULL AND feedback_id IS NULL)` 允许 warn_cleared 这类无 ticket 关联的审计行;否则会直接拒写;(B4)`chk_moderation_actions_admin_id` CHECK `(admin_id >= 0)`——`admin_id=0` 明确为系统自动 sentinel,避免与真 admin id 冲突;(B5)9.1 匿名保护明确——**所有后台审核员可见** `reporter_id`(1.3 范围"仅一种角色"——不存在超级管理员;之前 v1.0 写的"后台仅对超级管理员可见"是 spec 内的矛盾,v2.2 改一致);(B6)4.1 状态机补 `resolved → resolved (restore/unban)` 行 + 6.2 路径 E——ban/takedown 误判后可解除(`UPDATE users SET is_active=true, deleted_at=NULL` + `mts.last_action_type='restore'` + `reports.resolved_action='restore'` + 写 `moderation_actions.action_type='restore'`),关闭"ban 不可逆"的法律/合规漏洞 |
|
||
| v2.3 | 2026-06-12 | Claude | **九轮 polish(v2.2 剩余 12 项 MEDIUM/LOW 一次性清零)**:(P1)`reports` 加 `target_snapshot JSONB` schema 强定义(asset / user_profile 两种结构) + `target_owner_uid_at_submit BIGINT`(ownership transfer 后通知路由) + `triggered_auto_hide BOOLEAN`(仅阈值触发者置 TRUE,区分连带升级);(P2)9.1 加 IP 限流(200/天,`mod:rl:report:ip:*`)+ 设备指纹限流(200/天)—— 防御多账号 spam;evidence 大小/类型限制(5MB / png|jpeg,50018);star_id stale 容忍文档化;claim_timeout(15 分钟自动释放)—— 防 admin crash 后工单僵尸;(P3)9.3 加 PII 匿名化策略——`feedbacks.contact` 在 closed/archived 后 90 天自动 NULL,与 GDPR / 中国《个人信息保护法》"最小必要 + 限期保存"一致;(P4)5.1 加 `PUT/DELETE /api/v1/moderation/reports/{id}` 客户端编辑/撤回端点(仅 pending,可改 description,新增 `withdrawn` 状态);5.2 加 `POST /api/admin/moderation/reports/bulk-dismiss`(批量驳回 spam)+ `force-release`(强制释放他人认领);(P5)7 加 4 个错误码:50016(target_type 不支持)/ 50017(reason 必填)/ 50018(证据图超限)/ 50019(举报不可编辑);50008 vs 50009 判定顺序明确(handler 二次 SELECT 查当前 status);(P6)6.1 step 6 加 `autohide_noop` 区分(`is_active=false` 时记 `action_type='autohide_noop'`);路径 C 加 `②.5` 批量清理同 target 老 auto_hidden 工单(防数据膨胀);(P7)阶段 6.5 新增每日 cron 任务(PII 匿名化 / 90 天 auto-archive / 15 分钟 claim timeout 回收);(P8)11.1 `ModerationConfig` 加 11 项新配置(`MaxReplyLen` / `MaxEvidenceSize` / `AllowedMimeTypes` / 3 个 `RateLimit*` / `ClaimTimeout` / `PIIAnonymizeAfter` / `AutoArchiveAfter` / `RedisKeyPrefix` / `PathDCounterPolicy`);(P9)11.3 加 10 个新指标(auto_hidden.noop / auto_hide / warn_cleared / counter_reset / pii_anonymized / auto_archived / claim_timeout / terminal_state.claim_attempt / evidence_upload.p95 / rate_limit.hit)|
|
||
| v2.4 | 2026-06-12 | Claude | **十轮聚焦修复(v2.3 引入的 1 项 CRITICAL + 7 项 HIGH 真实 bug)**:(V1 CRITICAL)**重复 `target_snapshot JSONB NOT NULL` 列**(行 231 + 行 237)——PG `ERROR: column specified more than once` migration 跑不起来;**DROP** 第二处 + 同时**DROP 死列** `target_owner_uid_at_submit`(v2.3 文档化的列但无任何流读/写,target_snapshot 内 `owner_uid` 字段已够);(V2)`chk_reports_status` 枚举补 `'withdrawn'` + `'archived'`(否则 DELETE withdraw INSERT 直接被 CHECK 拒;6.5 cron UPDATE reports SET status='archived' 也会拒);(V3)`chk_moderation_actions_action_type` 补 `'autohide_noop'` / `'withdraw'` / `'force_release'`(否则 v2.3 加的 3 类 audit 行全部被拒);(V4)`chk_mts_last_action_type` 补 `'warn_cleared'`(否则 v2.2 B2 warn_cleared UPDATE 直接被 CHECK 拒——这是 v2.2 就埋下的真 bug,v2.3 也没修);(V5)6.1 step 6 ② 显式写 `triggered_auto_hide=(id=$trigger_report_id)`(之前 v2.3 加列但 v2.4 才在 SQL 实际写入);(V6)6.5 cron 补 `feedbacks` auto-archive 完整 SQL(v2.3 "同理 feedbacks" 没写实际 SQL;PII 匿名化改用 `closed_at`/`archived_at` 而非 `updated_at` 避免业务操作重置 90 天时钟);(V7)6.1 step 6 ③.5 改 `IF affected_rows > 0` conditional —— 实际业务表软删除时写 `action_type='takedown'`,no-op 时写 `action_type='autohide_noop'`(v2.3 文档化但 SQL 未分支);(V8)5.1 PUT body 扩为 `description + category_code + evidence_keys`(v2.3 只允许改 description 不够,category_code 误选无解);DELETE 撤回**不增 `withdrawn_at` 列**(与 `updated_at` 冗余);(V9)9.1 evidence 校验明确——**moderationService 不能直接校验**(OSS 客户端直传 moderationService 只见 key),校验在 **OSS bucket policy 层**实施,moderationService 只校验 `evidence_keys` 字符串格式 |
|
||
| v2.5 | 2026-06-12 | Claude | **十一轮聚焦审查修复(v2.4 仍存的 6 项 HIGH 真实 bug)**:(W1 HIGH-1)6.5 cron `UPDATE feedbacks SET status='archived'` 补 `archived_by=0, archived_at=$now`(v2.4 漏的,cron 第一次跑就违反 `chk_feedbacks_archived_fields`);同时补 2c 写 `moderation_actions` 审计行(v2.4 cron 无审计);(W2 HIGH-3)`idx_reports_triggered_autohide` 部分索引(`WHERE triggered_auto_hide=TRUE` keyed by `(target_type, target_id, created_at DESC)`)—— 否则"哪个 report 触发 auto-hide"查询全表扫;(W3 HIGH-4)4.1 状态机矩阵补 2 行:`pending → withdrawn`(客户端 DELETE 撤回)和 `pending → archived`(6.5 cron);(W4 H2)`POST /api/admin/moderation/reports/{id}/force-release` 完整 SQL——UPDATE 释放 + `moderation_actions action_type='force_release'` + `admin_audit_logs extra=jsonb_build_object('previous_claimed_by',...)` + 通知原 claimer 模板(v2.3 只说"写 note"但没 SQL);(W5 H5)PII 匿名化扩到 `replied` 终态(用 `replied_at` 而非 `updated_at`)—— 修复 v2.4 GDPR 漏洞:admin 回复后用户的 `contact` 永不匿名化;之前只覆盖 closed/archived 漏了 `replied` ;(W6 H4)路径 E unban 用 CTE 条件写 mts —— `WHERE EXISTS (SELECT 1 FROM user_unbanned)` 确保仅当 `is_active=false→true` 实际解了 user 才写 `last_action_type='restore'`,避免 user 已 active 时写误导审计;(W7 H1)50012 错误响应加结构化 `{scope: "user"|"ip"|"device", limit: 30|200}` 字段,前端可精准显示 "今日已提交 X 次(user/ip/device 限)";(W8)50014 race 后错误响应加 `{current_status, suggested_action}` 智能提示字段;阶段 6.5 cron 顺序文档化(PII → archive → claim 释放 + 解释 PII 时钟从 `archived_at` 起的 trade-off)|
|
||
|
||
---
|
||
|
||
**📝 设计方案已确认,可以开始实现!** ✅
|