120 KiB
举报与反馈系统设计
状态:设计已确认 ✅ 创建时间:2026-06-11 作者:Claude 目标:在 TopFans 平台新增内容举报与用户反馈两大工单系统,支撑运营/审核员通过独立 web 后台对违规内容(数字藏品/用户/AI 描述词)和用户反馈(BUG/咨询/合作/建议)进行审核处理
目录
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),与 Gotime.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 举报分类
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 反馈分类
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 举报工单
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 举报证据图
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 反馈工单
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 反馈截图
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 审核动作流水
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 共享"对象受管控状态"
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) — 自动隐藏/解除/警告时:- 业务表软删除/恢复/警告计数(按 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=$idtarget_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(恢复)
- moderation_target_status UPSERT(审计追溯 + last_action_type)
- 业务表软删除/恢复/警告计数(按 target_type 路由到对应表):
TopFans-Activity后台 (FastAPI) — 审核员手动下架/封禁/警告时同样执行"业务表 + 状态表"双重写入(事务保证一致,失败时回滚)
读取方(业务表自带软删除字段已足够,moderation_target_status 仅用于审计/警告):
assetService藏品查询/详情/列表:WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL(沿用既有软删除过滤,无须 JOINmoderation_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都在publicschema(与assets/users业务表同 schema),无跨 schema 访问——moderationService直连topfans库publicschema 即可完成所有读写;业务服务读路径也只在public内做软删除过滤(无须 JOINmoderation_target_status)。
孤儿记录清理(target 被删除时):
moderation_target_status与业务表(assets/users)跨服务,无外键约束,应用层负责清理- 在
assetService/userService的删除逻辑中加清理钩子:// 伪代码 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 管理员操作日志
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 时误导审计):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>行为分支: - user_unbanned=1(实际解 user)+ report_updated=1 → 完整 unban,mts+流水双写 - user_unbanned=0(user 已 active)+ report_updated=1 → 只写流水,不写 mts(避免 "明明不是我解的" 误导审计) - 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 流程保持一致。
并发认领防护:
UPDATE reports
SET status = 'reviewing', updated_at = ?
WHERE id = ? AND status IN ('pending', 'auto_hidden')
用 affected rows = 1 判断是否抢锁成功。
自动隐藏幂等性:
- 自动隐藏触发时,上层 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} |
查看反馈详情(含回复) |
提交举报请求体:
{
"target_type": "asset",
"target_id": 12345,
"category_code": "pornographic",
"description": "图片含有裸露内容",
"is_anonymous": true,
"evidence_keys": ["report/2026/06/11/uuid1.png"]
}
响应:
{
"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 节约定)
错误响应:
{ "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):
{
"category_code": "bug",
"title": "反馈标题",
"content": "详细描述",
"contact": "user@example.com",
"is_anonymous": false,
"evidence_keys": ["feedback/2026/06/11/uuid1.png"]
}
响应:
{
"code": 200,
"data": {
"feedback_id": 12345,
"status": "pending",
"claimed_by": null,
"claimed_at": null,
"created_at": 1718123456789
}
}
字段说明:
feedbacks没有auto_hidden机制(无阈值触发),新工单 status 始终为pendingclaimed_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 子段新增 ——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>注: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 |
看板:今日待处理/平均处理时长/分类分布 |
执行审核动作请求:
{
"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 脚本
-- 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 不在脚本内,由应用层完整生命周期管理:
// 伪代码
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" | |
| 50013 | 反馈每日提交超限 | 429 |
| 50014 | 状态机非法迁移(如 pending → archived 跳过 reviewing) |
409 |
| 响应体:`{ code, message, current_status: "pending" | "reviewing" | |
| 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查当前状态:
- 记录不存在(
id未找到)→ 50007 "工单不存在"status='reviewing'且claimed_by<>本人→ 50008 "工单已被他人认领"(响应体含claimed_by/claimed_at,见 50008 行)status IN ('replied','closed','archived')(反馈)/('resolved','dismissed')(举报)→ 50009 "工单已结案"status='reviewing'且claimed_by=本人→ 200 幂等成功(re-claim 同人)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_statusUPSERT 保留最新状态,历史可查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):#!/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-serviceconfigs/dubbo.yaml模板(参考services/socialService/configs/dubbo.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/ResponseListMyReportsRequest/ResponseGetReportRequest/ResponseGetReportCategoriesRequest/ResponseSubmitFeedbackRequest/ResponseListMyFeedbacksRequest/ResponseGetFeedbackRequest/ResponseGetFeedbackCategoriesRequest/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.goORM 模型(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.gocategory_service.goauto_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 客户端初始化,参考 aiChatServicemain.go)
- 2.8
backend/services/moderationService/provider/moderation_provider.goDubbo 注册(**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.gobackend/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 新增):
// 举报分类
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.pySQLAlchemy ORM(与 PG 表结构对应) - 3.2
TopFans-activity/backend/schemas/moderation.pyPydantic 模型 - 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(用日期前缀保持与本仓命名一致;如果用 SQLAlchemycreate_all可省略)
阶段 4:后台前端 (Vue3)
- 4.1
D:\shanghai\TopFans-activity\frontend\src\api\moderation.jsaxios 封装(新增文件)- 复用既有
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举报分类 CRUDFeedbackCategoryConfig.vue反馈分类 CRUDDashboard.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/一致)
- props:
- 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
- 既有 SQL 已含
- 6.2
userService登录/操作校验- 既有 SQL 已含
WHERE users.is_active = TRUE AND users.deleted_at IS NULL,由审核动作产生的 ban 已通过软删除自动拦截,本阶段无须改 SQL
- 既有 SQL 已含
- 6.3 描述词(
assets.description字段,随 asset 软删除自动过滤)- 既有 SQL 已含
WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL,本阶段无须改 SQL
- 既有 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
- Redis 缓存
阶段 6.5:定时任务(v2.3 新增)
每日 cron 任务(建议挂载在 K8s CronJob 或 systemd timer):
步骤顺序约束(v2.5 文档化,避免 PII 时钟被业务操作重置):
- 先 PII 匿名化(用
closed_at/replied_at/archived_at而非updated_at,与具体业务操作解耦)- 后 auto-archive(status='pending' 状态 90 天未处理)
- 最后 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)。
-- 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.goLua 脚本逻辑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 的边界
- 测试 DB 隔离:
- 7.5 前端联调
- uni-app → gateway → Go service
- Vue3 后台 → FastAPI → PG
- 用 gateway 已暴露的 Swagger UI(
router.goline 30-33)做端到端联调
11. 附录
11.1 配置文件
在 D:\shanghai\topfans\backend\services\moderationService\config\moderation_config.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/P99moderation.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 |
| 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 ... |
| 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 |
| 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" |
📝 设计方案已确认,可以开始实现! ✅