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