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

120 KiB
Raw Blame History

举报与反馈系统设计

状态:设计已确认 创建时间2026-06-11 作者Claude 目标:在 TopFans 平台新增内容举报与用户反馈两大工单系统,支撑运营/审核员通过独立 web 后台对违规内容(数字藏品/用户/AI 描述词和用户反馈BUG/咨询/合作/建议)进行审核处理


目录

  1. 背景与目标
  2. 架构概览
  3. 数据模型
  4. 状态机
  5. API 设计
  6. 关键流程
  7. 错误码
  8. 通知机制
  9. 安全与防护
  10. 实施步骤
  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 NULLmoderation_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 举报分类

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_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 举报证据图

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 保留为"系统自动" 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 共享"对象受管控状态"

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_statusAI 描述词即 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 schemaassets / users 业务表同 schema无跨 schema 访问——moderationService 直连 topfanspublic schema 即可完成所有读写;业务服务读路径也只在 public 内做软删除过滤(无须 JOIN moderation_target_status)。

孤儿记录清理target 被删除时)

  • moderation_target_status 与业务表assets/users跨服务无外键约束应用层负责清理
  • assetService / userService 的删除逻辑中加清理钩子:
    // 伪代码
    defer func() {
        moderationService.DeleteTargetStatus(ctx, targetType, targetID)
    }()
    
  • 定期清理脚本(每日 1 次):扫描 moderation_target_statustarget_type='asset'target_idassets 表不存在的记录,写一条 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=falseis_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 时误导审计):
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 → 完整 unbanmts+流水双写
- user_unbanned=0user 已 active+ report_updated=1 → 只写流水,不写 mts(避免 "明明不是我解的" 误导审计)
- 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 流程保持一致。

并发认领防护

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_statusUPSERT ... 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} 查看反馈详情(含回复)

提交举报请求体

{
  "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 状态(可能为 pendingauto_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-optarget_hidden=falsestatus='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 只允许改 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_userv2.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 始终为 pending
  • claimed_by / claimed_at 新反馈始终为 nulladmin 认领后 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-dismissv2.3 批量驳回 spambody {report_ids: [1,2,3...], reason: "spam_ring"},幂等返回成功数(防御多账号 spam 攻击)
POST /api/admin/moderation/reports/{id}/force-releasev2.3 强制释放他人认领admin crash 后回收用),写 moderation_actions.note='force_release: <reason>'
POST /api/admin/moderation/reports/{id}/force-release SQLv2.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 → 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>
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_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 脚本

-- 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 是兜底——若服务崩溃未 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_idreports/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
50016v2.3 举报对象类型不支持(target_type 不在白名单 {'asset','user_profile'} 400
50017v2.3 回复 / 备注 必填路径path C/D/E 选 dismiss/unban 时需填 reason 400
50018v2.3 证据图超限(单图 > 5MB / MIME 非 png/jpeg 400
50019v2.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 同时覆盖 resolveddismissed 两种终态(消息体根据 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 后客户端直传 OSSmoderationService 只收到 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.countOSS 4xx 响应)
描述长度 服务端校验 ≤ 500 字DB 字段 VARCHAR(500)
证据图数量 服务端校验 ≤ 5 张
匿名保护 is_anonymous=true 时:reporter_id 仍记录但 API 响应中不返回给被举报方(仅对被举报方匿名后台所有审核员可见 reporter_id1.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 任务自动回退到 pendingUPDATE 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
      #!/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
      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.gotestcontainers 集成测试)
  • 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.goRedis 客户端初始化,参考 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.servicesmoderationservice 构建行 + docker/docker-compose.local.yml + docker-compose.prod.yml 加服务定义 + Makefilestart-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.goRPC pb ↔ HTTP DTO 字段映射)
    • backend/gateway/client/moderation_client.gogateway 端调用本服务的 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/Nacosgateway 端通过 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' })
      • elseuni.showToast({ title: '举报已提交', icon: 'success' })
    • UI 库:用 uView 2.xu-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-archivestatus='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

-- 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 通知模板注册到 notificationService6 个模板:含自动隐藏)
  • 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
    • Fixturehttpx.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 UIrouter.go line 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           // 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_atclaim 写入 / release-dismiss 清零)、reports.resolved_action/resolved_by/resolved_at/resolution_notetakedown/ban/warn/dismiss 终态)、feedbacks.claimed_by/claimed_atclaim 写入 / release-reply-close-archive 清零)、feedbacks.replied_by/replied_at/reply_contentreply 终态)、feedbacks.closed_by/closed_atclose 终态)、feedbacks.archived_by/archived_atarchive 终态)、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 pausecollectors.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 动作名 dismisseddismiss6.2 业务 JOIN target_type='user''user_profile'moderation_actions.xor CHECK 改 XOR、补 action_type/target_pair/success_msg 三个 CHECKmoderation_target_statustarget_type/source/source_report_id 三个 CHECK + 审计索引;report_categories.severity 加 1-5 CHECKadmin_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 跨 schemaH6reports/feedbacksstatus/target_type/resolved_action/replied/claimed 4 类 CHECK 约束、终态字段一致性保护M1moderation_actions.action_type 枚举 unhiderestore 与 mts 对齐M2target_pair CHECK 限定 target_type 合法值M3last_action_type auto_hideautohide 命名风格统一M4chk_mts_warn_fields 强化要求 is_warned=TRUE ⇒ warn_count>0M5idx_mts_warned_user partial index 加 target_type='user_profile' 限定M6chk_mts_source_report_id 放宽 admin 可空支持多工单聚合警告M7admin_audit_logs.resource_* 成对 CHECKM8idx_admin_audit_logs_resourcecreated_at DESCM9idx_moderation_actions_target partial indexM10idx_reports_category 索引M11feedbacksclaimed_by/claimed_at 字段 + CHECKM12feedbacks.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 兜底 + 完整生命周期伪代码H3feedbacksclosed_by/closed_at/archived_by/archived_at 4 字段 + 2 个 CHECK 约束终态字段一致性H4chk_mts_warn_fields 放宽支持累计——is_warned=FALSEwarn_count >= 0 允许保留历史计数(语义修正:累计警告次数可在 dismiss 后保留H511.2 跨服务调用清单"5 个模板"→"6 个模板"残留修正H66.2 dismiss 流程重构为 4 路径pending 快速通道 / 普通 reviewing / auto_hidden 恢复 / auto_hidden 不恢复,每条带明确的 SQL 步骤H7错误码 50004 文案修正为"您已举报过该对象"(明确是"同 reporter" 范围M1admin_audit_logs.ip 100→500兼容 IPv6 多级代理链实测 230 字符M2feedbacksidx_feedbacks_claimed_by 索引M3reports/feedbacksstar_id 部分索引M4idx_mts_warned_user 简化去掉冗余 target_typeM5fk_reports_category / fk_feedbacks_categoryON 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.goM12阶段 2.10 标题改为"客户端 Gateway 路由配置清单",阶段 3.4 显式说明后台路由 /api/admin/moderation/* 由 FastAPI 注册不在本仓 gateway11.2 跨服务调用清单去重去幽灵行(合并 assetService 重复行、删除"资产描述词"幽灵行)
