topfans/docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md
2026-06-12 17:00:46 +08:00

1609 lines
120 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 举报与反馈系统设计
> **状态**:设计已确认 ✅
> **创建时间**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 → moderationServiceDubbo 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_profilev1.4 砍掉 description_wordAI 描述词即 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` 已 DROPv2.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 保留为"系统自动" sentinelauto-hide真实 admin 必 ≥ 1
CONSTRAINT chk_moderation_actions_action_type
CHECK (action_type IN (
'takedown','restore','ban','warn','dismiss','reply','close','archive',
'autohide_noop', -- v2.4auto-hide 时目标已 is_active=false 的 no-op 审计
'withdraw', -- v2.4:客户端 DELETE 撤回
'force_release' -- v2.4admin 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=$nowis_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 → 完整 unbanmts+流水双写<br>- user_unbanned=0user 已 active+ report_updated=1 → **只写流水,不写 mts**(避免 "明明不是我解的" 误导审计)<br>- report_updated=0status 不在 ['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_actionsaction_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 客户端 APIuni-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 只允许改 descriptioncategory_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 后台 APIVue3 后台调用,路径前缀 `/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| 批量驳回 spambody `{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`** SQLv2.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 → 50009status 已被 dismiss/resolve<br>-- 2. 写流水v2.5v2.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_logsv2.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. 通知原 claimerv2.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_uidtarget_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_evidenceOSS keysoss_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 状态机矩阵):
│ **路径 Apending → 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 保留审计)
│ ③ 发通知给举报人
│ **路径 Breviewing → 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', ...
│ ③ 发通知给举报人
│ **路径 Cauto_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 不重置
│ **路径 Dauto_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 是**兜底**——若服务崩溃未 DEL5s 后自动失效,下次请求可重入
- `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/archive0 行匹配时 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 副本,跨表无 FKstar 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 迁移已含种子数据则跳过
### 阶段 2Go moderationServicetopfans
- 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` 引用无需修改其他服务的注册配置
### 阶段 3TopFans-Activity 后台 APIFastAPI
> **范围说明**:以下产物在外部仓库 `D:\shanghai\TopFans-activity` 实施本仓topfans仅做规范约定不阻塞本仓 releaseDB 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_atPII 时钟从回复时刻起 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 工单自动 archivereports—— 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 工单自动 archivefeedbacks—— 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 同 report1 成功 1 返回 50008
- 自动隐藏 Lua 脚本 + 5s 短锁的交互errgroup 模拟并发)
- 7.3 FastAPI 单元测试
- `test_moderation_admin.py` 列表/详情/认领/动作(含 dismiss 4 路径分支覆盖)
- 鉴权测试(无 token 401
- Fixture`httpx.AsyncClient` + pytestadmin 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 脚本测试**:需要真实 Redistestcontainer验证 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 // 320RFC 5321 完整邮箱地址上限feedbacks.contact 服务端校验)
MaxReplyLen int // 2000feedbacks.reply_content 服务端校验v2.3
MaxEvidenceSize int64 // 5MB 单图上限v2.3
AllowedMimeTypes []string // ["image/png", "image/jpeg"]v2.3
RateLimitUserPerDay int // 30举报/ 5反馈
RateLimitIPPerDay int // 200v2.3IP 限流防多账号 spam
RateLimitDevicePerDay int // 200v2.3,设备指纹限流)
ClaimTimeout time.Duration // 15 分钟v2.3cron 任务自动释放)
PIIAnonymizeAfter time.Duration // 90 天v2.3feedbacks.contact 自动匿名化)
AutoArchiveAfter time.Duration // 90 天v2.3pending 工单自动 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消除 `...` 占位符H29 张表 `CREATE SEQUENCE` 全部加 `OWNED BY`(让 `setval()` 生效CLAUDE.md 强制H32.1/2.2 关键边界移除已废弃的 `LEFT JOIN moderation_target_status` 描述H4状态机迁移矩阵补 `pending → dismissed` 快速通道、区分 dismiss 与 dismiss-restore 副作用H5 隐含11.2 调用清单同步去掉 aichat 跨 schemaH6`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_*` 成对 CHECKM8`idx_admin_audit_logs_resource` 加 `created_at DESC`M9`idx_moderation_actions_target` partial indexM10`idx_reports_category` 索引M11`feedbacks` 加 `claimed_by`/`claimed_at` 字段 + CHECKM12`feedbacks.contact` 100 → 254RFC 5321 邮箱上限M13阶段 1.2 "8 张表" → "9 张表"M145.2 API 补 `feedbacks/{id}/release` 端点M15错误码 50009/50014 语义区分(终态 vs 非法迁移M168 节补 `report_auto_hidden_notice` 模板 + 2.3/阶段 7.1 "5 个模板" → "6 个模板"M176.2 ban 路径重构为 ① ② ③ 与 takedown 对齐M182.1 架构图自动隐藏描述同步业务表软删除M19阶段 2.7 补 `notification_client.go` |
| v1.5 | 2026-06-11 | Claude | **二轮自审修复19 项 — 修 v1.4 引入的 4 项回归)**H16.3 release SQL 改正——清 `claimed_by/claimed_at` 不是 `replied_by`,避免 `chk_feedbacks_claimed_pair` 违例H26.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 后保留H511.2 跨服务调用清单"5 个模板"→"6 个模板"残留修正H66.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 破坏 FKM6阶段 1.1 路径从 Windows 绝对路径 `D:\shanghai\topfans\...` 改为本仓相对路径 + 编号 010→011M7阶段 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` 改名修正M1011.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 注册不在本仓 gateway11.2 跨服务调用清单去重去幽灵行(合并 assetService 重复行、删除"资产描述词"幽灵行)|
| v1.6 | 2026-06-11 | Claude | **Polish9 项 LOW 优化)**L1`feedbacks.contact` 254→320RFC 5321 完整邮箱地址 64+1+255=320 上限修正注释L2`idx_moderation_actions_target` 简化去掉冗余 `target_type`WHERE 已限定 NOT NULL**v1.7 修正L2 是错的,回退**L36.2 路径 A 补"任意审核员"权限说明 + `claimed_by=NULL` 防御性显式清零L4阶段 2.1 补 `start.sh` 完整 env 模板PORT/DB_*/REDIS_*/DUBBO_*+ `dubbo.yaml` 模板application name / tri protocol / nacos registryL5阶段 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` 既有"我的"菜单L95 张核心表加 `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/M26.3 reply 流程补 `claimed_by=NULL, claimed_at=NULL`——终态清认领人避免与 `replied_by` 重复M38 节补"模板复用说明"——`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 项遗漏)**H16.2 路径 D 注释错误引用 `mts.is_hidden` 字段——v1.3 已删除该字段,改为"业务表 `is_active=false, deleted_at=$now` 保持原状mts 仅审计 last_action_type='dismiss'"H26.2 路径 D 补审计说明——`last_action_type='dismiss'` 会覆盖 `autohide` 记录,完整历史需通过 `moderation_actions` 表按 `report_id` + `created_at` 时序回溯M16.3 close/archive 流程补 `claimed_by=NULL, claimed_at=NULL`**重要**——之前会违反 `chk_feedbacks_claimed_pair`M26.2 补 reports release SQL 流程5.2 端点早已存在但缺 SQLM311.2 TopFans-Activity 行补 claimed_by/claimed_at 读路径说明M47.2 `report_service_test.go` 加 claimed_* 一致性测试M550008 响应体补 `claimed_by_admin_id` / `claimed_at` 字段说明L16.1 step 4 INSERT 显式 `claimed_by=NULL, claimed_at=NULL` 与 dismiss 路径 A 防御性写法对齐L24.1 矩阵 `pending → dismissed` 行副作用列补 claimed_by 清零L33.x 全局注释提升"Unix 毫秒 (BIGINT)"约定到章节头(原 v1.7 注释仅在 report_categories 前)|
| v1.9 | 2026-06-12 | Claude | **五轮自审修复v1.8 引入的 2 项真实 bug + 4 项遗漏)**H1163 行 v1.7 注释删除重复"Unix 毫秒 (BIGINT)"v1.8 L3 添加的 142 行全局约定已为 canonical——保留 PG 隐式 numeric→bigint 转换 caveat 作为本地示例说明H26.3 close 补"必须在 reviewing 状态"说明(与 dismiss-pending 快速通道的差异H36.3 archive SQL 去死代码 `OR claimed_by IS NULL` 分支——按 `chk_feedbacks_claimed_pair` 约束 `reviewing`/`replied` 状态 `claimed_by` 必非空,该 OR 分支不可达且误导读者H44.1 状态机补 `reviewing → pending`(释放认领)行——之前 v1.8 补了 SQL 但矩阵没条目H54.1 `pending → dismissed` 行副作用补完整 SETresolved_action/resolved_by/resolved_at/updated_atH650008 响应体字段名 `claimed_by_admin_id` 重命名为 `claimed_by`——与 `reports`/`feedbacks` 列名一致(避免 API 字段与 DB 字段名漂移M16.1 step 4 NULL 注释从"防御性写法"升级为"必填 NULL"——`chk_reports_claimed_pair` 约束 `status<>'reviewing'` 时必须 NULL不可省略M211.2 跨服务表字段列举完整化——reads/writes 各自列claim 写入 / release-dismiss 清零 / reply-close-archive 清零M37.2 测试描述从"claimed_* 字段一致性"具体化——分别枚举 claim/release/dismiss 4 路径reports与 claim/release/reply/close/archivefeedbacks的 CHECK 违例用例M45.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 项遗漏)**CRITICAL6.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-A4.1 ASCII 状态机图补"释放认领"回退边 + "快速驳回"边 + "解除隐藏"边(之前 v1.5 H6 + v1.7 H4 + v1.8 H2 加的迁移矩阵条目在图中均未体现H-C4.1 `auto_hidden → reviewing` 行补"is_auto_hidden 保持 TRUE 不变"注释(与 v1.7 H4 加的 release 行一致H-D6.2 路径 A 注释从"防御性写法"升级为"必填 NULL"(与 6.1 step 4 M1 措辞统一M-B5.1 响应字段说明补 `target_hidden` 语义——表示"本次 auto-hide 是否执行了 UPDATE"(不是"目标当前是否被隐藏"auto-hide 幂等时 `target_hidden=false``status='auto_hidden'`M-D11.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-1163 行重复注释重新定位为"示例"标签(与 142 行全局约定差异化)|
| v2.1 | 2026-06-12 | Claude | **七轮深审修复(联合回归 + 业务用例组合发现 3 项 CRITICAL + 4 项 HIGH**CRITICAL #16.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 #24.2 反馈状态机表 `replied/closed/archived` 全部明示"**终态**" + 6.3 claim SQL 补 0 行匹配的 50008 vs 50009 区分注释replied 终态后认领应返回 50009"工单已结案"而非 50008"已被他人认领"CRITICAL #36.1 step 6 补 `③.5``moderation_actions` 流水——auto-hide 时 `action_type='takedown'` + `admin_id=0`(系统自动),`mts.last_action_type='autohide'` 与 `moderation_actions.action_type='takedown'` 命名空间分离H15.1 补提交反馈请求体 + 响应 schema之前只有 report submitH26.1 step 4 文档化 `star_id` 路由查询(`asset.star_id` / `user.identity_id`)——`star_id` 是 denormalized 副本,跨表无 FKH36.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**B16.2 路径 C Redis 通配 `DEL mod:report:user:*` **不可执行**——Redis DEL 不支持通配;改为 `SCAN MATCH ... | DEL` 迭代 + Go 伪代码(之前 H1 changelog 写的通配符描述有误B26.2 warn_cleared 加 `POST /api/admin/moderation/users/{id}/clear-warn` 端点 + 完整事务内双重写入mts UPDATE + `moderation_actions` + `admin_audit_logs` 三张表——之前是死代码v2.1 H3 文档化了 SQL 但无触发 APIB3`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 冲突B59.1 匿名保护明确——**所有后台审核员可见** `reporter_id`1.3 范围"仅一种角色"——不存在超级管理员;之前 v1.0 写的"后台仅对超级管理员可见"是 spec 内的矛盾v2.2 改一致B64.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 | **九轮 polishv2.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区分连带升级P29.1 加 IP 限流200/天,`mod:rl:report:ip:*`+ 设备指纹限流200/天)—— 防御多账号 spamevidence 大小/类型限制5MB / png|jpeg50018star_id stale 容忍文档化claim_timeout15 分钟自动释放)—— 防 admin crash 后工单僵尸P39.3 加 PII 匿名化策略——`feedbacks.contact` 在 closed/archived 后 90 天自动 NULL与 GDPR / 中国《个人信息保护法》"最小必要 + 限期保存"一致P45.1 加 `PUT/DELETE /api/v1/moderation/reports/{id}` 客户端编辑/撤回端点(仅 pending可改 description新增 `withdrawn` 状态5.2 加 `POST /api/admin/moderation/reports/bulk-dismiss`(批量驳回 spam+ `force-release`强制释放他人认领P57 加 4 个错误码50016target_type 不支持)/ 50017reason 必填)/ 50018证据图超限/ 50019举报不可编辑50008 vs 50009 判定顺序明确handler 二次 SELECT 查当前 statusP66.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 回收P811.1 `ModerationConfig` 加 11 项新配置(`MaxReplyLen` / `MaxEvidenceSize` / `AllowedMimeTypes` / 3 个 `RateLimit*` / `ClaimTimeout` / `PIIAnonymizeAfter` / `AutoArchiveAfter` / `RedisKeyPrefix` / `PathDCounterPolicy`P911.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 就埋下的真 bugv2.3 也没修V56.1 step 6 ② 显式写 `triggered_auto_hide=(id=$trigger_report_id)`(之前 v2.3 加列但 v2.4 才在 SQL 实际写入V66.5 cron 补 `feedbacks` auto-archive 完整 SQLv2.3 "同理 feedbacks" 没写实际 SQLPII 匿名化改用 `closed_at`/`archived_at` 而非 `updated_at` 避免业务操作重置 90 天时钟V76.1 step 6 ③.5 改 `IF affected_rows > 0` conditional —— 实际业务表软删除时写 `action_type='takedown'`no-op 时写 `action_type='autohide_noop'`v2.3 文档化但 SQL 未分支V85.1 PUT body 扩为 `description + category_code + evidence_keys`v2.3 只允许改 description 不够category_code 误选无解DELETE 撤回**不增 `withdrawn_at` 列**(与 `updated_at` 冗余V99.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-16.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-44.1 状态机矩阵补 2 行:`pending → withdrawn`(客户端 DELETE 撤回)和 `pending → archived`6.5 cronW4 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"但没 SQLW5 H5PII 匿名化扩到 `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 H150012 错误响应加结构化 `{scope: "user"|"ip"|"device", limit: 30|200}` 字段,前端可精准显示 "今日已提交 X 次user/ip/device 限)"W850014 race 后错误响应加 `{current_status, suggested_action}` 智能提示字段;阶段 6.5 cron 顺序文档化PII → archive → claim 释放 + 解释 PII 时钟从 `archived_at` 起的 trade-off|
---
**📝 设计方案已确认,可以开始实现!**