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