v1.6 2026-06-11 Claude Polish9 项 LOW 优化)L1feedbacks.contact 254→320RFC 5321 完整邮箱地址 64+1+255=320 上限修正注释L2idx_moderation_actions_target 简化去掉冗余 target_typeWHERE 已限定 NOT NULLv1.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.goDubbo 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 项遗漏)H1idx_moderation_actions_target 回退 v1.6 L2 简化——target_id 跨类型不唯一(users.id=123assets.id=123 冲突),必须 target_type 前缀,索引改为 (target_type, target_id, created_at DESC)H2chk_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+4reports 表补 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 同时覆盖 resolveddismissed(消息体根据 resolved_action 区分L3 规则6.1 step 2.5 自举报拦截规则明确——target_type='asset'owner_uidtarget_type='user_profile'target_id不能举报自己LOW 配置11.1 ModerationConfigMaxContactLen int // 320feedbacks.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_pairM26.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=NULLarchive 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 项 HIGHCRITICAL #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 补 ③.5moderation_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 真实 bugB16.2 路径 C Redis 通配 DEL mod:report:user:* 不可执行——Redis DEL 不支持通配;改为 `SCAN MATCH ...
v2.3 2026-06-12 Claude 九轮 polishv2.2 剩余 12 项 MEDIUM/LOW 一次性清零)P1reportstarget_snapshot JSONB schema 强定义asset / user_profile 两种结构) + target_owner_uid_at_submit BIGINTownership transfer 后通知路由) + triggered_auto_hide BOOLEAN(仅阈值触发者置 TRUE区分连带升级P29.1 加 IP 限流200/天,mod:rl:report:ip:*+ 设备指纹限流200/天)—— 防御多账号 spamevidence 大小/类型限制5MB / png
v2.4 2026-06-12 Claude 十轮聚焦修复v2.3 引入的 1 项 CRITICAL + 7 项 HIGH 真实 bugV1 CRITICAL重复 target_snapshot JSONB NOT NULL(行 231 + 行 237——PG ERROR: column specified more than once migration 跑不起来;DROP 第二处 + 同时DROP 死列 target_owner_uid_at_submitv2.3 文档化的列但无任何流读/写target_snapshot 内 owner_uid 字段已够V2chk_reports_status 枚举补 'withdrawn' + 'archived'(否则 DELETE withdraw INSERT 直接被 CHECK 拒6.5 cron UPDATE reports SET status='archived' 也会拒V3chk_moderation_actions_action_type'autohide_noop' / 'withdraw' / 'force_release'(否则 v2.3 加的 3 类 audit 行全部被拒V4chk_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_keysv2.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 真实 bugW1 HIGH-16.5 cron UPDATE feedbacks SET status='archived'archived_by=0, archived_at=$nowv2.4 漏的cron 第一次跑就违反 chk_feedbacks_archived_fields);同时补 2c 写 moderation_actions 审计行v2.4 cron 无审计W2 HIGH-3idx_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 → archived6.5 cronW4 H2POST /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"

📝 设计方案已确认,可以开始实现!