diff --git a/docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md b/docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md
index e5f0b17..ab01b6a 100644
--- a/docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md
+++ b/docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md
@@ -81,7 +81,7 @@ TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
│ - ReportService / FeedbackService / CategoryService │
│ - 写 PostgreSQL reports/feedbacks/categories/状态表 │
│ - Redis 计数 (自动隐藏阈值) │
-│ - 触发自动隐藏:UPDATE reports + UPSERT moderation_target_status│
+│ - 触发自动隐藏:业务表软删除 + UPDATE reports + UPSERT moderation_target_status│
└────────┬─────────────────────────────────────────┬───────────────┘
│ 共享 PG 库 │
│ │
@@ -94,9 +94,9 @@ TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
│ - report_evidence │ └──────────────────────────────────┘
│ - feedbacks │
│ - feedback_evidence │
-│ - moderation_target_status│ ← 业务服务读路径 JOIN 此表
-│ - moderation_actions │
-│ - admin_audit_logs │
+│ - moderation_target_status│ ← 审计追溯 + 警告状态表(下架/封禁由业务表 is_active/deleted_at 软删除承担)
+│ - moderation_actions │ │
+│ - admin_audit_logs │ │
└────────┬───────────────────┘
│ 共享 PG 库 (直接读写,不调对方接口)
▼
@@ -119,19 +119,19 @@ TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
| `CategoryService` (Go+FastAPI) | 维护分类(动态配置),客户端读取 + 后台 CRUD |
| `AutoHideExecutor` (Go) | Redis 计数 + 触发自动隐藏(写 reports + 状态表) |
| 后台 Moderation Admin (FastAPI) | 审核员认领/动作/分类管理,**只读写 PostgreSQL,不操作 Redis** |
-| `moderation_target_status` 表 | 业务服务读路径 JOIN,呈现"隐藏/封禁/警告"状态 |
+| `moderation_target_status` 表 | 审计追溯 + 警告状态;下架/封禁由业务表 `is_active` + `deleted_at` 软删除承担 |
**关键边界**:
- `TopFans-Activity` 后台**不调用** `moderationService` 的任何 RPC / Dubbo / HTTP 接口,**不操作 Redis**;仅通过共享 PostgreSQL 完成所有读写
- 唯一的跨服务依赖是:客户端 API 通过 gateway → moderationService(Dubbo RPC),这是项目既有的调用模式
-- 业务下架/封禁的"实际生效"完全通过读路径 `LEFT JOIN moderation_target_status` 实现,不需要跨服务写业务表
+- 业务下架/封禁的"实际生效"完全通过业务表自带的 `is_active` + `deleted_at` 软删除字段实现(读路径 `WHERE is_active=TRUE AND deleted_at IS NULL`),`moderation_target_status` 仅承担审计追溯 + 用户警告状态,不参与运行时过滤
- 如后台需查询"实时计数"等 Redis 状态,由 Go service 异步定时把 Redis 数据回写到 PostgreSQL 一张快照表(`moderation_counter_snapshot`),后台查快照表即可——**这是唯一允许的"Redis → PG"数据流,且单向**
### 2.3 复用现有资产
- **OSS 上传**:举报证据图、反馈截图复用 `assetService` 的 OSS 签名接口(`GET /api/v1/assets/oss/signature?type=asset`)
- **认证**:客户端 JWT 用 topfans userService;后台 JWT 用 `TopFans-activity` 已有的 `verify_token`
-- **通知中心**:处理结果通过 topfans `notificationService` 模板下发(新增 5 个模板)
+- **通知中心**:处理结果通过 topfans `notificationService` 模板下发(新增 6 个模板)
- **数据迁移**:遵循 topfans 现有 `backend/migrations/00x_*.sql` 命名规范
- **序列同步**:手动指定 ID 时必须 `SELECT setval('table_id_seq', MAX(id))`(CLAUDE.md 强制规范)
@@ -139,6 +139,8 @@ TopFans 是粉丝/明星数字藏品平台。当前平台缺少:
## 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 核心表
```
@@ -149,15 +151,18 @@ report_evidence 举报证据图(一对多)
feedbacks 反馈工单主表
feedback_evidence 反馈截图(一对多)
moderation_actions 审核动作流水
-moderation_target_status 共享"对象受管控状态"表(业务服务读路径 JOIN)
+moderation_target_status 共享"对象受管控状态"表(审计 + 警告;下架/封禁由业务表软删除承担)
admin_audit_logs 管理员操作日志
```
### 3.2 `report_categories` 举报分类
```sql
-CREATE SEQUENCE report_categories_id_seq START WITH 10000;
+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,
@@ -167,19 +172,20 @@ CREATE TABLE report_categories (
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
- updated_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, ...),
- ('infringing', '侵权盗版', '侵犯他人著作权/商标权', 4, 3, ...),
- ('false_info', '虚假信息', '虚假、欺骗性内容', 3, 4, ...),
- ('political', '政治敏感', '涉及政治敏感话题', 5, 5, ...),
- ('ad_spam', '广告骚扰', '垃圾广告、骚扰信息', 2, 6, ...),
- ('other', '其他', '其他违规情况', 1, 99, ...);
+ ('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));
```
@@ -187,8 +193,9 @@ 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;
+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,
@@ -201,10 +208,10 @@ CREATE TABLE feedback_categories (
);
INSERT INTO feedback_categories (code, name, description, sort_order, created_at, updated_at) VALUES
- ('bug', 'BUG 报告', '使用中遇到的问题', 1, ...),
- ('consult', '使用咨询', '不知道怎么用', 2, ...),
- ('business', '内容合作', '合作/商务联系', 3, ...),
- ('suggestion', '功能建议', '希望增加什么功能', 4, ...);
+ ('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));
```
@@ -212,38 +219,91 @@ SELECT setval('feedback_categories_id_seq', (SELECT MAX(id) FROM feedback_catego
### 3.4 `reports` 举报工单
```sql
-CREATE SEQUENCE reports_id_seq START WITH 10000;
+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 | description_word(统一以 user_profile 表示用户主页)
+ 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, -- 提交时的对象快照
-
+
+ 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,
-
- resolved_action VARCHAR(20), -- takedown | ban | warn | dismissed
+ -- 标记本工单是否曾经因阈值被自动隐藏:自动隐藏触发时置 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
+ 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
@@ -254,7 +314,7 @@ CREATE UNIQUE INDEX uk_reports_reporter_target_pending
### 3.5 `report_evidence` 举报证据图
```sql
-CREATE SEQUENCE report_evidence_id_seq START WITH 10000;
+CREATE SEQUENCE report_evidence_id_seq START WITH 10000 OWNED BY report_evidence.id;
CREATE TABLE report_evidence (
id BIGSERIAL PRIMARY KEY,
@@ -272,7 +332,7 @@ 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;
+CREATE SEQUENCE feedbacks_id_seq START WITH 10000 OWNED BY feedbacks.id;
CREATE TABLE feedbacks (
id BIGSERIAL PRIMARY KEY,
@@ -281,31 +341,70 @@ CREATE TABLE feedbacks (
category_code VARCHAR(50) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
- contact VARCHAR(100),
+ 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
+ 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;
+CREATE SEQUENCE feedback_evidence_id_seq START WITH 10000 OWNED BY feedback_evidence.id;
CREATE TABLE feedback_evidence (
id BIGSERIAL PRIMARY KEY,
@@ -323,92 +422,151 @@ CREATE INDEX idx_feedback_evidence_feedback ON feedback_evidence(feedback_id, so
### 3.8 `moderation_actions` 审核动作流水
```sql
-CREATE SEQUENCE moderation_actions_id_seq START WITH 10000;
+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 | unhide | ban | warn | dismiss | reply | close | archive
- target_type VARCHAR(30),
+ 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) OR (feedback_id IS NOT NULL))
+ 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;
+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 | description_word(与 reports.target_type 统一)
+ target_type VARCHAR(30) NOT NULL, -- asset | user_profile(与 reports.target_type 统一)
target_id BIGINT NOT NULL,
-
- is_hidden BOOLEAN NOT NULL DEFAULT FALSE, -- 隐藏/下架
- is_banned BOOLEAN NOT NULL DEFAULT FALSE, -- 封禁(仅 user)
- is_warned BOOLEAN NOT NULL DEFAULT FALSE, -- 已警告(仅 user)
-
+
+ -- ⚠️ 不存 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,
+ 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 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_hidden ON moderation_target_status(is_hidden) WHERE is_hidden = TRUE;
-CREATE INDEX idx_mts_banned ON moderation_target_status(is_banned) WHERE is_banned = TRUE;
+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) — 自动隐藏时 UPSERT
-- `TopFans-Activity` 后台 (FastAPI) — 审核员手动下架/封禁/警告时 UPSERT
+**写入方(双重写入)**:
+- `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) — 审核员手动下架/封禁/警告时同样执行"业务表 + 状态表"双重写入(事务保证一致,失败时回滚)
-**读取方**:
-- `assetService` 藏品查询/详情/列表:`LEFT JOIN moderation_target_status ON target_type='asset' AND target_id=assets.id WHERE COALESCE(is_hidden, false) = false`
-- `userService` 登录/操作校验:`LEFT JOIN ... WHERE COALESCE(is_banned, false) = false`
-- AI 描述词服务查询时同样 JOIN
+**读取方(业务表自带软删除字段已足够,`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 |
+| `asset` | `assets` | assets.id(包含名称、描述(含 AI 描述词)、素材、属性等所有字段)|
| `user_profile` | `users` | users.id(用户主页违规、头像、昵称)|
-| `description_word` | `ai_descriptions` | ai_descriptions.id(AI 描述词,存在于 `aiChatService` 库)|
-> `description_word` 类型的举报目标存放在 `aiChatService` 服务的数据库中(与 `topfans` 主库**共享同一个 PG 实例**、不同 schema:`topfans` 与 `aichat`,通过 `schema='aichat'.ai_descriptions` 跨 schema 访问)。提交举报时 `moderationService` 直连同实例,跨 schema 查询 `aichat.ai_descriptions` 验证存在性;`moderation_target_status` 写在 `topfans` schema。`aiChatService` 读路径在自己 schema 内 `SELECT ... FROM aichat.ai_descriptions d LEFT JOIN topfans.moderation_target_status mts ON mts.target_type='description_word' AND mts.target_id=d.id`。
->
-> **统一规则**:所有 moderation_* 表都在 `topfans` schema,跨 schema JOIN 是允许的(因为在同一 PG 实例)。如果未来拆分到不同 PG 实例,则需改用 Redis 缓存做跨库状态传递(见 2.2 节的 `moderation_counter_snapshot` 模式)。
+> **统一规则**:所有 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/ai_descriptions)跨服务,无外键约束,应用层负责清理
-- 在 `assetService` / `userService` / `aiChatService` 的删除逻辑中加清理钩子:
+- `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` 表不存在的记录,软标记 `is_hidden=false` 并写 admin_audit_logs
+- 定期清理脚本(每日 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;
+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,
@@ -416,13 +574,20 @@ CREATE TABLE admin_audit_logs (
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(30),
resource_id BIGINT,
- ip VARCHAR(50),
+ ip VARCHAR(500), -- 支持 IPv6 (45 字符) + X-Forwarded-For 多级代理链(实测 5 级 IPv6 代理链 ≈ 230 字符)
user_agent VARCHAR(500),
extra JSONB,
- created_at BIGINT NOT NULL
+ 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);
+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 数据结构
@@ -450,24 +615,26 @@ CREATE INDEX idx_admin_audit_logs_resource ON admin_audit_logs(resource_type, re
┌──────────────────────────────────┐
│ pending │
│ (待处理) │
- └─────┬─────────────┬──────────────┘
- 管理员认领 │ │ 阈值 N 触发
- ▼ ▼
- ┌──────────────────┐ ┌──────────────────┐
- │ reviewing │ │ auto_hidden │
- │ (审核中) │ │ (已自动隐藏) │
- └────┬────┬────┬───┘ └────┬─────────────┘
- 下架/封禁 │ │驳回│ 解除隐藏 │ 管理员认领
- /警告 │ │ │ ▼
- ▼ │ ▼ ┌──────────────────┐
- ┌──────┐ │ ┌──────────────┐ │
- │resolved│ │ │ dismissed │ │
- │(已处理)│ │ │ (已驳回) │ │
- └──────┘ │ └──────────────┘ │
- │ │
- └────→ resolved / dismissed
+ └─┬──────┬──────┬─────────┬───────┘
+ 管理员认领 │ │ 阈值 N│快速驳回 │ 释放认领(回退)
+ ▼ ▼ 触发 ▼ ▲
+ ┌──────────┐ ┌─────────────┐ ┌────────────┐
+ │ reviewing│ │ auto_hidden │ │ dismissed │
+ │ (审核中) │ │(已自动隐藏) │ │ (已驳回) │
+ └─┬──┬──┬──┘ └──────┬──────┘ └────────────┘
+ │ │ │ │ 管理员认领
+ 下架/封禁/警告│ │驳回│解除隐藏 ▼
+ ▼ │ ▼ ┌──────────┐
+ ┌─────┐ │ ┌──────────────┐ │(回到 reviewing)
+ │resolved│ │ dismissed │ │
+ │(已处理)│ │ (已驳回) │ │
+ └─────┘ │ └──────────────┘ │
+ │ │
+ └─────→ resolved/dismissed
```
+> **图示说明**:本 ASCII 图简化展示主要迁移;完整 9 行迁移矩阵见下表。图中"释放认领"回退到 pending;"快速驳回"是 `pending → dismissed` 快速通道;"解除隐藏"是 `auto_hidden → reviewing → dismissed (恢复)`。
+
| 状态 | 含义 | 进入 | 退出 |
|------|------|------|------|
| `pending` | 新提交待处理 | 创建 | 认领 OR 自动隐藏 |
@@ -482,14 +649,22 @@ CREATE INDEX idx_admin_audit_logs_resource ON admin_audit_logs(resource_type, re
|------|----|--------|----------|--------|
| (init) | pending | 客户端/系统 | 提交举报 | 写 reports |
| pending | reviewing | 后台 | 管理员点击"认领" | 抢锁 UPDATE |
-| pending | auto_hidden | 系统 | Redis counter ≥ threshold | 写 reports + UPSERT mts(is_hidden=true) |
-| auto_hidden | reviewing | 后台 | 管理员点击"认领" | 抢锁 UPDATE(被举报对象保持隐藏)|
-| reviewing | resolved | 后台 | takedown/ban/warn | 写 mts + 通知被举报方 |
-| reviewing | dismissed | 后台 | dismiss 且非 auto_hidden | 通知举报人 |
-| reviewing | dismissed | 后台 | dismiss 且曾 auto_hidden | 解除隐藏 UPSERT mts(is_hidden=false) + 通知举报人 |
-| resolved | (终态) | - | - | - |
+| 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)|
+| pending | archived | 系统 cron | 6.5 cron 90 天未处理自动 archive | UPDATE reports SET status='archived', updated_at=$now, resolution_note='auto-archived: pending > 90 days' WHERE status='pending' AND created_at < now-90d; 6.5 cron SQL 完整版见阶段 6.5 | - |
| dismissed | (终态) | - | - | - |
+> **pending → dismissed 快速通道**:用于"同一用户对同一对象重复举报"或"明显不在分类范围内的误报"——管理员可在不认领的情况下直接驳回;状态机仍记录在迁移矩阵中,与 6.2 流程保持一致。
+
**并发认领防护**:
```sql
UPDATE reports
@@ -531,9 +706,9 @@ WHERE id = ? AND status IN ('pending', 'auto_hidden')
|------|------|------|
| `pending` | 创建 | 初始 |
| `reviewing` | 认领 | |
-| `replied` | 填写 `reply_content` | 终态,触发用户通知 |
-| `closed` | 点击关闭 | 终态,重复/无效 |
-| `archived` | 归档 | 终态,已处理保留记录 |
+| `replied` | 填写 `reply_content` | **终态,不可再认领/关闭/归档**(4.2 状态机无出边;YAGNI 不含 reopen 流程)。如需追加信息,用户须创建新反馈 |
+| `closed` | 点击关闭 | **终态**,重复/无效(仅从 `reviewing` 入口,与 6.3 close SQL 一致)|
+| `archived` | 归档 | **终态**,已处理保留记录(仅从 `reviewing` 入口;v2.0 archive SQL 不接受 `replied` 分支)|
---
@@ -570,21 +745,66 @@ WHERE id = ? AND status IN ('pending', 'auto_hidden')
"code": 200,
"data": {
"report_id": 67890,
- "status": "pending",
+ "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": "您已举报过该对象,请等待处理" }
+{ "code": 50004, "message": "您已举报过该对象(未结案,同一举报人对同一对象同一类型只能有一条未结案举报)" }
```
+> **客户端编辑/撤回**(v2.4 修订):
+> - `PUT /api/v1/moderation/reports/{id}` body `{description: "...", category_code: "...", evidence_keys: [...]}`——v2.4 允许改 **`description` + `category_code` + `evidence_keys`**(之前 v2.3 只允许改 description,category_code 误选无解);仅当 `status='pending'` 且 `reporter_id=current_user`;返回 50019("非 pending 状态不可编辑")否则
+> - `DELETE /api/v1/moderation/reports/{id}`——软删除:`UPDATE reports SET status='withdrawn', updated_at=$now WHERE id=$id AND status='pending' AND reporter_id=$current_user`;**v2.4 不增 `withdrawn_at` 列**(与 `updated_at` 冗余;`status='withdrawn'` + `updated_at` 已携带完整时间信息);写 `moderation_actions.action_type='withdraw'`(v2.4 新增此 action_type)
+> - 用途:账号被盗 / 误点错分类 / 撤销误报
+
> **证据上传**:客户端先调用 `GET /api/v1/assets/oss/signature?type=asset` 直传 OSS,再把返回的 `key` 提交给本接口。
+**提交反馈请求体**(`POST /api/v1/moderation/feedbacks`):
+```json
+{
+ "category_code": "bug",
+ "title": "反馈标题",
+ "content": "详细描述",
+ "contact": "user@example.com",
+ "is_anonymous": false,
+ "evidence_keys": ["feedback/2026/06/11/uuid1.png"]
+}
+```
+
+**响应**:
+```json
+{
+ "code": 200,
+ "data": {
+ "feedback_id": 12345,
+ "status": "pending",
+ "claimed_by": null,
+ "claimed_at": null,
+ "created_at": 1718123456789
+ }
+}
+```
+
+> **字段说明**:
+> - `feedbacks` 没有 `auto_hidden` 机制(无阈值触发),新工单 status 始终为 `pending`
+> - `claimed_by` / `claimed_at` 新反馈始终为 `null`(admin 认领后 GET 详情才非 null)
+> - `created_at` 为 Unix 毫秒(参见 3 节约定)
+
### 5.2 后台 API(Vue3 后台调用,路径前缀 `/api/admin/moderation`)
| Method & Path | 说明 |
@@ -594,9 +814,13 @@ WHERE id = ? AND status IN ('pending', 'auto_hidden')
| `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` | 归档 |
@@ -631,20 +855,53 @@ 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 / aichat.ai_descriptions)
- ├─ 2.5 自举报拦截:reporter_id != owner_id(target) → 否则返回 50011
+ ├─ 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)
- │ └─ 达到 → 触发自动隐藏:
- │ ① UPDATE reports WHERE target=... AND status='pending'
- │ SET status='auto_hidden', is_auto_hidden=true
- │ ② UPSERT moderation_target_status
- │ (target_type, target_id, is_hidden=true, source='auto', source_report_id=...)
- │ ③ 发通知给被举报方:"您的内容被多人举报已自动隐藏"
+ │ └─ 达到 → 触发自动隐藏(**事务内双重写入**):
+ │ ① 业务表软删除(按 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}
```
@@ -663,34 +920,136 @@ gateway → moderationService.SubmitReport()
│ 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(藏品/描述词/用户内容):
- │ UPSERT moderation_target_status (is_hidden=true, source='admin', ...)
- │ UPDATE reports SET status='resolved', resolved_action='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(用户):
- │ UPSERT moderation_target_status (is_banned=true, source='admin', ...)
- │ UPDATE reports SET status='resolved', resolved_action='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(用户):
- │ UPSERT moderation_target_status (is_warned=true, source='admin', ...)
- │ UPDATE reports SET status='resolved', resolved_action='warn', ...
+ ├─ 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', ...
│ 发站内信给用户
│
- ├─ dismiss(驳回,状态机必经 reviewing):
- │ 注意:auto_hidden 工单必须先被审核员认领(→ reviewing)才能 dismiss
- │ 路径 auto_hidden → reviewing → dismissed:
- │ ① UPSERT moderation_target_status (is_hidden=false, source='admin', ...) 解除隐藏
- │ ② UPDATE reports SET status='dismissed', resolved_action='dismissed', ...
- │ 路径 pending/reviewing → dismissed(未触发自动隐藏):
- │ 仅 ②,不动 moderation_target_status
- │ 发通知给举报人:"您的举报已驳回"
+ │ **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
```
@@ -707,18 +1066,32 @@ gateway → moderationService.SubmitReport()
认领 → 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', replied_by=NULL WHERE id=? AND status='reviewing'
+ │ 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
+ │ UPDATE feedbacks SET status='replied', reply_content=?, replied_by=?, replied_at=now,
+ │ claimed_by=NULL, claimed_at=NULL -- 终态清认领人,避免与 `replied_by` 重复
│ 发通知给反馈人
│
- ├── 关闭 → POST .../close → status='closed'(终态,重复/无效)
+ ├── 关闭 → 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 → status='archived'(终态)
+ └── 归档 → 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 脚本
@@ -741,7 +1114,24 @@ end
return {0, n}
```
-返回 `{triggered, count}`:triggered=1 时上层触发自动隐藏。`lock` 不在脚本内,由应用层在调用 EVAL 之前 `SET key 1 NX EX 5` 抢占。
+返回 `{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:*'`)
---
@@ -752,27 +1142,43 @@ return {0, n}
| 50001 | 举报分类不存在或已停用 | 400 |
| 50002 | 反馈分类不存在或已停用 | 400 |
| 50003 | 目标对象不存在 | 404 |
-| 50004 | 同一对象已举报过(未结案)| 429 |
+| 50004 | 您已举报过该对象(未结案,同一举报人对同一对象同一类型只能有一条未结案举报)| 429 |
| 50005 | 描述过长(>500 字)| 400 |
| 50006 | 证据图超限(>5 张)| 400 |
| 50007 | 工单不存在 | 404 |
| 50008 | 工单已被他人认领 | 409 |
-| 50009 | 工单已结案 | 400 |
+| | **响应体**:`{ 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 | 状态机非法迁移(如对已结案工单再次操作)| 409 |
+| 50014 | 状态机非法迁移(如 `pending → archived` 跳过 reviewing)| 409 |
+| | **响应体**:`{ code, message, current_status: "pending"|"reviewing"|"auto_hidden"|"resolved"|"dismissed", suggested_action: "re_claim"|"wait_for_admin"|"contact_support" }`(v2.5 新增)—— race 后 admin A 拿到的不是 50008("已被他人认领")而是 50014("工单已被释放或结案,请 re-claim 后再操作"),前端按 `current_status` 智能提示 | | |
| 50015 | 管理员未登录 / Token 失效 | 401 |
+| **50016**(v2.3)| 举报对象类型不支持(`target_type` 不在白名单 `{'asset','user_profile'}`)| 400 |
+| **50017**(v2.3)| 回复 / 备注 必填路径(path C/D/E 选 dismiss/unban 时需填 `reason`)| 400 |
+| **50018**(v2.3)| 证据图超限(单图 > 5MB / MIME 非 png/jpeg)| 400 |
+| **50019**(v2.3)| 举报不可编辑(仅 `status='pending'` 可改 `description`)| 409 |
+
+> **50008 vs 50009 判定顺序**(v2.3 明确):
+> - 客户端发 claim/reply/close/archive,0 行匹配时 handler 二次 `SELECT` 查当前状态:
+> 1. 记录不存在(`id` 未找到)→ 50007 "工单不存在"
+> 2. `status='reviewing'` 且 `claimed_by<>本人` → 50008 "工单已被他人认领"(响应体含 `claimed_by` / `claimed_at`,见 50008 行)
+> 3. `status IN ('replied','closed','archived')`(反馈)/ `('resolved','dismissed')`(举报)→ 50009 "工单已结案"
+> 4. `status='reviewing'` 且 `claimed_by=本人` → 200 幂等成功(re-claim 同人)
+> 5. `status='pending'` + 请求非 claim(如 takedown)→ 50014 状态机非法迁移(pending → resolved 跳过 reviewing)|
---
## 8. 通知机制
-复用 `notificationService` 模块,注册 5 个模板:
+复用 `notificationService` 模块,注册 6 个模板:
| 事件 | 接收方 | 模板名 | 渠道 |
|------|--------|--------|------|
+| 举报达到阈值触发自动隐藏 | 被举报方 | `report_auto_hidden_notice` | 站内信 |
| 举报状态变更为 resolved/dismissed | 举报人 | `report_resolved` | 站内信 + 通知中心 |
| 举报成立 + takedown | 被举报方 | `report_takedown_notice` | 站内信 |
| 举报成立 + ban | 被举报用户 | `report_ban_notice` | 站内信 + 短信 |
@@ -780,6 +1186,8 @@ return {0, n}
| 反馈被回复 | 反馈人 | `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 评估)。
---
@@ -793,12 +1201,17 @@ return {0, n}
| 防 Redis 计数重复 | Lua 脚本用 `user_marker` SETNX 保证独立用户才 INCR |
| 防自动隐藏并发 | Redis 短锁 `mod:report:lock:*` 5s + DB 幂等 SQL(仅 pending → auto_hidden)|
| 证据图校验 | OSS 签名接口限定目录 `report/` 和 `feedback/` 前缀 |
+| **证据图大小/类型**(v2.4 修订)| **moderationService 不能直接校验**(证据图通过 `GET /api/v1/assets/oss/signature?type=asset` 拿 presigned URL 后**客户端直传 OSS**,moderationService 只收到 `key` 字符串,不见字节)。校验在 **OSS bucket policy 层**实施:单图 ≤ 5MB / MIME 限 `image/png` `image/jpeg` / OSS 返回 4xx 时客户端应捕获并**不**提交 report;服务端 9.1 仍 50018 作为**防御纵深**(客户端篡改 key 提交,提交时校验 `evidence_keys` 字符串格式与 `report/` 前缀,但**不**二次校验大小/MIME——OSS 已校验)。监控指标 `moderation.oss.evidence_upload_failed.count`(OSS 4xx 响应)|
| 描述长度 | 服务端校验 ≤ 500 字(DB 字段 VARCHAR(500))|
| 证据图数量 | 服务端校验 ≤ 5 张 |
-| 匿名保护 | `is_anonymous=true` 时:`reporter_id` 仍记录但 API 响应中不返回给被举报方;后台仅对超级管理员可见 |
+| 匿名保护 | `is_anonymous=true` 时:`reporter_id` 仍记录但 API 响应中不返回给被举报方(**仅对被举报方匿名**);**后台所有审核员可见** `reporter_id`(1.3 范围"仅一种角色"——不存在超级管理员;如需审核员侧也匿名,需解除 YAGNI 限制并新增 super_admin 角色)|
| 自举报拦截 | 提交举报时校验 `reporter_id != owner_id(target)`,命中则返回 50011 |
+| **IP 限流**(v2.3)| 每 IP 每天 ≤ 200 次举报(Redis 计数 `mod:rl:report:ip:{ip}:{yyyymmdd}`,TTL 36h);与 user_id 限流 `AND` 语义(任一超限即 50012)—— 防御多账号 spam 攻击 |
+| **Device fingerprint 限流**(v2.3)| 每设备指纹每天 ≤ 200 次举报(`mod:rl:report:device:{fp}:{yyyymmdd}`);同上 `AND` 语义 |
+| **star_id stale 容忍**(v2.3)| `reports.star_id` 是 denormalized 副本,跨表无 FK;star binding 转移后 reports.star_id 不回溯更新 |
| 全局限流(举报) | 每用户每天 ≤ 30 次举报(Redis 计数 `mod:rl:report:user:{user_id}:{yyyymmdd}`,TTL 36h);超限返回 50012 |
| 全局限流(反馈) | 每用户每天 ≤ 5 条反馈(Redis 计数 `mod:rl:feedback:user:{user_id}:{yyyymmdd}`,TTL 36h);超限返回 50013 |
+| **Claim 超时自动释放**(v2.3)| 15 分钟未操作的 `reviewing` 工单由 cron 任务自动回退到 `pending`:`UPDATE reports SET status='pending', claimed_by=NULL, claimed_at=NULL WHERE status='reviewing' AND claimed_at < now() - INTERVAL '15 minutes'`;监控指标 `moderation.reports.claim_timeout.count` |
### 9.2 数据隔离
@@ -811,22 +1224,55 @@ return {0, n}
- `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 写迁移 `D:\shanghai\topfans\backend\migrations\2026_06_11_010_moderation_tables.sql`
-- 1.2 包含 8 张表 + 索引 + 默认分类种子数据
+- 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 脚本生成种子数据(带序列重置)
+- 1.4 测试数据脚本(可选):在 `backend/scripts/` 加 Go 脚本生成额外测试数据;如 SQL 迁移已含种子数据则跳过
### 阶段 2:Go moderationService(topfans)
-- 2.1 `D:\shanghai\topfans\backend\services\moderationService\` 新建目录
- - `main.go` / `config/` / `repository/` / `service/` / `provider/` / `client/` / `go.mod` / `start.sh`
+- 2.1 `backend/services/moderationService/` 新建目录
+ - `main.go` / `config/` / `repository/` / `service/` / `provider/` / `client/` / `start.sh` / `configs/dubbo.yaml` / `go.mod`
- 复用 socialService 类似的分层
-- 2.2 `D:\shanghai\topfans\backend\proto\moderation\moderation.proto`
+ - 端口:`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`
@@ -835,24 +1281,29 @@ return {0, n}
- `ListMyFeedbacksRequest/Response`
- `GetFeedbackRequest/Response`
- `GetFeedbackCategoriesRequest/Response`
-- 2.3 `D:\shanghai\topfans\backend\pkg\proto\moderation\` 编译生成 `.pb.go`
-- 2.4 `D:\shanghai\topfans\backend\pkg\models\moderation.go` ORM 模型
-- 2.5 `D:\shanghai\topfans\backend\services\moderationService\repository\moderation_repository.go` 数据访问
-- 2.6 `D:\shanghai\topfans\backend\services\moderationService\service\`:
- - `report_service.go`
+- 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 脚本)
-- 2.7 `D:\shanghai\topfans\backend\services\moderationService\client\`:
+ - `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`(验证用户/封禁查询)
-- 2.8 `D:\shanghai\topfans\backend\services\moderationService\provider\social_provider.go` Dubbo 注册
-- 2.9 `D:\shanghai\topfans\backend\go.work` 加入新服务
+ - `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 层:
- - `D:\shanghai\topfans\backend\gateway\controller\moderation_controller.go`
- - `D:\shanghai\topfans\backend\gateway\dto\moderation_dto.go`
- - `D:\shanghai\topfans\backend\gateway\dto\moderation_converter.go`
- - `D:\shanghai\topfans\backend\gateway\router\router.go` 注册 `/api/v1/moderation/*`
+ - `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
@@ -874,19 +1325,25 @@ 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)
-- 3.1 `D:\shanghai\TopFans-activity\backend\models\moderation.py` SQLAlchemy ORM(与 PG 表结构对应)
-- 3.2 `D:\shanghai\TopFans-activity\backend\schemas\moderation.py` Pydantic 模型
-- 3.3 `D:\shanghai\TopFans-activity\backend\crud\moderation_crud.py` 数据库操作
+
+> **范围说明**:以下产物在外部仓库 `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 `D:\shanghai\TopFans-activity\backend\handlers\moderation_admin.py` 路由层
+- 3.4 `TopFans-activity/backend/handlers/moderation_admin.py` 路由层
- 复用现有 `verify_token` 中间件
-- 3.5 `D:\shanghai\TopFans-activity\backend\router\__init__.py` 注册路由
+ - **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 表不一致时):
- - `D:\shanghai\TopFans-activity\backend\migrations\010_moderation_init.sql`(如果用 SQLAlchemy create_all 可省略)
+ - `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 封装
- - `getReports()` / `getReportDetail()` / `claimReport()` / `executeAction()` / ...
+- 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` 举报工单列表(筛选:状态/分类/目标类型/关键词)
@@ -907,13 +1364,16 @@ v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail)
- 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` 加:
+- 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, targetId, targetName`
+ - props: `targetType: 'asset' | 'user_profile'`, `targetId: number`, `targetName: string`(类型断言)
- 表单:分类单选 + 描述 textarea + 证据图上传(最多 5 张)+ 匿名开关
- - 调 `submitReportApi`,成功后提示"已提交"+ 自动隐藏提示
+ - 调 `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` 等) 长按或"..."菜单
- 用户主页(粉丝身份下)"..."菜单
@@ -924,32 +1384,125 @@ v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail)
- 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:业务服务读路径加 JOIN(按需展开)
-- 6.1 `D:\shanghai\topfans\backend\services\assetService\repository\` 藏品查询
- - 详情/列表 SQL:`LEFT JOIN moderation_target_status ON target_type='asset' AND target_id=assets.id WHERE COALESCE(is_hidden, false) = false`
-- 6.2 `D:\shanghai\topfans\backend\services\userService\repository\` 用户校验
- - 登录/操作校验:`LEFT JOIN ... ON target_type='user' AND target_id=users.id WHERE COALESCE(is_banned, false) = false`
-- 6.3 描述词相关服务(`aiChatService` 或新)查询时同样 JOIN
-- 6.4 性能优化(如果热路径压力变大):
- - Redis 缓存 `mod:status:cache:{target_type}:{target_id}` TTL 60s
+### 阶段 6:业务服务读路径(沿用既有软删除过滤,无须改业务 SQL)
+- 6.1 `assetService` 藏品查询/详情/列表
+ - 既有 SQL 已含 `WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL`,由审核动作产生的下架/封禁已通过软删除自动过滤,**本阶段无须改 SQL**
+- 6.2 `userService` 登录/操作校验
+ - 既有 SQL 已含 `WHERE users.is_active = TRUE AND users.deleted_at IS NULL`,由审核动作产生的 ban 已通过软删除自动拦截,**本阶段无须改 SQL**
+- 6.3 描述词(`assets.description` 字段,随 asset 软删除自动过滤)
+ - 既有 SQL 已含 `WHERE assets.is_active = TRUE AND assets.deleted_at IS NULL`,**本阶段无须改 SQL**
+- 6.4 用户警告展示(仅前端/详情页需要,**非热路径**)
+ - `LEFT JOIN moderation_target_status ON target_type='user_profile' AND target_id=users.id WHERE is_warned = TRUE`
+ - 用于在用户主页/控制台展示最近一次警告原因与时间;不阻塞登录
+- 6.5 性能优化(如警告查询变热):
+ - Redis 缓存 `mod:warn:cache:{user_id}` TTL 60s
- 命中即跳过 JOIN
+### 阶段 6.5:定时任务(v2.3 新增)
+
+每日 cron 任务(建议挂载在 K8s CronJob 或 systemd timer):
+
+> **步骤顺序约束**(v2.5 文档化,避免 PII 时钟被业务操作重置):
+> 1. **先 PII 匿名化**(用 `closed_at`/`replied_at`/`archived_at` 而非 `updated_at`,与具体业务操作解耦)
+> 2. **后 auto-archive**(status='pending' 状态 90 天未处理)
+> 3. **最后 claim 超时回收**(reviewing 状态 15 分钟未操作)
+>
+> 若一个反馈既 > 90 天 pending 又被 stale-claim 锁定 91 天,链路是:claim 释放 → status='pending' → auto-archive → status='archived'。PII 匿名化时钟从 `archived_at` 起(不是 `created_at`)——admin 调查时间会延长 PII 保留窗口(这是 GDPR/中国《个人信息保护法》"最小必要"原则下可接受的 trade-off)。
+
+```sql
+-- 1. PII 匿名化(feedbacks.contact 90 天后清空;v2.5 扩到所有终态 replied/closed/archived)
+UPDATE feedbacks
+SET contact = NULL
+WHERE contact IS NOT NULL
+ AND (
+ -- 'replied' 没有 closed_at/archived_at,用 replied_at(PII 时钟从回复时刻起 90 天)
+ (status = 'replied' AND replied_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
+ OR (status = 'closed' AND closed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
+ OR (status = 'archived' AND archived_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000)
+ );
+-- v2.5 修复 v2.4 GDPR 漏洞:之前只覆盖 closed/archived,漏了 'replied'(admin 回复后用户的 contact 永不匿名化)
+
+-- 2. 过期 pending 工单自动 archive(reports)—— v2.5 补 archived_at + resolution_note
+UPDATE reports
+SET status = 'archived',
+ updated_at = EXTRACT(EPOCH FROM NOW()) * 1000,
+ resolution_note = 'auto-archived: pending > 90 days'
+WHERE status = 'pending'
+ AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
+
+-- 2b. 过期 pending 工单自动 archive(feedbacks)—— v2.5 补 archived_by/archived_at(满足 chk_feedbacks_archived_fields)+ 审计行
+UPDATE feedbacks
+SET status = 'archived',
+ archived_by = 0, -- 0 = system auto sentinel (与 admin_id CHECK 配合)
+ archived_at = EXTRACT(EPOCH FROM NOW()) * 1000,
+ updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
+WHERE status = 'pending'
+ AND created_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '90 days') * 1000;
+
+-- 2c. 审计行(v2.5 新增)—— 为刚 archived 的 feedbacks 写 moderation_actions 流水
+INSERT INTO moderation_actions
+ (report_id, feedback_id, admin_id, action_type, target_type, target_id,
+ note, success, created_at)
+SELECT NULL, id, 0, 'archive', 'user_profile', id,
+ 'auto-archived by cron: pending > 90 days', TRUE, archived_at
+FROM feedbacks
+WHERE status = 'archived' AND archived_by = 0
+ AND archived_at = EXTRACT(EPOCH FROM NOW()) * 1000;
+
+-- 3. Claim 超时自动释放(每 5 分钟跑)
+UPDATE reports
+SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
+WHERE status = 'reviewing'
+ AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
+-- 同理 feedbacks
+UPDATE feedbacks
+SET status = 'pending', claimed_by = NULL, claimed_at = NULL, updated_at = EXTRACT(EPOCH FROM NOW()) * 1000
+WHERE status = 'reviewing'
+ AND claimed_at < EXTRACT(EPOCH FROM NOW() - INTERVAL '15 minutes') * 1000;
+```
+
+> **监控指标**:
+> - `moderation.feedback.pii_anonymized.count` — 每日匿名化条数
+> - `moderation.reports.auto_archived.count` — 每日 archive 条数
+> - `moderation.reports.claim_timeout.count` — claim 超时回收条数
+
### 阶段 7:通知与测试
-- 7.1 通知模板注册到 `notificationService`(5 个模板)
+- 7.1 通知模板注册到 `notificationService`(6 个模板:含自动隐藏)
- 7.2 Go 单元测试
- - `report_service_test.go` 防重复、自动隐藏触发
- - `feedback_service_test.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` 列表/详情/认领/动作
+ - `test_moderation_admin.py` 列表/详情/认领/动作(含 dismiss 4 路径分支覆盖)
- 鉴权测试(无 token 401)
-- 7.4 集成测试
- - 端到端:提交举报 → 后台审核 → 状态变更 → 用户收到通知
- - 自动隐藏:5 个独立用户举报 → 自动隐藏 + 状态表写入
+ - 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)做端到端联调
---
@@ -966,6 +1519,18 @@ type ModerationConfig struct {
LockTTL time.Duration // 5s
MaxDescriptionLen int // 500
MaxEvidenceCount int // 5
+ MaxContactLen int // 320(RFC 5321 完整邮箱地址上限;feedbacks.contact 服务端校验)
+ MaxReplyLen int // 2000(feedbacks.reply_content 服务端校验,v2.3)
+ MaxEvidenceSize int64 // 5MB 单图上限(v2.3)
+ AllowedMimeTypes []string // ["image/png", "image/jpeg"](v2.3)
+ RateLimitUserPerDay int // 30(举报)/ 5(反馈)
+ RateLimitIPPerDay int // 200(v2.3,IP 限流防多账号 spam)
+ RateLimitDevicePerDay int // 200(v2.3,设备指纹限流)
+ ClaimTimeout time.Duration // 15 分钟(v2.3,cron 任务自动释放)
+ PIIAnonymizeAfter time.Duration // 90 天(v2.3,feedbacks.contact 自动匿名化)
+ AutoArchiveAfter time.Duration // 90 天(v2.3,pending 工单自动 archive)
+ RedisKeyPrefix string // "mod:report"(生产)| "test:mod:report"(测试,v2.3)
+ PathDCounterPolicy string // "reset" | "preserve" | "expire_1h"(v2.3,路径 D 的 Redis counter 行为)
}
```
@@ -973,22 +1538,49 @@ type ModerationConfig struct {
| 调用方 | 被调服务 | 触发时机 | 失败处理 |
|--------|----------|----------|----------|
-| moderationService | assetService.GetAssetRPC | 提交举报 `target_type='asset'` 验证目标 | 返回 50003 |
+| moderationService | assetService.GetAssetRPC | 提交举报 `target_type='asset'` 验证目标(含 `assets.description` AI 描述词随表过滤)| 返回 50003 |
| moderationService | userService.GetUserRPC | 提交举报 `target_type='user_profile'` 验证用户 | 返回 50003 |
-| moderationService | aichat.ai_descriptions(跨 schema 直查)| 提交举报 `target_type='description_word'` 验证目标 | 返回 50003 |
-| moderationService | notificationService | 自动隐藏 + 动作结果通知(5 个模板)| 仅日志,不影响主流程 |
-| TopFans-Activity 后台 | (无 — 仅读写 DB) | - | - |
-| assetService | (无 — 仅读 DB 做 JOIN) | - | - |
-| userService | (无 — 仅读 DB 做 JOIN) | - | - |
-| aichat 描述词服务 | (无 — 跨 schema 读 topfans.moderation_target_status 做 JOIN) | - | - |
+| 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)
-- `moderation.actions.failed.count` (counter, tags: action_type, error)
+
+**失败/异常类**(v1.5 补充,11.2 已声明"通知失败仅日志",必须可观测):
+- `moderation.actions.failed.count` (counter, tags: action_type, error) — 已有
+- `moderation.report.duplicate.count` (counter, tag: target_type) — 防重复触发次数
+- `moderation.auto_hide.lock.contention.count` (counter) — 5s 短锁抢占失败次数(监控 Lua 锁是否合理)
+- `moderation.notification.send_failed.count` (counter, tag: template) — 6 个模板的失败分布
+- `moderation.transaction.rollback.count` (counter, tag: flow=auto_hide|takedown|ban|dismiss) — 事务回滚次数
+
+**延迟/SLO 类**(v1.5 补充):
+- `moderation.api.latency` (histogram, tag: endpoint, status) — 客户端 + 后台接口 P50/P95/P99
+- `moderation.dashboard.queue.pending` (gauge) — pending 状态工单数(运营告警:> N 触发告警)
+- `moderation.dashboard.queue.auto_hidden` (gauge) — 待认领 auto_hidden 工单数
+
+**运行时(自动注册)**:
+- Go runtime 默认指标(goroutine 数、heap、GC pause)— `collectors.NewGoCollector()` 自动注册
+
+**Grafana 看板 + 告警规则**:另行维护(不阻塞本设计)。
+
+**v2.3 新增指标**:
+- `moderation.report.auto_hidden.noop.count` (counter) — auto-hide 触发但目标已 `is_active=false` 的 no-op 次数
+- `moderation.actions.auto_hide.count` (counter, tag: target_type) — 系统自动 takedown 次数(与人工 takedown 区分)
+- `moderation.actions.warn_cleared.count` (counter) — warn_cleared 流程触发次数
+- `moderation.redis.counter_reset.count` (counter, tag: path=C|D) — Redis counter 重置监控
+- `moderation.feedback.pii_anonymized.count` (counter) — 每日 PII 匿名化条数
+- `moderation.reports.auto_archived.count` (counter) — 每日 auto-archive 条数
+- `moderation.reports.claim_timeout.count` (counter) — claim 超时自动释放条数
+- `moderation.feedback.terminal_state.claim_attempt.count` (counter, tag: status=replied|closed|archived) — 监控试图认领终态工单次数(验证 50009 区分是否生效)
+- `moderation.api.latency.evidence_upload.p95` (histogram) — 证据上传 P95 延迟
+- `moderation.rate_limit.hit.count` (counter, tag: type=report|feedback, scope=user|ip|device) — 限流触发监控
### 11.4 变更历史
@@ -996,6 +1588,20 @@ type ModerationConfig struct {
|------|------|------|----------|
| 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)|
---
diff --git a/docs/superpowers/specs/2026-06-12-load-testing-design.md b/docs/superpowers/specs/2026-06-12-load-testing-design.md
new file mode 100644
index 0000000..0c44d9a
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-12-load-testing-design.md
@@ -0,0 +1,1154 @@
+# 后端服务压力测试设计
+
+> **状态**:设计已确认 ✅
+> **创建时间**:2026-06-12
+> **作者**:Claude
+> **目标**:为部署在阿里云单机(`101.132.250.62`,4G/2C,docker-compose)的 TopFans 后端微服务,设计一套可执行、可恢复、可重复的压力测试方案。覆盖容量评估、SLA 基线、稳定性验证、破坏性测试四类目标。
+
+---
+
+## 目录
+
+1. [背景与目标](#1-背景与目标)
+2. [关键约束与已验证事实](#2-关键约束与已验证事实)
+3. [总体方案与架构](#3-总体方案与架构)
+4. [测试数据准备](#4-测试数据准备)
+5. [场景设计与 RPS 梯度](#5-场景设计与-rps-梯度)
+6. [执行计划与时间盒](#6-执行计划与时间盒)
+7. [监控指标与判停红线](#7-监控指标与判停红线)
+8. [风险控制与回滚](#8-风险控制与回滚)
+9. [产出物清单](#9-产出物清单)
+10. [不在范围(YAGNI)](#10-不在范围yagni)
+11. [后续步骤](#11-后续步骤)
+12. [附录 A:术语表](#附录-a术语表)
+13. [附录 B:被测接口路径速查](#附录-b被测接口路径速查)
+
+---
+
+## 1. 背景与目标
+
+### 1.1 背景
+
+TopFans 平台当前部署在阿里云单机 `101.132.250.62`,使用 `docker/docker-compose.prod.yml` 启动 11 个容器(gateway + 9 个 Dubbo 微服务 + PostgreSQL + Redis)。
+
+项目目前处于早期阶段(生产数据规模:89 users / 91 fan_profiles / 223 assets),尚未做过任何系统化的压力测试。团队对系统在当前 4G/2C 资源配置下的**真实承载能力**、**接口性能瓶颈**、**长时间运行的稳定性**均缺乏量化数据。
+
+### 1.2 目标(四象全要)
+
+| 目标 | 含义 | 产出 |
+|---|---|---|
+| **G1 容量评估** | 找出每个核心接口的拐点 RPS(错误率/P99 突破阈值前的最大稳态 RPS) | 每场景一个"拐点 RPS"数字 + 瓶颈分析 |
+| **G2 SLA 基线** | 建立每个接口的 P50/P95/P99 延迟基线,供后续回归对比 | `baseline.csv` + HDR 直方图 |
+| **G3 稳定性** | 在安全水位下跑 30 分钟,验证无内存/连接池/goroutine 泄漏 | 时序图 + 资源增长率 |
+| **G4 破坏性** | 推至拐点 2-3 倍 RPS,验证降级与自动恢复 | 恢复时间 + 异常日志摘录 |
+
+### 1.3 非目标(明确不做)
+
+- 不做端到端业务正确性测试(这是 E2E 的工作)
+- 不做安全/渗透测试
+- 不做前端性能测试
+- 不做单元/集成测试
+- 不做 AI Chat WebSocket 压测(独立场景,本轮跳过)
+- 不做活动榜单/星册等次要接口压测(本轮聚焦核心 7 场景)
+
+---
+
+## 2. 关键约束与已验证事实
+
+### 2.1 环境约束
+
+| 项 | 现状 |
+|---|---|
+| 部署位置 | 阿里云 ECS `101.132.250.62`(**华东 1 / 杭州**,101.132.x.x 段;root SSH) |
+| 资源 | **4G RAM / 2 CPU**(生产单机,无 staging) |
+| 容器资源限额 | gateway 300M/0.5C,各微服务 100-200M/0.5C,PG 400M(max 100 connections),Redis 256M |
+| 入口 | 公网 `http://101.132.250.62:8080`(无 nginx 反代,gateway 直接对外) |
+| 部署脚本 | `docker/deploy.sh`(参考用法见脚本头部注释) |
+
+⚠️ **PG 内存配置冲突(必须在 preflight 阶段处理)**:
+- PG 容器限额 400M,但 `POSTGRES_MAX_CONNECTIONS=100`
+- 每个 PG backend 进程典型占用 5-10MB(work_mem + stack + plan cache,不含 shared_buffers)
+- 100 connections × 7MB ≈ **700MB > 400M 容器限额**
+- → R4 红线 85 connections 之前,PG 进程很可能已被 cgroup OOM Killer 杀掉
+- → R6 OOM 红线如果检测不准(详见 §7.3 修复说明),会出现"PG 突然消失但红线没触发"
+- **处置方案**(任选其一):
+ - 方案 A(推荐):第一轮压测前**手动**把 `POSTGRES_MAX_CONNECTIONS` 降到 50(preflight 仅做**检测+报错+给出 SQL 命令**,不自动改 PG 配置,因为 `max_connections` 修改需要重启 PG 才生效,不能 reload)。手动步骤:
+ ```bash
+ # 改 docker-compose.prod.yml POSTGRES_MAX_CONNECTIONS=50
+ # 重启 postgres 容器(约 30s 停机)
+ docker-compose -f docker-compose.prod.yml restart postgres
+ # 验证
+ docker exec "$PG_CONTAINER" psql -U postgres -c "SHOW max_connections;"
+ ```
+ - 方案 B:把 PG 容器 limits.memory 提到 1024M,max_connections 不变(同样需重启 PG)
+ - 方案 C:接受现状,把 R4 阈值从 85 调到 50(无停机,但风险还在)
+
+### 2.2 数据库约束(来自本地 `top-fans` 库 schema 调研)
+
+**库名差异(重要)**:
+- 本地 docker:`top-fans`(带横线)
+- prod docker-compose 配置:`topfans`(无横线)
+- → seed/loadgen 工具的 DSN 必须参数化,**开工前先 ssh 到 prod 跑 `\d+ ` 验证 schema 与本地一致**
+
+**核心表与隐含约束**:
+
+| 表 | 关键约束 |
+|---|---|
+| `users` | mobile 唯一(仅 deleted_at IS NULL);id BIGSERIAL |
+| `stars` | star_id BIGSERIAL;identity_id 唯一 |
+| `fan_profiles` | `(user_id, star_id)` 唯一;`(star_id, nickname)` 唯一;多了 `experience`/`revenue_boost_bps` 字段(vs GORM model) |
+| `assets` | id BIGSERIAL;外键 owner_uid → users.id, star_id → stars.star_id |
+| `asset_likes` | **`exhibition_id NOT NULL`**;唯一约束 `(user_id, asset_id, exhibition_id)`;**点赞必须依附 exhibition** |
+| `exhibitions` | `uk_asset WHERE deleted_at IS NULL`:每个 asset 同一时刻只能上一个展位 |
+| `booth_slots` | `is_enabled` 默认 false,**seed 时必须显式置 true** |
+| `mint_orders` | OrderID 是 UUID(字符串主键),不需序列重置 |
+
+**`auto_users` 表(避坑)**:
+存在一张 `auto_users` 表(主键序列名 `auto_like_users_id_seq` 暴露原始用途:**自动点赞机器人元数据表**)。压测数据**绝不写入 auto_users**,避免被业务侧的自动点赞调度器扫到产生不可控背景流量。
+
+### 2.3 业务约束
+
+**铸造成本指数翻倍(关键约束)**:
+
+`mint_cost_config` 当前配置:
+
+```
+第1次:2 第2次:4 第3次:8 第4次:16 第5次:32
+第6次:64 第7次:128 第8次:256 第9次:512 第10次:1024
+累计 = 2046 水晶/用户
+```
+
+**含义**:
+- 单测试账号最多压 10 次铸造(之后 `mint_cost_config` 查空报错)
+- → 铸造场景采用 **轮转重置策略**:每 200 秒一轮,跑完 reset 水晶+次数+订单,再开下一轮(详见 §5.4)
+
+**JWT secret**:
+- 本地:`topfans-secret-key-local-dev-only`
+- prod:`topfans-secret-key-please-change-in-production`
+- seed 工具复用 `backend/pkg/jwt.GenerateToken()`,secret 通过 `--jwt-secret` 参数注入,**不要 commit 到 repo**
+
+### 2.4 已确认决策汇总
+
+| 决策项 | 选择 |
+|---|---|
+| 压源位置 | 同地域阿里云 ECS(4G/2C 按量付费,~5 元/天) |
+| 监控形态 | 可配置三档:`--monitor=off/lite/full`,默认 `lite` |
+| 工具栈 | **方案 D**:自研 Go 二进制 `loadgen` + `seed` |
+| 业务规模预期 | 早期项目摸底,无业务量预设 |
+| 核心场景 | S1 登录、S2 资产读、S3 点赞、S4 铸造、S5 看板、S6 多维榜单、S7 上架 |
+| seed 运行位置 | prod 服务器本地(避免 PG 公网暴露) |
+| schema 验证 | 开工前 ssh prod 跑 diff |
+| 铸造场景节奏 | 轮转重置 |
+| RPS 阶梯策略 | 每场景独立阶梯(详见 §5.6) |
+| 压测分轮 | 分两轮:探索 ~2h + 修复 + 验证 ~4h |
+| 第一轮混合场景 | 不做(数据驱动后再定比例) |
+| 6 维红线阈值 | 维持设计原值(详见 §7.3) |
+| 实时仪表 | stderr 行模式(不做 TUI) |
+| Grafana 面板 | 4 个(整机/容器/PG/业务) |
+
+---
+
+## 3. 总体方案与架构
+
+### 3.1 架构总览
+
+```
+┌──────────────────────────┐ ┌────────────────────────────────────┐
+│ 压力机 (同地域 ECS) │ 公网 HTTP │ prod 101.132.250.62 (4G/2C) │
+│ │ ─────────► │ │
+│ loadgen (Go binary) │ │ docker-compose.prod.yml │
+│ ├ scenarios/*.go │ │ ├ gateway:8080 (REST) │
+│ ├ lib/{ramp,circuit, │ │ ├ userservice/socialservice/... │
+│ │ hdr,csv,client} │ │ └ postgres + redis │
+│ ├ users.csv (1000 行) │ │ │
+│ └ reports/run-*/ │ │ /opt/topfans/loadtest/ │
+│ │ ssh tunnel │ ├ seed binary │
+│ loadgen-watcher (ssh) │ ─────────► │ ├ sample.sh (后台) │
+│ │ │ ├ metrics-feed.jsonl │
+│ │ │ ├ emergency-stop.sh │
+│ │ │ └ restore-from-backup.sh │
+└──────────────────────────┘ └────────────────────────────────────┘
+ ▲ ▲
+ │ 报告生成(事后) │ 可选: docker-compose.monitor.yml
+ ▼ ▼
+ ./reports/run-YYYYMMDD-HHMMSS/ cAdvisor + node/pg/redis-exporter
+ *.json *.csv *.md *.svg + Prometheus + Grafana (端口 3000)
+```
+
+### 3.2 为什么选这个架构
+
+| 决策 | 理由 |
+|---|---|
+| **不在被压机器跑压源** | 资源争抢:loadgen 在 200-1000 goroutine 并发下吃 0.5-1 CPU、300-800MB,会污染容量数据 30-50%;localhost 走 lo 不经过物理 NIC,路径与真实用户不同;docker stats 自己也会被压慢,监控失真。**注意区分**:spec §4.2 的"1000 测试用户"是用户池规模(数据),loadgen 实际并发 goroutine 数由 RPS × 平均 RT 决定(200 RPS × 100ms RT = 20 并发 goroutine)。 |
+| **同地域 ECS** | 跨公网压会把家宽延迟/丢包混进 P99,污染结论;**压源 ECS 与 prod 同在华东 1(杭州)**,内网/同骨干 RTT 2-5ms |
+| **不绕过 gateway** | 真实业务路径就从 gateway 入,绕过测不出 gateway 自身瓶颈 |
+| **可配置监控(off/lite/full)** | 摸容量拐点时 lite 干扰最小(< 30M);定位慢查询时 full(~400M/0.3C)值得开 |
+| **方案 D 自研 Go 工具** | Go 后端团队熟,可复用 `pkg/jwt`/`pkg/types`;单二进制部署;逻辑可灵活定制(如铸造轮转) |
+
+### 3.3 工程目录结构
+
+```
+backend/scripts/loadgen/
+├── seed/
+│ ├── main.go # 入口,解析 --jwt-secret/--db-dsn/--reset
+│ ├── stars.go # INSERT star_id=999900
+│ ├── users.go # INSERT 1000 users (bcrypt 'Test@123')
+│ ├── profiles.go # INSERT fan_profiles + 充值 2200 水晶
+│ ├── slots_and_exhibits.go # INSERT booth_slots (is_enabled=true) + exhibitions
+│ ├── assets.go # INSERT 5000 assets (status=1)
+│ ├── friendships.go # INSERT 10000 friendships
+│ ├── tokens.go # 复用 pkg/jwt 预签 token,写 users.csv
+│ ├── sequences.go # 重置所有相关表的序列(CLAUDE.md 规范)
+│ ├── cleanup.go # DELETE WHERE star_id=999900(强校验)
+│ └── README.md
+├── loadgen/
+│ ├── main.go # 入口,解析 --scenario/--rps/--vus/--duration/--monitor
+│ ├── lib/
+│ │ ├── ramp.go # 阶梯调度器:[]Stage{rps, duration}
+│ │ ├── circuit.go # 6 维红线判停(详见 §7.3)
+│ │ ├── hdr.go # HdrHistogram-go 封装
+│ │ ├── csv.go # users.csv 加载到内存
+│ │ ├── client.go # http.Transport(MaxConnsPerHost=500)
+│ │ ├── ssh_metrics.go # ssh tail metrics-feed.jsonl
+│ │ └── log.go # stderr 行仪表 + 全量 events.jsonl
+│ ├── scenarios/
+│ │ ├── s1_login.go
+│ │ ├── s2_read.go
+│ │ ├── s3_like.go
+│ │ ├── s4_mint.go # 含 reset SQL 调度
+│ │ ├── s5_dashboard.go
+│ │ ├── s6_ranking.go
+│ │ └── s7_place.go
+│ ├── reporter/
+│ │ ├── json.go # raw 时序
+│ │ ├── csv.go # 聚合表
+│ │ ├── plot.go # gonum/plot 三联图
+│ │ └── markdown.go # report.md 生成
+│ ├── preflight.go # §8 7 项 sanity check
+│ └── verify.go # 压测后数据完整性校验
+├── monitor/
+│ ├── sample.sh # 被压机后台采样
+│ ├── docker-compose.monitor.yml # cAdvisor+exporters+Prom+Grafana
+│ └── grafana-dashboards/ # 4 个预置面板 JSON
+├── recover/
+│ ├── emergency-stop.sh # 一键熔断
+│ └── restore-from-backup.sh # pg_dump 还原
+└── reports/ # gitignore,跑测产出
+ └── run-YYYYMMDD-HHMMSS/
+```
+
+### 3.4 loadgen CLI 完整命令与 flag 表(终审修复 D3)
+
+> 散落在 §4.5/§5.6/§6.1/§7.5/§8.5 的命令在此处统一定义。实施时**严格按这张表**生成 CLI。
+
+| 子命令 | 用途 | flag | 默认值 |
+|---|---|---|---|
+| `loadgen seed --prod` | 在 prod 本地跑 seed | `--jwt-secret ` 必填 | - |
+| | | `--db-host ` | `localhost` |
+| | | `--db-name ` | `topfans` |
+| | | `--db-password ` 或读 `$DB_PASSWORD` | - |
+| | | `--reset` 删旧测试数据后重新 seed | false |
+| | | `--reset-tokens` 只重签 token,不动数据 | false |
+| `loadgen seed cleanup` | 清理测试数据 | `--keep-baseline` 保留 1000 testers + 资产 | false |
+| | | `--full` 全删(含 stars/users/profiles) | false |
+| `loadgen run` | 跑压测主流程 | `--scenarios ` 复数 ,逗号分隔 | - |
+| | | `--stage ` | `step` |
+| | | `--rps ` 单 RPS 模式 | - |
+| | | `--vus ` 最大 VU 数 | 自动 |
+| | | `--duration <30m>` 单阶段时长 | 按 §5.3 |
+| | | `--inter-scenario-pause <15m>` | `15m` |
+| | | `--monitor ` | `lite` |
+| | | `--prod-ssh ` | - |
+| | | `--target ` 被压 gateway 入口 | `http://101.132.250.62:8080` |
+| `loadgen preflight` | 开压前 7 项检查 | `--target ` | - |
+| | | `--prod-ssh ` | - |
+| `loadgen verify` | 压后数据完整性校验 | `--prod-ssh ` | - |
+| `loadgen report` | 从 raw data 生成 Markdown 报告 | `--input ` | - |
+| | | `--output ` | `./report.md` |
+
+**命名约定(实施时强制)**:
+- 多场景一律用 `--scenarios=S1,S2,...`(复数,逗号分隔)
+- 单场景用 `--scenarios=S4`(不再设 `--scenario` 单数 flag,避免歧义)
+
+---
+
+## 4. 测试数据准备
+
+### 4.1 数据隔离策略("压测沙盒")
+
+所有压测数据归到一个独立的 `star_id`,物理隔离真实业务:
+
+| 维度 | 隔离值 | 业务真实值 |
+|---|---|---|
+| star_id | **999900** | 87, 88, 91, 93, 94, 95(6 个) |
+| 测试手机号前缀 | **199000xxxxx**(11 位) | 13x/15x/17x... |
+| 测试 user_id 区间 | **30000001 ~ 30001000** | max=110 |
+| 测试 asset_id 起点 | **`MAX(assets.id) + 1000` 动态分配** | max=303 |
+| 测试昵称 | `loadtest_` | (任意中文/英文) |
+| 测试资产名 | `loadtest_asset__` | (任意) |
+
+### 4.2 数据规模
+
+| 资源 | 数量 | 设计依据 |
+|---|---|---|
+| 测试用户 | 1000 | 200 并发 × 5 倍余量 |
+| 每用户 crystal_balance 初始 | 2200 | ≥ 2046(10 次铸造累计)+ 缓冲 |
+| 每用户预铸资产 | 5 | asset_id 从 `MAX+1000` 起。**用途分配**:资产 1-2 用于 seed 预上架(S3 点赞依赖),资产 3-5 留作未上架库存供 S7 上架/卸下轮转使用 |
+| 每用户 booth_slots | 3(is_enabled=true) | 默认配额;slot_index=1,2 给 seed 预上架,slot_index=3 留给 S7 压测 |
+| 每用户 exhibitions | 2(用 slot 1, 2) | "可点赞资产池"(S3 依赖) |
+| 每用户测试好友 | 10 | S2/S6 部分查询走好友关系 |
+| 总 INSERT 行数 | ~25,000 | users 1k + profiles 1k + assets 5k + slots 3k + exhibits 2k + friendships 1w + stars 1 |
+
+### 4.3 Seed 执行流程
+
+```sql
+BEGIN;
+
+-- 时间戳占位:ts = extract(epoch from now())*1000 (毫秒,与项目时间戳约定一致)
+-- 实际 seed 程序中由 Go 侧 time.Now().UnixMilli() 计算
+
+-- 1. 测试明星
+INSERT INTO stars (star_id, name, identity_id, is_active, created_at, updated_at)
+VALUES (999900, 'loadtest_star', 'loadtest_star', true, ts, ts)
+ON CONFLICT (star_id) DO NOTHING;
+
+-- 2. 1000 测试用户(password_hash 用 bcrypt('Test@123') 离线生成一次复用)
+INSERT INTO users (id, mobile, password_hash, is_active, created_at, updated_at) VALUES
+ (30000001, '19900000001', '$2a$10$', true, ts, ts),
+ ...
+ON CONFLICT (id) DO NOTHING;
+
+-- 3. fan_profiles + 充值
+INSERT INTO fan_profiles (user_id, star_id, nickname, crystal_balance, slot_limit, is_active, created_at, updated_at) VALUES
+ (30000001, 999900, 'loadtest_1', 2200, 3, true, ts, ts), ...;
+
+-- 4. assets(从 MAX(id)+1000 起步,避免与真实数据撞主键)
+WITH base AS (SELECT COALESCE(MAX(id), 0) + 1000 AS start FROM assets)
+INSERT INTO assets (id, owner_uid, star_id, name, cover_url, info, status, like_count, is_active, created_at, updated_at, grade)
+SELECT start + n, owner_uid, 999900, 'loadtest_asset_'||owner_uid||'_'||idx, '', 'loadtest', 1, 0, true, ts, ts, 1
+FROM base, generate_series(1, 5000) n, ...;
+
+-- 5. booth_slots(每 user 3 个,is_enabled=true)
+INSERT INTO booth_slots (host_profile_id, user_id, star_id, slot_index, is_enabled, created_at, updated_at) ...;
+
+-- 6. exhibitions(每 user 把 asset 1,2 上架到 slot 1,2)
+INSERT INTO exhibitions (asset_id, slot_id, host_profile_id, occupier_uid, occupier_star_id, start_time, expire_at, created_at, updated_at)
+SELECT a.id, s.slot_id, fp.id, a.owner_uid, 999900, ts, ts + 4*3600*1000, ts, ts
+FROM assets a
+JOIN fan_profiles fp ON a.owner_uid=fp.user_id AND fp.star_id=999900
+JOIN booth_slots s ON s.host_profile_id=fp.id AND s.slot_index IN (1,2)
+WHERE a.star_id=999900 AND a.name LIKE 'loadtest_%';
+
+-- 7. friendships(双向;status 默认 'accepted',唯一约束 (user_id, friend_id, star_id))
+INSERT INTO friendships (user_id, friend_id, star_id, status, intimacy, created_at, updated_at)
+SELECT a.id, b.id, 999900, 'accepted', 0, ts, ts
+FROM users a, users b
+WHERE a.id BETWEEN 30000001 AND 30001000
+ AND b.id BETWEEN 30000001 AND 30001000
+ AND a.id != b.id
+ AND ((a.id - 30000000) + 1) % 10 = ((b.id - 30000000) % 10)
+ON CONFLICT (user_id, friend_id, star_id) DO NOTHING;
+-- 上述 JOIN 条件生成约 10000 行双向好友关系
+
+COMMIT;
+
+-- 8. ⚠️ CLAUDE.md 强制:重置所有相关表的序列
+SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));
+SELECT setval('fan_profiles_id_seq', (SELECT MAX(id) FROM fan_profiles));
+SELECT setval('assets_id_seq', (SELECT MAX(id) FROM assets));
+SELECT setval('booth_slots_slot_id_seq', (SELECT MAX(slot_id) FROM booth_slots));
+SELECT setval('exhibitions_id_seq', (SELECT MAX(id) FROM exhibitions));
+SELECT setval('stars_star_id_seq', (SELECT MAX(star_id) FROM stars));
+SELECT setval('asset_likes_id_seq', (SELECT COALESCE(MAX(id), 0) FROM asset_likes));
+SELECT setval('friendships_id_seq', (SELECT MAX(id) FROM friendships));
+SELECT setval('crystal_transaction_records_id_seq', (SELECT COALESCE(MAX(id), 0) FROM crystal_transaction_records));
+```
+
+### 4.4 JWT Token 预签发
+
+```go
+// backend/scripts/loadgen/seed/tokens.go
+import "github.com/topfans/backend/pkg/jwt"
+
+func GenerateTokensForLoadtest(users []TestUser, jwtSecret string) error {
+ jwt.SetSecret(jwtSecret) // 从命令行参数注入
+
+ csvFile, _ := os.Create("users.csv")
+ defer csvFile.Close()
+ writer := csv.NewWriter(csvFile)
+ writer.Write([]string{"phone", "password", "user_id", "star_id", "jwt_token", "asset_ids", "exhibition_ids"})
+
+ for _, u := range users {
+ token, err := jwt.GenerateToken(u.UserID, 999900, time.Now().UnixMilli())
+ if err != nil { return err }
+ writer.Write([]string{
+ u.Mobile, "Test@123",
+ strconv.FormatInt(u.UserID, 10),
+ "999900", token,
+ joinInt64Slice(u.AssetIDs, ";"), // 工具函数:strings.Builder + strconv.FormatInt
+ joinInt64Slice(u.ExhibitionIDs, ";"),
+ })
+ }
+ return writer.Flush()
+}
+```
+
+⚠️ **`users.csv` 加入 `.gitignore`**,包含 token 不能 commit。
+
+⚠️ **``** 需要替换为 OSS 上真实存在的占位图 URL(seed 执行前先上传一张 `loadtest-placeholder.png` 到 OSS 并填入)。**上传方式**:用项目现有接口 `POST /api/v1/assets/oss/upload-signature` 获取签名 → PUT 文件到 OSS 的 `loadtest/loadtest-placeholder.png` 路径 → 拷贝返回的可访问 URL 填入 seed 工具的 `LOADTEST_PLACEHOLDER_URL` 常量。
+
+### 4.5 数据清理策略
+
+```bash
+# 删压测产生的写入(保留 1000 个测试账号 + 资产,下次复用)
+loadgen seed cleanup --keep-baseline
+
+# 全删(包括账号本身)
+loadgen seed cleanup --full
+```
+
+**cleanup 安全校验**:
+
+```go
+const LoadtestStarID = 999900
+
+func cleanup(db *sql.DB, starID int64) error {
+ if starID != LoadtestStarID {
+ return errors.New("safety: cleanup only accepts loadtest star_id 999900")
+ }
+ queries := []string{
+ "DELETE FROM asset_likes WHERE star_id = $1",
+ "DELETE FROM exhibitions USING fan_profiles fp WHERE exhibitions.host_profile_id=fp.id AND fp.star_id = $1",
+ "DELETE FROM booth_slots WHERE star_id = $1",
+ "DELETE FROM mint_orders WHERE star_id = $1",
+ "DELETE FROM crystal_transaction_records WHERE star_id = $1",
+ // assets / fan_profiles / users / stars: 按 --keep-baseline / --full 决定
+ }
+ for _, q := range queries {
+ if _, err := db.Exec(q, starID); err != nil { return err }
+ }
+ return nil
+}
+```
+
+---
+
+## 5. 场景设计与 RPS 梯度
+
+### 5.1 7 个场景一览
+
+| ID | 场景 | 接口 | 依赖 | 预期瓶颈 | 基线 P95 目标 |
+|---|---|---|---|---|---|
+| S1 | 登录+鉴权 | `POST /auth/login` + `GET /me/profile` | mobile + password | UserService bcrypt 验密 / PG users 查询 / JWT 签发 CPU | < 200ms |
+| S2 | 资产读 | `GET /assets/me/items?page=1` + `GET /assets/:id` 随机 | 预签 token + asset_ids | PG `idx_assets_owner_star` 索引 / Gateway→Dubbo→Asset 三跳 | < 100ms |
+| S3 | 点赞 | `POST /social/assets/:id/like` + `DELETE` | 必须用 seed 上架的 asset_id(依附 exhibition) | PG `asset_likes` 唯一约束 / `assets.like_count` 行锁 | < 150ms |
+| S4 | 资产铸造 | `POST /assets/mints/precreate` → `POST /assets/mints` | 水晶 ≥ cost + 未达 10 次硬上限 | PG 事务(扣余额+写订单) / `crystal_transaction_records` 流水 | < 500ms |
+| S5 | 数据看板 | 7 个 `GET /dashboard/*` 串行 | 当前用户 token | PG 物化视图 / 多表 JOIN / Statistic 服务 | < 800ms(用户会话端到端)|
+| S6 | 多维榜单 | `GET /rankings/{hot,original}?dimension={displaying,month,total}&star_id={87,88,93,999900}` | 24 种参数组合循环(2 接口 × 3 dimension × 4 star_id) | PG 排序+LIMIT / Asset 服务 Redis 缓存命中率 | < 250ms |
+| S7 | 上架展位 | `POST /galleries/place` + `DELETE /galleries/slots/:slot_id/asset` | slot_index=3 留作压测槽位 | PG `exhibitions.uk_asset` 唯一 / booth_slots 状态机 | < 200ms |
+
+### 5.2 度量定义(避免歧义)
+
+**RPS 在本文档中的统一含义**:除非特别说明,"RPS" = **后端请求/秒(backend QPS)**,即 loadgen 实际发出的 HTTP 请求计数。
+
+**S5 看板场景特例**:S5 一次"用户会话" = 7 个 `/dashboard/*` 串行请求。S5 的 RPS 阶梯(§5.3)以**用户会话视角**给出,对应后端 QPS = 阶梯值 × 7。例如 S5 阶梯 "20 RPS" 表示**20 个并发用户会话/秒,对应 140 backend QPS**。S5 的 P95/P99 也按"用户会话端到端"度量(即 7 个串行请求的总耗时)。
+
+**S6 榜单场景特例**:S6 一次"用户行为" = 1 次请求(不串行)。S6 的 RPS = 后端 QPS。
+
+**所有红线判定基于"后端 QPS 视角"**:§7.3 R1/R2/R3 的错误率和延迟红线统计的是 events.jsonl 单请求事件,**不分场景类型一律按原子请求计**。这意味着 S5 在 20 RPS(会话)= 140 QPS 时如果有 1.4 QPS 错误(5%),R1 触发。
+
+### 5.3 4 阶段 RPS 梯度
+
+> **⚠️ 两轮拆分(P0-1 修复)**:本节描述 4 阶段**完整 cycle 模型**。**第一轮(探索)只跑阶段 1+2**(baseline + step,225 min),目标是找到拐点;**阶段 3 稳定性 + 阶段 4 破坏性 + §5.5 混合场景全部推到第二轮(验证)**。第二轮窗口独立计算(详见 §6.3)。
+>
+> | 阶段 | 第一轮 | 第二轮 |
+> |---|---|---|
+> | 1. Baseline | ✅ 跑 | (已有数据,跳过) |
+> | 2. Step Ramp-up | ✅ 跑 | (已有拐点,跳过;如修复后想对比,可重跑) |
+> | 3. Soak(30min × N) | ❌ 不跑 | ✅ 跑(在已知拐点的 60% 安全水位) |
+> | 4. Stress(5min × N) | ❌ 不跑 | ✅ 跑(拐点 × 2-3 倍) |
+> | 混合场景 | ❌ 不跑 | ✅ 跑(按数据驱动比例) |
+
+#### 阶段 1 · 基线 (Baseline)
+- 目标:拿到"无并发干扰"的 P50/P95/P99
+- 执行:每场景 1 VU × 1 RPS × 3 min
+- 产出:`baseline.csv`
+
+#### 阶段 2 · 容量阶梯 (Step Ramp-up) ← 找拐点
+- 目标:每场景独立 6 阶爬升,找到错误率/P99 突破阈值的 RPS
+- 每阶 2 分钟
+
+**每场景独立的 RPS 阶梯**:
+
+| 场景 | RPS 阶梯(每阶 2min) | 预估拐点 |
+|---|---|---|
+| S1 登录 | 2 → 5 → 10 → 15 → 25 → 40 | ~15 |
+| S2 资产读 | 20 → 50 → 100 → 200 → 400 → 700 | ~250 |
+| S3 点赞 | 5 → 10 → 20 → 40 → 80 → 150 | ~50 |
+| S4 铸造 | 5 → 10 → 20 → 30 → 50 → 80 | ~30 |
+| S5 看板 | 2 → 5 → 10 → 20 → 35 → 60 | ~20 |
+| S6 榜单 | 20 → 50 → 100 → 200 → 400 → 700 | ~300 |
+| S7 上架 | 5 → 10 → 20 → 40 → 80 → 150 | ~50 |
+
+每场景跑到红线(错误率>5% 持续 30s 或 P99>3s 持续 30s)自动停止。
+
+#### 阶段 3 · 稳定性 (Soak) ← 找泄漏
+- 目标:取阶段 2 拐点的 60% 作为"安全水位",跑 30 分钟
+- 关注:goroutine / 内存增长 / PG connections / 慢查询累积
+- S1/S2/S3/S5/S6/S7 各跑一次 30min;S4 见 §5.4
+
+#### 阶段 4 · 破坏性 (Stress) ← 验证降级
+- 目标:拐点 × 2-3 倍,跑 5 分钟
+- 观察:Gateway 503 / Dubbo 超时熔断 / PG 连接拒绝 / OOM Kill / 撤压后恢复时间
+
+### 5.4 铸造场景特殊处理:每阶 reset 轮转
+
+铸造单用户硬上限 10 次(mint_cost_config 只有 10 行):1000 用户共 10,000 次配额。
+
+**为什么不能裸跑阶梯**(B1 自审发现):
+
+S4 阶梯 5→10→20→30→50→80 RPS(每阶 120s),累计请求量 = (5+10+20+30+50+80) × 120 = **23,400 次** >> 10,000 配额。
+
+如果不每阶 reset,第 50 RPS 阶(累计 8400 次 + 120s × 50 = 14,400 次)就会大面积"用户已达 10 次"业务报错;80 RPS 阶(累计 16,800 次起)几乎 100% 失败 → **R3 5xx 红线触发,但实际不是系统拐点,是配额耗尽**,拐点测不出来。
+
+**正确节奏(每阶单独 reset)**:
+
+```
+S4 阶梯每阶 = "压 120s → 暂停 → reset → 下一阶"
+
+阶段 1: 5 RPS × 120s = 600 req → reset
+阶段 2: 10 RPS × 120s = 1200 req → reset
+阶段 3: 20 RPS × 120s = 2400 req → reset
+阶段 4: 30 RPS × 120s = 3600 req → reset
+阶段 5: 50 RPS × 120s = 6000 req → reset
+阶段 6: 80 RPS × 120s = 9600 req → reset
+(每阶都在 10000 配额内,余量充足)
+```
+
+**稳定性阶段同样轮转**(取 50 RPS 安全水位):
+
+```
+[T+0] 开始压 50 RPS
+[T+200s] loadgen 暂停(1000 用户 × 10 次 = 10000 / 50 RPS = 200s)
+[T+201s] ssh prod 执行 reset SQL(约 5-10s)
+[T+210s] 继续下一轮
+...
+循环 9 轮 ≈ 30 分钟
+```
+
+**Reset SQL(P0-2 修复:包装成可执行脚本 + PGPASSWORD)**:
+
+`scenarios/s4_mint.go` 在每阶/每轮结束后**通过 ssh 远程触发 prod 上的 `mint_reset.sh`**:
+
+```bash
+# /opt/topfans/loadtest/scripts/mint_reset.sh(部署到 prod)
+#!/bin/bash
+set -e
+
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
+export PGPASSWORD="${DB_PASSWORD:-postgres123}"
+
+docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans <<'EOF'
+BEGIN;
+DELETE FROM user_mint_count WHERE star_id = 999900;
+DELETE FROM mint_orders WHERE star_id = 999900;
+UPDATE fan_profiles SET crystal_balance = 2200 WHERE star_id = 999900;
+DELETE FROM crystal_transaction_records WHERE star_id = 999900;
+DELETE FROM assets WHERE star_id = 999900 AND name LIKE 'loadtest_mint_%';
+COMMIT;
+-- 序列不重置(mint_orders 主键是 UUID;assets 新建的会被上面 DELETE 清掉,序列空洞 OK)
+EOF
+echo "✅ mint reset 完成"
+```
+
+`scenarios/s4_mint.go` 调用方式(伪代码):
+```go
+func resetMintData() error {
+ cmd := exec.Command("ssh", prodSSH, "bash /opt/topfans/loadtest/scripts/mint_reset.sh")
+ return cmd.Run()
+}
+```
+
+**铸造场景资产命名约定(重要)**:
+
+S4 压测调用 `POST /assets/mints` 时,**请求 body 的 `name` 字段必须以 `loadtest_mint_` 开头**(如 `loadtest_mint_30000001_round3_42`),这样 reset SQL 的 `name LIKE 'loadtest_mint_%'` 才能精准清理,不会误删 seed 阶段预铸的 `loadtest_asset_` 资产。
+
+`scenarios/s4_mint.go` 构造请求时强制按此前缀生成 name。
+
+### 5.5 不做的(YAGNI)
+
+- **混合场景**:第一轮不做。理由:早期项目业务比例还没成型,凭直觉给比例会产出"看似科学但实际是猜的"数字。等单场景拐点出齐,按业务相对调用频率回推 DAU 上限。
+- **WebSocket(AI Chat)**:独立的长连接压力模型,本轮不压。
+- **活动榜单 / 星册接口**:聚焦核心 7 场景。
+
+### 5.6 场景间隔离缓冲与总时长(B8 自审修复)
+
+每个场景跑完不能立刻切下一个。必须等:
+
+| 缓冲项 | 最少耗时 | 原因 |
+|---|---|---|
+| TIME_WAIT 释放 | 120-240s | Linux `tcp_fin_timeout=60`,200 VU 的 TIME_WAIT 堆积要分钟级散去;不等会让下一场景压力机连接抖动 |
+| 连接池回收 | 60-120s | Dubbo client / Go http.Transport idle conn + PG `idle_in_transaction_session_timeout`;前场景占用的 PG 连接要释放完 |
+| Redis 缓存散热 | 60-300s | 前场景预热的热点 key 退场;不等会让下一场景"缓存命中率虚高" |
+| PG WAL 消化 | 30-60s | 前场景的写操作 WAL 还在异步落盘 |
+| cleanup SQL | 30-60s | S4 还要 §5.4 reset |
+| 人眼判断 / 短报告 | 5-10min | 看本场景是否需要进入下一场景,还是要重跑 |
+
+**结论**:每场景之间至少 **8-12 min 缓冲**。
+
+修正后的第一轮总时长(终审修复,明细可对账):
+
+| 阶段 | 单次耗时 | 次数 | 小计 | 累计 |
+|---|---|---|---|---|
+| 开场(seed + monitor 启动) | 2 min | 1 | 2 min | 2 min |
+| Baseline(每场景 1 RPS) | 3 min | 7 场景 | 21 min | 23 min |
+| Baseline 场景间短 buffer | 1 min | 6 间隔 | 6 min | 29 min |
+| 阶梯(6 阶 × 2min/阶) | 12 min | 7 场景 | 84 min | 113 min |
+| 阶梯场景间长 buffer | 15 min | 6 间隔 | 90 min | 203 min |
+| 收尾(cleanup + verify + 释放) | 22 min | 1 | 22 min | 225 min |
+| **合计** | | | | **225 min = 3h 45min** |
+
+→ §6.1 第一轮窗口:**02:00-06:00**(含 preflight 在 01:30-02:00 之外)。第二轮稳定性 + 破坏性约 4 小时(含混合场景)。
+
+`loadgen run --scenarios=S1,S2,S3,S4,S5,S6,S7 --inter-scenario-pause=15m` 自动在场景间插入 pause。
+
+---
+
+## 6. 执行计划与时间盒
+
+### 6.1 第一轮(探索压测)
+
+#### Day 1:环境与数据准备(白天,6-7 小时,分两个时段)
+
+| 时段 | 动作 |
+|---|---|
+| 上午 1h | ssh prod 跑 `\d+ users fan_profiles assets asset_likes exhibitions booth_slots mint_cost_config` 对照本地,生成 `prod-vs-local-schema-diff.md` |
+| 上午 2h | 编写 `seed/` Go 工具,本地 docker-compose 联调跑通 |
+| **下午 14:00-14:05(业务低峰,P0-5)** | **应用 §2.1 方案 A(必做)**:改 `docker-compose.prod.yml` 把 `POSTGRES_MAX_CONNECTIONS` 从 100 改到 50,`docker-compose -f docker-compose.prod.yml restart postgres`(约 30s 停机)。验证:`docker exec ... psql -c "SHOW max_connections;"` 返回 50 |
+| 下午 1h | 阿里云控制台开 ECS(**华东 1 杭州 同地域**,4G/2C 按量付费) |
+| 下午 1h | 编译 `seed` + `loadgen` 二进制;scp 到压力机 + scp seed 二进制和 `mint_reset.sh` 到 prod `/opt/topfans/loadtest/` |
+| **晚上 23:00(次低峰)** | **预演 dry run**:用 `--monitor=off` 跑 5 min mini baseline(10 RPS × 1min × 7 场景),验证 preflight/seed/cleanup 链路通畅 |
+
+#### Day 1 当晚 / 02:00 - 06:00(即 Day 2 凌晨):第一次正式压测窗口
+
+> **P0-3 对账:本表逐分钟与 §5.6 总时长表对账**。02:00 开场到 05:45 收尾结束 = 225 min;06:00 为余量缓冲(容纳意外延迟、监控停止、scp 报告等收尾动作)。
+
+| 时间 | 持续 | 动作 |
+|---|---|---|
+| 01:30 | 25 min | preflight 检查(详见 §8.5;含 §8.2 防线 1 的 7 项 + max_connections=50 验证) |
+| 01:55 | 5 min | pg_dump 备份完成(pg_dump 跑了 ~3 min,加 buffer) |
+| 02:00 | 1 min | ssh prod 跑 `loadgen seed --prod` |
+| 02:01 | 1 min | ssh prod 启动 `monitor.sh` 后台采样 |
+| 02:02 | 21 min | 压力机跑 **baseline**(每场景 1 RPS × 3min,7 场景,**场景间 1min 短 buffer**) |
+| 02:29 | 84 min | 压力机跑 **step 阶梯**(每场景 6 阶 × 2min,7 场景) |
+| 02:29-05:35 | +90 min | **场景间 15min 长 buffer × 6 间隔**(与阶梯交错执行;阶梯第 1 场景跑完 12min → 15min buffer → 第 2 场景 → ... → 第 7 场景结束) |
+| 05:35 | 5 min | ssh prod 停 monitor.sh + 收尾监控 |
+| 05:40 | 5 min | `loadgen seed cleanup --keep-baseline` |
+| 05:45 | 5 min | `loadgen verify`(详见 §8.5) |
+| 05:50 | 10 min | 压力机拉回报告目录 + 释放 ECS(如 1 周内不再压) |
+| **06:00** | — | **窗口结束,prod 完全可用** |
+
+> **加和验证**:开场 2 + baseline 27 + step+buffer 174(84+90 交错) + 收尾 25 = **228 min ≈ §5.6 表 225 min**(3 min 容差在 §6.5 允许内)。窗口实际 240 min,留 12 min 应急余量。
+
+#### Day 3:分析与决策
+
+- 读取 `report-round1.md`
+- Review 会议:哪些瓶颈是**配置可修**(bcrypt cost、PG max_connections、连接池),哪些是**代码必改**(N+1、缺索引、锁粒度),哪些**接受现状**
+
+### 6.2 修复期(Day 3 - Day 14)
+
+如有必要,按 review 结论改代码:调 bcrypt cost、加索引、加缓存、调 Dubbo 超时。每个修复**打 tag 部署一次**便于第二轮对比。
+
+### 6.3 第二轮(验证压测)
+
+**触发条件**:第一轮报告交付 + 团队 review 后,**决定是否修复瓶颈**:
+- 如果第一轮拐点已经满足业务预期 → **第二轮可跳过**
+- 如果改了代码/配置 → 第二轮只压**改动影响的场景**
+
+**第二轮内容**(详见 §5.3 拆分表):
+- 阶段 3 稳定性:30min × 修改了的场景
+- 阶段 4 破坏性:5min × 修改了的场景
+- 混合场景:15min(按 §5.5 数据驱动的比例)
+
+**第二轮时长估算**(修改了 N 个场景的情况):
+- 稳定性:30min × N + 15min × (N-1) 缓冲
+- 破坏性:5min × N + 5min × (N-1) 缓冲
+- 混合:15min + 30min 收尾
+- N=3 时约 **3h**;N=7(全部场景)时约 **6.5h**(要拆 2 个凌晨窗口)
+
+仍在凌晨 02:00-06:00 窗口(必要时第二天 02:00-06:00 续)。
+
+⚠️ **JWT 重签(终审修复 C1)**:`pkg/jwt` 的 `TokenExpiration = 7 * 24 * time.Hour`(7 天)。第一轮 seed 时签的 token 7 天后过期。如第二轮在第一轮 7 天后才跑,必须**第二轮开压前 30 min 重跑**:
+
+```bash
+# prod 服务器上
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+loadgen seed --reset-tokens --jwt-secret="${JWT_SECRET}" --db-host=localhost --db-name=topfans
+# --reset-tokens 仅重新签发 users.csv 里 1000 个 token,不动数据
+```
+
+### 6.4 时间承诺
+
+- 第一轮端到端:从今天起 **5 个工作日内**完成报告
+- 第二轮端到端(如触发):第一轮报告交付后 **2 周内**
+
+### 6.5 中止/回退原则
+
+| 情况 | 动作 |
+|---|---|
+| 压测期间 prod 错误率持续 > 10% | loadgen 自动 SIGINT,ssh 跑 `emergency-stop.sh` |
+| 监控仪表提示磁盘满(< 5GB) | 立即 stop |
+| 用户半夜投诉 | 立即 stop,第二天 review 是否窗口选错 |
+| 任何阶段 reset SQL 失败 | 不进入下一阶段,保留现场 |
+| preflight 任何一项 fail | 拒绝启动主压测 |
+
+---
+
+## 7. 监控指标与判停红线
+
+### 7.1 监控分层(4 个采集源 × 3 个聚合粒度)
+
+```
+压力机侧(loadgen 自身)
+ ├ 单请求级:start_ts/end_ts/rt_ms/status/scenario/vu_id → events.jsonl
+ ├ 10 秒滑窗:actual_rps/p50/p95/p99/error_rate → 10s.csv + stderr
+ └ 全局 HDR 直方图:每场景 .bin 文件,事后还原任意百分位
+
+被压机侧 · monitor.sh(lite 模式,默认)
+ ├ docker stats(每 5s,所有容器 CPU/MEM/NET/IO)
+ ├ pg_stat_activity(每 5s)
+ ├ pg_stat_statements top 10(每 30s)
+ ├ redis INFO(每 5s)
+ └ uptime + df -h(每 5s)
+
+被压机侧 · Prometheus(full 模式,可选)
+ ├ cadvisor(容器维度,scrape 5s)
+ ├ node-exporter(OS 层)
+ ├ postgres-exporter(连接/lock/cache hit/replication)
+ └ redis-exporter(内存/命令/客户端)
+
+业务侧探针(可选)
+ └ Gateway /health 每 1s 探活(外部 SLA 视角)
+```
+
+### 7.2 实时仪表盘(stderr 行模式)
+
+每秒一行:
+
+```
+[02:15:34] S3 like | target= 50 actual= 48.3 | p50=42 p95=180 p99=520 | err=0.2% | vu=5 conn_err=0
+```
+
+每分钟汇总:
+
+```
+═══════════════════════════════════════════════════════════════
+[02:16:00] SCENARIO=S3 STAGE=stage3-20rps ELAPSED=01:58
+ Requests: 5,820 (97.0/s avg)
+ Errors: 12 (0.2%)
+ Latency p50: 42ms p95: 198ms p99: 612ms max: 2.1s
+ Connection: active=5 refused=0 timeout=0
+ Status: 2xx=99.8% 4xx=0.1% 5xx=0.1%
+═══════════════════════════════════════════════════════════════
+```
+
+### 7.3 6 维红线(任一触发即停)
+
+| # | 红线 | 触发条件 | 数据源 | 周期 |
+|---|---|---|---|---|
+| R1 | 客户端错误率 | `error_rate > 5%` 持续 30s | loadgen 滑窗 | 5s |
+| R2 | 客户端 P99 | `p99 > 3000ms` 持续 30s | loadgen HDR | 5s |
+| R3 | 5xx 比例 | `5xx_rate > 10%` 持续 10s | loadgen status | 5s |
+| R4 | PG 连接数 | `pg_active > 85`(max 100 的 85%) | monitor.sh + pg_stat_activity | 5s |
+| R5 | 磁盘空间 | `disk_free < 5GB`(B9 修复:原 2GB 太低,PG WAL 在写密集场景会快速膨胀 2-3GB) | monitor.sh + df -h | 30s |
+| R6 | 容器 OOM | `docker events --filter event=oom` 收到事件 **或** `docker inspect --format='{{.State.OOMKilled}}'` 返回 true **或** 容器 RestartCount 较基线增加 | monitor.sh + docker events 流式订阅 | 1s(事件触发)|
+
+⚠️ **不要用 `ExitCode=137` 检测 OOM**(自审 B5):
+- 137 = 128 + SIGKILL,**`docker stop` 超时也会触发**,无法区分 OOM/手动 kill
+- `restart: always` 把容器自动拉起后,`docker inspect` 看到的是新实例的 ExitCode=0,**OOM 痕迹消失**
+- 正确做法:`docker events --filter event=oom` 提供实时事件流;`State.OOMKilled` 字段是 docker daemon 直接记录的 OOM 标志
+
+`lib/circuit.go` 主循环每 5s 检查;触发后向主进程发 SIGINT,loadgen 优雅退出。
+
+### 7.4 Grafana 4 面板(--monitor=full)
+
+| 面板 | 内容 |
+|---|---|
+| 1. 整机概览 | CPU / Memory / Network / Disk IO 时序 + load1/5/15 |
+| 2. 容器维度 | 每容器 CPU% / Memory 对比限额 + 重启次数(捕捉 OOM) |
+| 3. PG 健康 | active connections vs max / longest transaction / cache hit ratio / locks / WAL 增长 |
+| 4. 业务指标 | loadgen RPS / Error / P99(Prometheus 从 loadgen `:9091/metrics` pull) |
+
+loadgen 暴露 `:9091/metrics`,被 Prometheus pull。
+
+### 7.5 报告生成(事后)
+
+```bash
+loadgen report --input ./reports/run-20260613-0200/ --output ./report.md
+```
+
+自动:
+- 从 `*.hdr` 还原任意百分位(**注**:HdrHistogram precision=3 时 P99 以下可信,**P99.9+ 误差 10-30%**,仅作参考;如需准确 P99.9,preflight 阶段将 precision 提到 4-5 位但内存 4×)
+- `gonum/plot` 画每场景 RPS-Latency-Error 三联图(SVG)
+- 表格列出每场景拐点 RPS
+- 摘录 prod 监控的高负载片段
+- 自动写"建议"节:哪些场景接近瓶颈、哪些有空间
+
+---
+
+## 8. 风险控制与回滚
+
+### 8.1 崩溃形态与恢复时间矩阵
+
+| # | 形态 | 概率 | 影响 | 自动恢复 | 恢复时间 | 数据风险 |
+|---|---|---|---|---|---|---|
+| 1 | 容器 OOM Killed | 🔴 高 | 单服务短时不可用 | ✅ `restart: always` | 10-30s | 无 |
+| 2 | PG 连接打满 100 | 🟡 中 | 整站新请求拒绝 | ✅ idle 释放 | 停压后 30s | 无 |
+| 3 | PG 慢查询雪崩 / 死锁 | 🟡 中 | 整站 hang | ⚠️ 半自动 | 30-90s | 无 |
+| 4 | TIME_WAIT 端口耗尽 | 🟡 中 | 新连接失败 | ✅ 60s 释放 | 60s | 无 |
+| 5 | 磁盘满 | 🟢 低 | 整站 5xx | ❌ 手动 rm logs | 2-5min | 极小 |
+| 6 | 整机 OOM(PG 进程被 cgroup kill) | 🟡 中(**原 🟢 错算,§2.1 PG 400M/100conn 冲突让概率提高**) | 整站宕机 | ✅ + WAL replay | 60-120s | 无 |
+| 7 | 数据污染真实数据 | 🔴 极低 | 业务异常 | ❌ 备份还原 | 5-10min | **中-高** |
+| 8 | 阿里云硬件故障 | ⚪ 极罕 | 数据丢失 | ❌ 快照还原 | 30min-1h | 高 |
+| 9 | **压力机自身 GC 暴涨**(B9 新增) | 🟡 中 | P99 数据失真,记的延迟里有 30-50% 是 loadgen 自己 | ❌ 需重测 | 重测整轮 | 无(但结果作废) |
+| 10 | **PG WAL 写满**(B9 新增) | 🟡 中(200 VU × 30min 点赞写 5w 行) | 整库变只读 | ❌ checkpoint + rm 旧 WAL | 5-15min | 无 |
+| 11 | **Docker bridge NAT 表满**(B9 新增) | 🟢 低(200 VU × 30min × 无 keep-alive ≈ 36w NAT 条目) | 新连接失败 | ✅ keep-alive 缓解 | 立即(开 keep-alive) | 无 |
+| 12 | **PG 锁等待雪崩**(B9 新增) | 🟡 中(S3 点赞行锁 + S7 唯一约束) | 假装连接耗尽,根因是锁 | ❌ pg_terminate_backend 杀长事务 | 30-90s | 无 |
+| 13 | **SSH tunnel 断开监控失效**(B9 新增) | 🟡 中(凌晨阿里云抖动) | R4/R5/R6 静默失效 | ⚠️ autossh 自愈 | 5-30s | 无 |
+
+**新增 R7 红线(压源自检)**:
+
+| R7 | 压源自身延迟漂移 | loadgen 内部计算 `client_p99_自测 > 0.3 × target_p99` 持续 30s | loadgen 内部钩子 | 5s | 触发说明压力机已成为瓶颈,数据不可信,需重测或上分布式压源 |
+
+### 8.2 三道熔断防线
+
+**防线 1:压测前必做(不做就不开压)**
+
+> **⚠️ 容器名注意(B7 自审修复)**:以下命令中的 `topfans-postgres` 来自 `docker-compose.prod.yml:41` 的 `container_name:` 字段,但实际启动后名字可能因 docker-compose 版本/项目目录前缀不同而异(如 `topfans_postgres_1`)。**所有脚本应用动态查找代替硬编码**:
+> ```bash
+> PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+> ```
+> 下文示例为可读性保留 `topfans-postgres` 名称,**实施时统一替换为 `$PG_CONTAINER`**。
+
+> **⚠️ PGPASSWORD 注入(终审修复)**:`docker exec ... psql -U postgres` 在容器内仍走 `pg_hba.conf` 鉴权,prod 配置默认要求密码(`.env.prod` 里 `DB_PASSWORD=postgres123`)。所有 psql 命令前必须 `export PGPASSWORD`,否则鉴权失败。
+
+```bash
+# 脚本头部统一注入(防线 1 / restore / snapshot / reset SQL 共用)
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
+export PGPASSWORD="${DB_PASSWORD:-postgres123}" # 与 .env.prod 一致
+
+# 数据库逻辑备份
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" pg_dump -U postgres -d topfans \
+ -f /opt/topfans/backups/pre-loadtest-$(date +%Y%m%d-%H%M).sql
+
+# 阿里云控制台拍快照(5-10 分钟,手动操作)
+# ECS → 实例详情 → 云盘 → 创建快照
+
+# 磁盘检查
+df -h | grep -E "/$|/opt" # 需要 ≥ 15GB 空闲(终审修复:原 10GB 在 30min soak 场景 WAL 膨胀下余量不够)
+
+# PG 连接基线
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans \
+ -c "SELECT count(*) FROM pg_stat_activity;"
+
+# PG 内存约束自检(B4 自审修复 + 终审 #6 澄清)
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans \
+ -c "SHOW max_connections;"
+# 如果显示 100 但容器 limit=400M,preflight 报错并退出,提示运维手动跑 §2.1 方案 A
+```
+
+**防线 2:压测中自动熔断**
+
+loadgen 6 维红线(详见 §7.3)+ monitor.sh 旁路监控。任一红线触发,loadgen 主进程收 SIGINT 优雅退出。
+
+**防线 3:手动一键灭火(`emergency-stop.sh`)**
+
+```bash
+#!/bin/bash
+# /opt/topfans/loadtest/recover/emergency-stop.sh
+
+pkill -9 loadgen 2>/dev/null
+
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+export PGPASSWORD="${DB_PASSWORD:-postgres123}"
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -c "
+ SELECT pg_terminate_backend(pid) FROM pg_stat_activity
+ WHERE state != 'idle' AND now() - query_start > interval '10 seconds'
+ AND usename = 'postgres';
+"
+
+cd /opt/topfans/docker
+docker-compose -f docker-compose.prod.yml restart
+
+sleep 30
+curl -f http://localhost:8080/health || echo "⚠️ Gateway 仍未恢复"
+```
+
+### 8.3 备份兜底(唯一不可恢复路径的保险)
+
+```bash
+# /opt/topfans/loadtest/recover/restore-from-backup.sh
+# 用法:bash restore-from-backup.sh /opt/topfans/backups/pre-loadtest-20260613-0200.sql
+
+BACKUP_FILE=$1
+[ -f "$BACKUP_FILE" ] || { echo "备份文件不存在"; exit 1; }
+
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+export PGPASSWORD="${DB_PASSWORD:-postgres123}"
+
+# 停所有应用层(动态查找 topfans 相关容器)
+docker ps --filter 'name=topfans-' --format '{{.Names}}' \
+ | grep -v postgres | grep -v redis | xargs -r docker stop
+
+# 删库重建
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "DROP DATABASE topfans;"
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "CREATE DATABASE topfans;"
+
+# 还原
+docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans < "$BACKUP_FILE"
+
+# 启动
+cd /opt/topfans/docker
+docker-compose -f docker-compose.prod.yml --profile prod up -d
+
+sleep 30
+curl http://localhost:8080/health
+```
+
+实测还原时间:~150MB DB → **5-8 分钟**。
+
+### 8.4 测试数据污染的额外保护
+
+| 层 | 机制 |
+|---|---|
+| 1 隔离 star_id | 所有写入强制带 `star_id=999900`,与真实业务 star_id=87~95 物理隔离 |
+| 2 cleanup 强制 WHERE | 所有清理 SQL 必须含 `WHERE star_id=999900`,代码层 reject 没有 WHERE 的语句 |
+| 3 本地完整跑一遍 | 用 docker-compose.local.yml 跑一次 seed + 压测 + cleanup,验证不影响 star_id≠999900 数据 |
+
+### 8.5 preflight 与 verify
+
+#### Preflight(开压前自动检查)
+
+```
+loadgen preflight --target http://101.132.250.62:8080 --prod-ssh root@101.132.250.62
+```
+
+7 项检查:
+
+```
+✓ ① Gateway /health 返回 200
+✓ ② SSH 到 prod 成功
+✓ ③ pg_dump 备份文件存在 (size > 50MB)
+✓ ④ 阿里云快照在 24h 内创建(人工确认或 ECS API 检查)
+✓ ⑤ prod 磁盘空闲 > 10GB
+✓ ⑥ users.csv 加载 OK,1000 行
+✓ ⑦ JWT_SECRET 与 prod 一致(用 1 个 token 调 /me/profile 验证返回 200)
+─────────────────────────────────────────
+ALL CHECKS PASSED — 可以开压
+```
+
+任一项 fail,loadgen 拒绝启动主压测。
+
+#### Verify(压测后自动验证)
+
+```
+loadgen verify --prod-ssh root@101.132.250.62
+```
+
+检查:
+
+- diff pre-snapshot vs post-snapshot(真实用户数据未变)
+- `SELECT count(*) FROM mint_orders WHERE star_id != 999900 AND created_at > <压测开始时间>` == 0
+- PG 连接数已回落到基线
+- 所有容器 Restart Count 未增加(除非压崩)
+- 磁盘空闲恢复(cleanup 后日志已清)
+
+任一项 fail → 走 emergency-stop + restore。
+
+### 8.6 prod 状态打点
+
+```bash
+# pre-test-snapshot.sh / post-test-snapshot.sh
+PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
+export PGPASSWORD="${DB_PASSWORD:-postgres123}"
+docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -c "
+ SELECT
+ (SELECT count(*) FROM users) AS users,
+ (SELECT count(*) FROM assets) AS assets,
+ (SELECT count(*) FROM asset_likes) AS likes,
+ (SELECT count(*) FROM mint_orders) AS mints,
+ (SELECT sum(crystal_balance) FROM fan_profiles WHERE star_id != 999900) AS real_user_crystal_total,
+ NOW() AS snapshot_at;
+"
+```
+
+**`real_user_crystal_total` 若变化 = 数据污染告警**。
+
+### 8.7 临时关闭外部告警(如有)
+
+```bash
+# D6 自审修复:项目当前未确认是否接入告警系统,本节为条件式
+# 1. 检查是否有 webhook 配置
+grep -rEn "webhook|alert|dingding|wechat|feishu|lark|slack" \
+ /opt/topfans/docker/.env.prod /opt/topfans/docker/docker-compose.prod.yml 2>/dev/null
+
+# 2a. 如果有 webhook,临时改为本地黑洞地址
+# 例如 sed -i.bak 's|ALERT_WEBHOOK=.*|ALERT_WEBHOOK=http://127.0.0.1:9999/null|' .env.prod
+# 完成后 systemctl 或 docker-compose restart 让配置生效
+# 压测结束后 mv .env.prod.bak .env.prod 恢复
+
+# 2b. 如果 grep 没有命中(项目当前状态),本节跳过
+
+# 3. 通知运维:今晚 02:00-06:00 压测,告警别理
+```
+
+### 8.8 不可触碰的红线(人工铁律)
+
+| 红线 | 处置 |
+|---|---|
+| 没 pg_dump 就不开压 | preflight 检查 |
+| 白天/工作时段不开压 | 时间窗口 02:00-06:00 |
+| cleanup SQL 没有 `WHERE star_id=999900` 不执行 | seed/cleanup 代码层强校验 |
+| 压测期间发现真实用户投诉立刻停 | emergency-stop |
+| 第一轮没出报告前不开第二轮 | 防止盲压 |
+
+### 8.9 关于 IP 白名单(决定不做)
+
+考虑过在 gateway 加临时 middleware,让真实用户走 503 维护页只让压力机 IP 进入。**决定不做**,理由:
+
+- 早期项目凌晨 02:00-06:00 DAU 接近 0,撞上概率极低
+- 加 middleware 要重新部署 gateway,反而引入新风险
+- 万一压测被熔断了,真实用户却被白名单拦了 = 把 prod 自己锁死
+
+靠时间窗口 + 6 维红线自动判停就够。
+
+---
+
+## 9. 产出物清单
+
+### 9.1 第一轮交付
+
+```
+docs/loadtest/round1/
+├── prod-vs-local-schema-diff.md # Day 1 schema 对照报告
+├── seed-validation.log # Day 1 联调记录
+├── report-round1.md # 主报告(模板见 §9.2)
+├── monitoring/
+│ ├── sample-YYYYMMDD-0200.log # 服务端采样原始
+│ ├── docker-stats.csv # CPU/内存时序
+│ ├── pg-slow-queries.txt # pg_stat_statements top 20
+│ └── grafana-screenshots/ # 仅 --monitor=full 时
+└── raw-data/
+ ├── baseline-*.json # 每场景基线
+ ├── step-*.json # 每场景阶梯
+ └── hdr-histograms.bin # 二进制 HDR(可重放)
+```
+
+### 9.2 `report-round1.md` 模板
+
+> ⚠️ 以下数字均为**格式示例**,不是真实压测结果。真实数字由 `loadgen report` 自动从 raw data 生成。
+
+```markdown
+# 第一轮压测报告 - 2026-MM-DD
+
+## 摘要(示例值)
+- 总耗时: 1h45min
+- 测试场景: 7
+- 拐点小结(示例): S1=15RPS, S2=350RPS, S3=60RPS, S4=25RPS, S5=18RPS, S6=400RPS, S7=70RPS
+- 主要瓶颈(示例): bcrypt cost (S1), PG aggregation (S5)
+- 建议: 优先改 S1/S5
+
+## 各场景详情
+(每场景一节:RPS 曲线、错误率曲线、Top 慢查询、容器 CPU/内存峰值)
+
+## 系统观察
+- 整机 CPU 峰值: 95%(发生在 S2=700 RPS 时)
+- PG connections 峰值: 78/100(发生在 S5=60 RPS 时)← 接近上限
+- Redis 内存峰值: 180MB / 256M
+- 容器 OOM: 无
+
+## 后续行动
+- [ ] 改 bcrypt cost 10 → 8(S1 拐点提高 ~3x)
+- [ ] 给 statistic_mv 加 refresh 调度(S5 拐点提高 ~2x)
+- [ ] 调 PG max_connections 100 → 150(S5 紧迫)
+```
+
+---
+
+## 10. 不在范围(YAGNI)
+
+| 项 | 不做的原因 |
+|---|---|
+| 分布式追踪(OpenTelemetry / Jaeger) | 早期摸底用不上 |
+| 业务级埋点(每接口单独 metrics) | 加业务代码代价大 |
+| 全链路日志聚合(ELK) | Loki/Promtail 都嫌重 |
+| APM(New Relic / DataDog) | 商业产品,超出范围 |
+| TUI 实时仪表盘 | stderr 行模式更可靠 |
+| 第一轮混合场景 | 业务比例还没成型,凭直觉给比例没意义 |
+| WebSocket 压测 | 独立的长连接模型,本轮不做 |
+| IP 白名单 middleware | 凌晨窗口 DAU 接近 0,引入新风险得不偿失 |
+| 端到端业务正确性测试 | E2E 的工作 |
+| 前端性能测试 | 范围外 |
+| 安全/渗透测试 | 范围外 |
+| 活动榜单/星册接口 | 聚焦核心 7 场景 |
+
+---
+
+## 11. 后续步骤
+
+1. **本设计文档复核** ← 当前节点
+2. 调用 `writing-plans` skill 生成实施计划
+3. Day 1: schema diff + seed 工具开发 + 压力机准备
+4. Day 2: 凌晨 02:00-06:00 第一次正式压测
+5. Day 3: review 报告与决策
+6. 修复期(可选)
+7. 第二轮压测(验证)
+
+---
+
+## 附录 A:术语表
+
+| 术语 | 含义 |
+|---|---|
+| **VU** | Virtual User,并发虚拟用户数 |
+| **RPS** | Requests Per Second,每秒请求数 |
+| **P50/P95/P99** | 延迟分位数(中位数 / 95% / 99% 的请求在此值以下完成) |
+| **HDR** | High Dynamic Range Histogram,高精度低开销直方图,用于准确算百分位 |
+| **拐点 RPS** | 错误率/P99 突破阈值前的最大稳态 RPS |
+| **安全水位** | 拐点 × 60%,作为稳定性测试的目标 RPS |
+| **Soak Test** | 稳定性测试,长时间维持中等负载找泄漏 |
+| **Stress Test** | 破坏性测试,超出极限验证降级与恢复 |
+| **MTTR** | Mean Time To Recover,平均恢复时间 |
+| **影子表** | 与生产表结构相同但物理分离的副本(本方案用 `star_id` 隔离代替) |
+
+---
+
+## 附录 B:被测接口路径速查
+
+| 场景 | Method | 路径 | 主要 body/query |
+|---|---|---|---|
+| S1 | POST | `/api/v1/auth/login` | `{mobile, password}` |
+| S1 | GET | `/api/v1/me/profile` | (Header: Authorization) |
+| S2 | GET | `/api/v1/assets/me/items?page=1&page_size=20` | |
+| S2 | GET | `/api/v1/assets/:asset_id` | |
+| S3 | POST | `/api/v1/social/assets/:asset_id/like` | (需 asset 在 exhibition 中) |
+| S3 | DELETE | `/api/v1/social/assets/:asset_id/like` | |
+| S4 | POST | `/api/v1/assets/mints/precreate` | `{material_url, name, info, ...}` |
+| S4 | POST | `/api/v1/assets/mints` | `{order_id}` |
+| S5 | GET | `/api/v1/dashboard/today-overview` | |
+| S5 | GET | `/api/v1/dashboard/income-curve` | |
+| S5 | GET | `/api/v1/dashboard/exhibition-summary` | |
+| S5 | GET | `/api/v1/dashboard/like-income-by-level` | |
+| S5 | GET | `/api/v1/dashboard/top-assets` | |
+| S5 | GET | `/api/v1/dashboard/level-distribution` | |
+| S5 | GET | `/api/v1/dashboard/upgrade-progress` | |
+| S6 | GET | `/api/v1/rankings/hot?dimension={displaying,month,total}&star_id={87,88,93,999900}&page=1&page_size=10` | dimension 值已通过代码核实(ranking_service.go:63-72),仅这 3 个 |
+| S6 | GET | `/api/v1/rankings/original?...` | |
+| S7 | POST | `/api/v1/galleries/place` | `{slot_id, asset_id}` |
+| S7 | DELETE | `/api/v1/galleries/slots/:slot_id/asset` | |
+
+---
+
+*— 设计文档结束 —*
diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue
index 2dec6d3..1d8f130 100644
--- a/frontend/pages/square/components/HotCategoryBlock.vue
+++ b/frontend/pages/square/components/HotCategoryBlock.vue
@@ -469,14 +469,20 @@ onUnmounted(() => {
margin: 0 auto;
width: 480rpx;
height: 96rpx;
- clip-path: inset(-200rpx 0 16rpx 0);
+ // clip-path: inset(-200rpx 0 16rpx 0);
&::before {
content: "";
position: absolute;
inset: 0;
- background: linear-gradient(184deg, #ff5a5d -36.55%, #c2ebff 121.2%);
+ background: linear-gradient(
+ 183deg,
+ #FF5A5D -36.55%, #C2EBFF 121.2%
+
+ );
opacity: 0.8;
- // backdrop-filter: blur(5.85px);
+ // Figma 用的就是 filter: blur(图形模糊),不是 backdrop-filter(背景模糊)
+ filter: blur(3.7px);
+ -webkit-filter: blur(3.7px);
border-top-left-radius: 14px;
border-top-right-radius: 13px;
border-bottom-right-radius: 8px;
@@ -515,7 +521,7 @@ onUnmounted(() => {
}
.ranking-tab-item.active .ranking-tab-icon {
- top: -24rpx;
+ top: -16rpx;
}
.ranking-tab-label {
@@ -530,6 +536,7 @@ onUnmounted(() => {
.ranking-tab-icon {
display: block;
position: absolute;
+
}
.ranking-tab-item.active .ranking-tab-label {
@@ -602,18 +609,21 @@ onUnmounted(() => {
flex-direction: column;
position: relative;
z-index: 2;
- background: linear-gradient(
- 145.83deg,
- rgba(255, 90, 93, 0.2) 16.63%,
- rgba(76, 237, 255, 0.2) 48.19%,
- rgba(255, 122, 124, 0.2) 83.71%
- );
+ // background: linear-gradient(
+ // 145.83deg,
+ // rgba(255, 90, 93, 0.2) 16.63%,
+ // rgba(76, 237, 255, 0.2) 48.19%,
+ // rgba(255, 122, 124, 0.2) 83.71%
+ // );
+ background: url("/static/square/galaxy/xbbj.png") center no-repeat;
+ background-size: 100% 100%;
+ // pointer-events: none;
// backdrop-filter: blur(4.65px);
border-top-left-radius: 13px;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
border-bottom-left-radius: 12px;
- opacity: 0.8;
+ // opacity: 0.8;
padding: 40rpx 20rpx 32rpx;
overflow: hidden;
}
@@ -787,6 +797,7 @@ onUnmounted(() => {
/* Top 排名标签(顶到右边) */
.top-badge {
margin-left: auto; /* 推到右侧 */
+ margin-right:8rpx;
min-width: 100rpx;
height: 36rpx;
border-radius: 18rpx;
@@ -809,6 +820,7 @@ onUnmounted(() => {
}
.badge-rank-number {
+ font-family: "Abyssinica SIL";
font-size: 84rpx;
font-weight: 600;
/* 渐变填充到文字 */
diff --git a/frontend/static/square/galaxy/xbbj.png b/frontend/static/square/galaxy/xbbj.png
new file mode 100644
index 0000000..db46f2f
Binary files /dev/null and b/frontend/static/square/galaxy/xbbj.png differ