72 KiB
Plan A: 数据库迁移 + Go moderationService 微服务 + Gateway 集成
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
父计划: docs/superpowers/plans/2026-06-17-moderation-report-feedback-system.md
Goal: 完成 topfans 主仓的举报反馈系统数据层 + 微服务层 + Gateway 路由层,端到端可提交举报/反馈
Architecture:
- 共享 PostgreSQL
top-fans库 9 张表 - 独立 Go Dubbo 微服务
moderationService(端口 20011)承载客户端 API + 自动隐藏阈值触发 - Gateway 注册
/api/v1/moderation/*(仅客户端路由) - Redis 计数 + Lua 自动隐藏
- 复用 socialService / notificationService 的分层模式
Tech Stack: Go 1.21+ / Dubbo-go / GORM / PostgreSQL 16 / Redis 7
仓库: topfans 主仓 (/Users/liulujian/Documents/code/TopFansByGithub)
依赖前置:
- ✅ 已有
socialService(端口 20002)作为分层参考 - ✅ 已有
notificationService(端口 20008)作为客户端调用参考 - ✅ 已有
aiChatService(端口 20008)作为 Redis 客户端参考 - ✅ 已有
gateway(端口 8080)作为 HTTP 路由参考 - ⚠️ 端口 20011 已分配给 moderationService(接续 aiChatService 20008 + 3)
阶段 A.1:数据库 Schema
Task A.1.1: 创建迁移文件骨架
Files:
-
Create:
backend/migrations/2026_06_11_011_moderation_tables.sql -
Step 1: 创建文件 + 头部注释
touch backend/migrations/2026_06_11_011_moderation_tables.sql
文件顶部加注释:
-- ============================================================================
-- 举报与反馈系统 (spec: docs/superpowers/specs/2026-06-11-moderation-report-feedback-design.md v2.5)
-- 9 张表 + 索引 + 种子数据 + 9 个序列同步
-- 创建时间: 2026-06-11
-- 文件编号: 011(接续 2026_06_08_010_statistic_partitions_initial.sql)
-- 命名约定: 所有 *_at 字段均为 Unix 毫秒 (BIGINT),与 Go time.Now().UnixMilli() 一致
-- ============================================================================
- Step 2: 写 report_categories 表
-- 1. report_categories (举报分类字典)
CREATE SEQUENCE report_categories_id_seq START WITH 10000 OWNED BY report_categories.id;
CREATE TABLE report_categories (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
severity SMALLINT NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT chk_report_categories_severity CHECK (severity BETWEEN 1 AND 5)
);
COMMENT ON TABLE report_categories IS '举报分类字典(动态配置,enabled=FALSE 不下发客户端)';
CREATE INDEX idx_report_categories_enabled ON report_categories(enabled, sort_order);
INSERT INTO report_categories (code, name, description, severity, sort_order, created_at, updated_at) VALUES
('pornographic', '色情低俗', '含裸露、性暗示内容', 5, 1, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('violence', '暴力血腥', '含暴力、血腥画面', 5, 2, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('infringing', '侵权盗版', '侵犯他人著作权/商标权', 4, 3, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('false_info', '虚假信息', '虚假、欺骗性内容', 3, 4, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('political', '政治敏感', '涉及政治敏感话题', 5, 5, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('ad_spam', '广告骚扰', '垃圾广告、骚扰信息', 2, 6, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('other', '其他', '其他违规情况', 1, 99, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000);
SELECT setval('report_categories_id_seq', (SELECT MAX(id) FROM report_categories));
- Step 3: 写 feedback_categories 表
-- 2. feedback_categories (反馈分类字典)
CREATE SEQUENCE feedback_categories_id_seq START WITH 10000 OWNED BY feedback_categories.id;
CREATE TABLE feedback_categories (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
COMMENT ON TABLE feedback_categories IS '反馈分类字典';
INSERT INTO feedback_categories (code, name, description, sort_order, created_at, updated_at) VALUES
('bug', 'BUG 报告', '使用中遇到的问题', 1, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('consult', '使用咨询', '不知道怎么用', 2, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('business', '内容合作', '合作/商务联系', 3, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000),
('suggestion', '功能建议', '希望增加什么功能', 4, EXTRACT(EPOCH FROM NOW())*1000, EXTRACT(EPOCH FROM NOW())*1000);
SELECT setval('feedback_categories_id_seq', (SELECT MAX(id) FROM feedback_categories));
- Step 4: 写 reports 表(最复杂)
完整 SQL 按 spec §3.4,重点约束:
chk_reports_status含'withdrawn'/'archived'(v2.4 V2)chk_reports_target_type限'asset'|'user_profile'chk_reports_claimed_pair—— reviewing 状态必 claimed_by/claimed_at 非空;其余状态必 NULLchk_reports_resolved_fields—— resolved/dismissed 状态必 resolved_action/resolved_by/resolved_at 非空- 关键索引:
idx_reports_status_created (status, created_at DESC)idx_reports_triggered_autohide (target_type, target_id, created_at DESC) WHERE triggered_auto_hide=TRUEuk_reports_reporter_target_pending唯一索引(同 reporter+target pending 只能一条)
注意:reports.target_owner_uid_at_submit 列不创建(v2.4 V1 删除死列),用 target_snapshot.owner_uid 替代。
CREATE SEQUENCE reports_id_seq START WITH 10000 OWNED BY reports.id;
CREATE TABLE reports (
id BIGSERIAL PRIMARY KEY,
reporter_id BIGINT NOT NULL,
star_id BIGINT,
target_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
target_snapshot JSONB NOT NULL,
triggered_auto_hide BOOLEAN NOT NULL DEFAULT FALSE,
category_code VARCHAR(50) NOT NULL,
description VARCHAR(500),
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
is_auto_hidden BOOLEAN NOT NULL DEFAULT FALSE,
claimed_by BIGINT,
claimed_at BIGINT,
resolved_action VARCHAR(20),
resolved_by BIGINT,
resolved_at BIGINT,
resolution_note VARCHAR(500),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT fk_reports_category FOREIGN KEY (category_code)
REFERENCES report_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT chk_reports_status
CHECK (status IN ('pending','reviewing','auto_hidden','resolved','dismissed','withdrawn','archived')),
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','restore')),
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','withdrawn','archived')
AND resolved_action IS NULL
AND resolved_by IS NULL
AND resolved_at IS NULL)
)
);
COMMENT ON TABLE reports IS '举报工单主表(5+2 状态:pending/reviewing/auto_hidden/resolved/dismissed/withdrawn/archived)';
CREATE INDEX idx_reports_status_created ON reports(status, created_at DESC);
CREATE INDEX idx_reports_target ON reports(target_type, target_id, status);
CREATE INDEX idx_reports_reporter ON reports(reporter_id, created_at DESC);
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;
CREATE INDEX idx_reports_triggered_autohide ON reports(target_type, target_id, created_at DESC)
WHERE triggered_auto_hide = TRUE;
CREATE UNIQUE INDEX uk_reports_reporter_target_pending
ON reports(reporter_id, target_type, target_id)
WHERE status IN ('pending', 'reviewing', 'auto_hidden');
SELECT setval('reports_id_seq', (SELECT MAX(id) FROM reports));
- Step 5: 写 report_evidence 表
-- 4. report_evidence (举报证据图)
CREATE SEQUENCE report_evidence_id_seq START WITH 10000 OWNED BY report_evidence.id;
CREATE TABLE report_evidence (
id BIGSERIAL PRIMARY KEY,
report_id BIGINT NOT NULL,
oss_key VARCHAR(255) NOT NULL,
oss_url VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
CONSTRAINT fk_report_evidence_report
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
);
CREATE INDEX idx_report_evidence_report ON report_evidence(report_id, sort_order);
SELECT setval('report_evidence_id_seq', (SELECT MAX(id) FROM report_evidence));
- Step 6: 写 feedbacks 表
按 spec §3.6,重点约束:
chk_feedbacks_status限'pending'|'reviewing'|'replied'|'closed'|'archived'- 4 个终态 CHECK:
chk_feedbacks_replied_fields/chk_feedbacks_closed_fields/chk_feedbacks_archived_fields chk_feedbacks_claimed_pair(与 reports 对齐)contact VARCHAR(320)(v1.6 L1 修正)- 关键索引:
idx_feedbacks_claimed_by (claimed_by, status, created_at DESC) WHERE claimed_by IS NOT NULL
CREATE SEQUENCE feedbacks_id_seq START WITH 10000 OWNED BY feedbacks.id;
CREATE TABLE feedbacks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT,
category_code VARCHAR(50) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(320),
is_anonymous BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
claimed_by BIGINT,
claimed_at BIGINT,
replied_by BIGINT,
replied_at BIGINT,
reply_content TEXT,
closed_by BIGINT,
closed_at BIGINT,
archived_by BIGINT,
archived_at BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT fk_feedbacks_category FOREIGN KEY (category_code)
REFERENCES feedback_categories(code) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT chk_feedbacks_status
CHECK (status IN ('pending','reviewing','replied','closed','archived')),
CONSTRAINT chk_feedbacks_claimed_pair
CHECK (
(status = 'reviewing' AND claimed_by IS NOT NULL AND claimed_at IS NOT NULL)
OR (status <> 'reviewing' AND claimed_by IS NULL AND claimed_at IS NULL)
),
CONSTRAINT chk_feedbacks_replied_fields
CHECK (
(status = 'replied'
AND replied_by IS NOT NULL AND replied_at IS NOT NULL AND reply_content IS NOT NULL)
OR (status <> 'replied'
AND replied_by IS NULL AND replied_at IS NULL AND reply_content IS NULL)
),
CONSTRAINT chk_feedbacks_closed_fields
CHECK (
(status = 'closed' AND closed_by IS NOT NULL AND closed_at IS NOT NULL)
OR (status <> 'closed' AND closed_by IS NULL AND closed_at IS NULL)
),
CONSTRAINT chk_feedbacks_archived_fields
CHECK (
(status = 'archived' AND archived_by IS NOT NULL AND archived_at IS NOT NULL)
OR (status <> 'archived' AND archived_by IS NULL AND archived_at IS NULL)
)
);
CREATE INDEX idx_feedbacks_status_created ON feedbacks(status, created_at DESC);
CREATE INDEX idx_feedbacks_user ON feedbacks(user_id, created_at DESC);
CREATE INDEX idx_feedbacks_category ON feedbacks(category_code, status);
CREATE INDEX idx_feedbacks_claimed_by ON feedbacks(claimed_by, status, created_at DESC)
WHERE claimed_by IS NOT NULL;
CREATE INDEX idx_feedbacks_star ON feedbacks(star_id, status, created_at DESC)
WHERE star_id IS NOT NULL;
SELECT setval('feedbacks_id_seq', (SELECT MAX(id) FROM feedbacks));
- Step 7: 写 feedback_evidence 表
-- 6. feedback_evidence (反馈截图)
CREATE SEQUENCE feedback_evidence_id_seq START WITH 10000 OWNED BY feedback_evidence.id;
CREATE TABLE feedback_evidence (
id BIGSERIAL PRIMARY KEY,
feedback_id BIGINT NOT NULL,
oss_key VARCHAR(255) NOT NULL,
oss_url VARCHAR(500),
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
CONSTRAINT fk_feedback_evidence_feedback
FOREIGN KEY (feedback_id) REFERENCES feedbacks(id) ON DELETE CASCADE
);
CREATE INDEX idx_feedback_evidence_feedback ON feedback_evidence(feedback_id, sort_order);
SELECT setval('feedback_evidence_id_seq', (SELECT MAX(id) FROM feedback_evidence));
- Step 8: 写 moderation_actions 表
按 spec §3.8,重点约束:
chk_moderation_actions_xor改为 OR:(report_id IS NOT NULL) <> (feedback_id IS NOT NULL) OR (report_id IS NULL AND feedback_id IS NULL)(v2.2 B3 — 允许 warn_cleared 无 ticket 关联)chk_moderation_actions_admin_id (admin_id >= 0)(v2.2 B4 — 0 是系统自动 sentinel)chk_moderation_actions_action_type含'autohide_noop'/'withdraw'/'force_release'(v2.4 V3)chk_moderation_actions_target_pairchk_moderation_actions_success_msg
CREATE SEQUENCE moderation_actions_id_seq START WITH 10000 OWNED BY moderation_actions.id;
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,
target_type VARCHAR(30),
target_id BIGINT,
note VARCHAR(500),
success BOOLEAN NOT NULL,
error_message VARCHAR(500),
created_at BIGINT NOT NULL,
CONSTRAINT chk_moderation_actions_xor
CHECK (
(report_id IS NOT NULL) <> (feedback_id IS NOT NULL)
OR (report_id IS NULL AND feedback_id IS NULL)
),
CONSTRAINT chk_moderation_actions_admin_id CHECK (admin_id >= 0),
CONSTRAINT chk_moderation_actions_action_type
CHECK (action_type IN (
'takedown','restore','ban','warn','dismiss','reply','close','archive',
'autohide_noop','withdraw','force_release'
)),
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))
);
COMMENT ON TABLE moderation_actions IS '审核动作流水(永久保留,审计追溯)';
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);
CREATE INDEX idx_moderation_actions_target
ON moderation_actions(target_type, target_id, created_at DESC)
WHERE target_type IS NOT NULL;
SELECT setval('moderation_actions_id_seq', (SELECT MAX(id) FROM moderation_actions));
- Step 9: 写 moderation_target_status 表
按 spec §3.9,重点约束:
chk_mts_target_type限'asset'|'user_profile'chk_mts_source (source IN ('auto','admin'))chk_mts_source_report_id (source='auto' → source_report_id NOT NULL)chk_mts_last_action_type含'warn_cleared'(v2.4 V4)chk_mts_warn_fields第 3 子句支持"历史警告已清除"(v1.7 H2 / v2.5 H4)uk_moderation_target UNIQUE (target_type, target_id)
CREATE SEQUENCE moderation_target_status_id_seq START WITH 10000 OWNED BY moderation_target_status.id;
CREATE TABLE moderation_target_status (
id BIGSERIAL PRIMARY KEY,
target_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
is_warned BOOLEAN NOT NULL DEFAULT FALSE,
warn_count INT NOT NULL DEFAULT 0,
last_warned_at BIGINT,
last_action_type VARCHAR(30) NOT NULL,
reason VARCHAR(200),
source VARCHAR(30) NOT NULL,
source_report_id BIGINT,
operator_admin_id BIGINT,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_moderation_target UNIQUE (target_type, target_id),
CONSTRAINT chk_mts_target_type
CHECK (target_type IN ('asset','user_profile')),
CONSTRAINT chk_mts_source CHECK (source IN ('auto','admin')),
CONSTRAINT chk_mts_source_report_id CHECK (source <> 'auto' OR source_report_id IS NOT NULL),
CONSTRAINT chk_mts_last_action_type
CHECK (last_action_type IN ('takedown','ban','warn','restore','autohide','dismiss','warn_cleared')),
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)
)
);
COMMENT ON TABLE moderation_target_status IS '对象受管控状态(审计 + 警告;下架/封禁由业务表软删除承担)';
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;
SELECT setval('moderation_target_status_id_seq', (SELECT MAX(id) FROM moderation_target_status));
- Step 10: 写 admin_audit_logs 表
按 spec §3.10,重点:
ip VARCHAR(500)(v1.5 M1 兼容 IPv6 多级代理链)chk_admin_audit_logs_resource_pair成对约束(v1.4 M7)
CREATE SEQUENCE admin_audit_logs_id_seq START WITH 10000 OWNED BY admin_audit_logs.id;
CREATE TABLE admin_audit_logs (
id BIGSERIAL PRIMARY KEY,
admin_id BIGINT NOT NULL,
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(30),
resource_id BIGINT,
ip VARCHAR(500),
user_agent VARCHAR(500),
extra JSONB,
created_at BIGINT NOT NULL,
CONSTRAINT chk_admin_audit_logs_resource_pair
CHECK (
(resource_type IS NULL AND resource_id IS NULL)
OR (resource_type IS NOT NULL AND resource_id IS NOT NULL)
)
);
CREATE INDEX idx_admin_audit_logs_admin ON admin_audit_logs(admin_id, created_at DESC);
CREATE INDEX idx_admin_audit_logs_resource
ON admin_audit_logs(resource_type, resource_id, created_at DESC)
WHERE resource_id IS NOT NULL;
SELECT setval('admin_audit_logs_id_seq', (SELECT MAX(id) FROM admin_audit_logs));
- Step 11: 验证 SQL 语法
# 用 docker 起临时 PG 16
docker run -d --name pg-mod-verify -e POSTGRES_PASSWORD=test -p 15433:5432 postgres:16
sleep 3
PGPASSWORD=test psql -h localhost -p 15433 -U postgres -d postgres -f backend/migrations/2026_06_11_011_moderation_tables.sql 2>&1 | tee /tmp/migration.log
# 期望:所有 CREATE/INSERT/setval 都成功,无 CHECK 违例,无 NOT NULL 错误
docker rm -f pg-mod-verify
- Step 12: 验证 9 个序列都同步
docker run -d --name pg-mod-check -e POSTGRES_PASSWORD=test -p 15434:5432 postgres:16
sleep 3
PGPASSWORD=test psql -h localhost -p 15434 -U postgres -d postgres -f backend/migrations/2026_06_11_011_moderation_tables.sql
PGPASSWORD=test psql -h localhost -p 15434 -U postgres -d postgres -c "
SELECT sequencename, last_value,
CASE
WHEN sequencename LIKE 'report_categories%' THEN (SELECT MAX(id) FROM report_categories)
WHEN sequencename LIKE 'feedback_categories%' THEN (SELECT MAX(id) FROM feedback_categories)
WHEN sequencename LIKE 'reports%' THEN (SELECT MAX(id) FROM reports)
WHEN sequencename LIKE 'report_evidence%' THEN (SELECT MAX(id) FROM report_evidence)
WHEN sequencename LIKE 'feedbacks%' THEN (SELECT MAX(id) FROM feedbacks)
WHEN sequencename LIKE 'feedback_evidence%' THEN (SELECT MAX(id) FROM feedback_evidence)
WHEN sequencename LIKE 'moderation_actions%' THEN (SELECT MAX(id) FROM moderation_actions)
WHEN sequencename LIKE 'moderation_target_status%' THEN (SELECT MAX(id) FROM moderation_target_status)
WHEN sequencename LIKE 'admin_audit_logs%' THEN (SELECT MAX(id) FROM admin_audit_logs)
END AS table_max_id
FROM pg_sequences
WHERE sequencename IN (
'report_categories_id_seq','feedback_categories_id_seq','reports_id_seq',
'report_evidence_id_seq','feedbacks_id_seq','feedback_evidence_id_seq',
'moderation_actions_id_seq','moderation_target_status_id_seq','admin_audit_logs_id_seq'
)
ORDER BY sequencename;
"
# 期望:所有 last_value >= table_max_id
docker rm -f pg-mod-check
Task A.1.2: 业务表软删除字段确认(无须改)
- Step 1: 验证 assets 表已有 is_active + deleted_at
SELECT column_name FROM information_schema.columns
WHERE table_name = 'assets' AND column_name IN ('is_active','deleted_at');
-- 期望:2 行
- Step 2: 验证 users 表已有 is_active + deleted_at
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('is_active','deleted_at');
-- 期望:2 行
如果不存在:跳过(spec §6 阶段 6 说明"业务表读路径无须改 SQL",所以字段必须已存在;如果不存在则本计划阻塞,需先建字段迁移)
阶段 A.2:proto 定义 + Go 模型
Task A.2.1: 创建 proto 文件
Files:
-
Create:
backend/proto/moderation.proto -
Step 1: 写 proto 定义
参考已有 backend/proto/social.proto 模板,写:
syntax = "proto3";
package github.com.topfans.backend.pkg.proto.moderation;
option go_package = "github.com/topfans/backend/pkg/proto/moderation;moderation";
service ModerationService {
rpc SubmitReport(SubmitReportRequest) returns (SubmitReportResponse);
rpc ListMyReports(ListMyReportsRequest) returns (ListMyReportsResponse);
rpc GetReport(GetReportRequest) returns (GetReportResponse);
rpc GetReportCategories(GetReportCategoriesRequest) returns (GetReportCategoriesResponse);
rpc SubmitFeedback(SubmitFeedbackRequest) returns (SubmitFeedbackResponse);
rpc ListMyFeedbacks(ListMyFeedbacksRequest) returns (ListMyFeedbacksResponse);
rpc GetFeedback(GetFeedbackRequest) returns (GetFeedbackResponse);
rpc GetFeedbackCategories(GetFeedbackCategoriesRequest) returns (GetFeedbackCategoriesResponse);
}
message SubmitReportRequest {
int64 reporter_id = 1;
string target_type = 2;
int64 target_id = 3;
string category_code = 4;
string description = 5;
bool is_anonymous = 6;
repeated string evidence_keys = 7;
string client_ip = 8;
string device_fp = 9;
}
message SubmitReportResponse {
int64 report_id = 1;
string status = 2;
bool auto_hidden = 3;
bool target_hidden = 4;
int64 claimed_by = 5;
int64 claimed_at = 6;
int64 created_at = 7;
}
message ListMyReportsRequest {
int64 reporter_id = 1;
string status = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListMyReportsResponse {
repeated ReportSummary reports = 1;
int32 total = 2;
}
message ReportSummary {
int64 id = 1;
string target_type = 2;
int64 target_id = 3;
string category_code = 4;
string status = 5;
int64 created_at = 6;
int64 resolved_at = 7;
string resolved_action = 8;
}
message GetReportRequest {
int64 reporter_id = 1;
int64 id = 2;
}
message GetReportResponse {
ReportSummary report = 1;
string description = 2;
repeated EvidenceSummary evidence = 3;
string target_snapshot_json = 4;
}
message EvidenceSummary {
string oss_key = 1;
string oss_url = 2;
}
message GetReportCategoriesRequest {}
message GetReportCategoriesResponse {
repeated CategoryItem categories = 1;
}
message CategoryItem {
string code = 1;
string name = 2;
string description = 3;
int32 severity = 4;
int32 sort_order = 5;
}
// Feedback (同模式,省略)
message SubmitFeedbackRequest { /* ... */ }
message SubmitFeedbackResponse { /* ... */ }
message ListMyFeedbacksRequest { /* ... */ }
message ListMyFeedbacksResponse { /* ... */ }
message GetFeedbackRequest { /* ... */ }
message GetFeedbackResponse { /* ... */ }
message GetFeedbackCategoriesRequest {}
message GetFeedbackCategoriesResponse { repeated CategoryItem categories = 1; }
完整字段按 spec §5.1 + 5.2 客户端 API 补全。
- Step 2: 编译生成 .pb.go 和 .triple.go
cd backend
# 参考现有 proto 编译流程
make proto-gen
# 或:
protoc --go_out=. --go-triple_out=. proto/moderation.proto
ls pkg/proto/moderation/
# 期望:moderation.pb.go + moderation.triple.go
Task A.2.2: 创建 GORM 模型
Files:
-
Create:
backend/pkg/models/moderation.go -
Step 1: 写 9 张表的 GORM 模型
package models
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
"gorm.io/datatypes"
)
// JSONB 支持
type JSONB datatypes.JSON
func (j JSONB) Value() (driver.Value, error) {
if len(j) == 0 { return nil, nil }
return json.RawMessage(j).MarshalJSON()
}
func (j *JSONB) Scan(value interface{}) error {
if value == nil { *j = nil; return nil }
return json.RawMessage(*j).Scan(value)
}
// 1. ReportCategory
type ReportCategory struct {
ID int64 `gorm:"primaryKey;column:id"`
Code string `gorm:"column:code;uniqueIndex"`
Name string `gorm:"column:name"`
Description *string `gorm:"column:description"`
Severity int16 `gorm:"column:severity"`
Enabled bool `gorm:"column:enabled"`
SortOrder int32 `gorm:"column:sort_order"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}
func (ReportCategory) TableName() string { return "report_categories" }
// 2. FeedbackCategory (同模式)
// 3. Report
type Report struct {
ID int64 `gorm:"primaryKey;column:id"`
ReporterID int64 `gorm:"column:reporter_id"`
StarID *int64 `gorm:"column:star_id"`
TargetType string `gorm:"column:target_type"`
TargetID int64 `gorm:"column:target_id"`
TargetSnapshot JSONB `gorm:"column:target_snapshot;type:jsonb"`
TriggeredAutoHide bool `gorm:"column:triggered_auto_hide"`
CategoryCode string `gorm:"column:category_code"`
Description *string `gorm:"column:description"`
IsAnonymous bool `gorm:"column:is_anonymous"`
Status string `gorm:"column:status"`
IsAutoHidden bool `gorm:"column:is_auto_hidden"`
ClaimedBy *int64 `gorm:"column:claimed_by"`
ClaimedAt *int64 `gorm:"column:claimed_at"`
ResolvedAction *string `gorm:"column:resolved_action"`
ResolvedBy *int64 `gorm:"column:resolved_by"`
ResolvedAt *int64 `gorm:"column:resolved_at"`
ResolutionNote *string `gorm:"column:resolution_note"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}
func (Report) TableName() string { return "reports" }
// 4. ReportEvidence
type ReportEvidence struct {
ID int64 `gorm:"primaryKey;column:id"`
ReportID int64 `gorm:"column:report_id"`
OSSKey string `gorm:"column:oss_key"`
OSSURL *string `gorm:"column:oss_url"`
SortOrder int32 `gorm:"column:sort_order"`
CreatedAt int64 `gorm:"column:created_at"`
}
func (ReportEvidence) TableName() string { return "report_evidence" }
// 5. Feedback (字段同 spec §3.6)
// 6. FeedbackEvidence
// 7. ModerationAction
type ModerationAction struct {
ID int64 `gorm:"primaryKey;column:id"`
ReportID *int64 `gorm:"column:report_id"`
FeedbackID *int64 `gorm:"column:feedback_id"`
AdminID int64 `gorm:"column:admin_id"`
ActionType string `gorm:"column:action_type"`
TargetType *string `gorm:"column:target_type"`
TargetID *int64 `gorm:"column:target_id"`
Note *string `gorm:"column:note"`
Success bool `gorm:"column:success"`
ErrorMessage *string `gorm:"column:error_message"`
CreatedAt int64 `gorm:"column:created_at"`
}
func (ModerationAction) TableName() string { return "moderation_actions" }
// 8. ModerationTargetStatus
type ModerationTargetStatus struct {
ID int64 `gorm:"primaryKey;column:id"`
TargetType string `gorm:"column:target_type"`
TargetID int64 `gorm:"column:target_id"`
IsWarned bool `gorm:"column:is_warned"`
WarnCount int32 `gorm:"column:warn_count"`
LastWarnedAt *int64 `gorm:"column:last_warned_at"`
LastActionType string `gorm:"column:last_action_type"`
Reason *string `gorm:"column:reason"`
Source string `gorm:"column:source"`
SourceReportID *int64 `gorm:"column:source_report_id"`
OperatorAdminID *int64 `gorm:"column:operator_admin_id"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}
func (ModerationTargetStatus) TableName() string { return "moderation_target_status" }
// 9. AdminAuditLog
type AdminAuditLog struct {
ID int64 `gorm:"primaryKey;column:id"`
AdminID int64 `gorm:"column:admin_id"`
Action string `gorm:"column:action"`
ResourceType *string `gorm:"column:resource_type"`
ResourceID *int64 `gorm:"column:resource_id"`
IP *string `gorm:"column:ip"`
UserAgent *string `gorm:"column:user_agent"`
Extra JSONB `gorm:"column:extra;type:jsonb"`
CreatedAt int64 `gorm:"column:created_at"`
}
func (AdminAuditLog) TableName() string { return "admin_audit_logs" }
// 业务表 asset / user (软删除字段确认)
type Asset struct {
gorm.Model
IsActive bool `gorm:"column:is_active"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
OwnerUID int64 `gorm:"column:owner_uid"`
StarID *int64 `gorm:"column:star_id"`
Name string `gorm:"column:name"`
}
func (Asset) TableName() string { return "assets" }
type User struct {
gorm.Model
IsActive bool `gorm:"column:is_active"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
StarID *int64 `gorm:"column:identity_id"` // 用户绑定的明星
}
func (User) TableName() string { return "users" }
- Step 2: 编译验证
cd backend
go build ./pkg/models/...
# 期望:编译通过
阶段 A.3:moderationService 微服务骨架
Task A.3.1: 创建目录结构
- Step 1: 复制 socialService 骨架
cd backend/services
cp -r socialService moderationService
cd moderationService
# 替换文件内所有 "social" / "Social" → "moderation" / "Moderation"
# 替换端口 20002 → 20011
# 替换 SERVICE_NAME=social-service → moderation-service
# 替换 imports 中的 social → moderation
注意:保持目录结构一致:
moderationService/
├── main.go
├── start.sh
├── go.mod
├── go.sum
├── configs/
│ └── dubbo.yaml
├── config/
│ └── moderation_config.go
├── model/
│ └── (本计划不用,复用 pkg/models)
├── repository/
│ └── moderation_repository.go
├── service/
│ ├── report_service.go
│ ├── feedback_service.go
│ ├── category_service.go
│ ├── auto_hide_service.go
│ └── transaction_helper.go
├── client/
│ ├── asset_client.go
│ ├── user_client.go
│ ├── notification_client.go (v1.5 stub)
│ └── redis_client.go
└── provider/
└── moderation_provider.go
- Step 2: 修改 go.work
cd backend
cat go.work
# 添加:./services/moderationService
- Step 3: 编译验证骨架
cd backend/services/moderationService
go mod tidy
go build -o moderationService main.go
# 期望:编译通过(即使还是 socialService 的实现)
Task A.3.2: 写 config
Files:
-
Modify:
backend/services/moderationService/config/moderation_config.go -
Step 1: 写 ModerationConfig
package config
import "time"
type ModerationConfig struct {
AutoHideThreshold int // 默认 5
CounterTTL time.Duration // 7 天
UserMarkerTTL time.Duration // 24h
LockTTL time.Duration // 5s
MaxDescriptionLen int // 500
MaxEvidenceCount int // 5
MaxContactLen int // 320
MaxReplyLen int // 2000
MaxEvidenceSize int64 // 5MB
AllowedMimeTypes []string // ["image/png","image/jpeg"]
RateLimitUserPerDay int // 30
RateLimitIPPerDay int // 200
RateLimitDevPerDay int // 200
RateLimitFbUserDay int // 5
ClaimTimeout time.Duration // 15m
PIIAnonymizeAfter time.Duration // 90 天
AutoArchiveAfter time.Duration // 90 天
RedisKeyPrefix string // "mod:report" | "test:mod:report"
PathDCounterPolicy string // "reset" | "preserve" | "expire_1h"
}
var Default = ModerationConfig{
AutoHideThreshold: 5,
CounterTTL: 7 * 24 * time.Hour,
UserMarkerTTL: 24 * time.Hour,
LockTTL: 5 * time.Second,
MaxDescriptionLen: 500,
MaxEvidenceCount: 5,
MaxContactLen: 320,
MaxReplyLen: 2000,
MaxEvidenceSize: 5 * 1024 * 1024,
AllowedMimeTypes: []string{"image/png", "image/jpeg"},
RateLimitUserPerDay: 30,
RateLimitIPPerDay: 200,
RateLimitDevPerDay: 200,
RateLimitFbUserDay: 5,
ClaimTimeout: 15 * time.Minute,
PIIAnonymizeAfter: 90 * 24 * time.Hour,
AutoArchiveAfter: 90 * 24 * time.Hour,
RedisKeyPrefix: "mod:report",
PathDCounterPolicy: "preserve",
}
阶段 A.4:仓储层
Task A.4.1: 写 moderation_repository.go
Files:
-
Create:
backend/services/moderationService/repository/moderation_repository.go -
Step 1: 写仓储结构 + 关键方法
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/topfans/backend/pkg/models"
)
var (
ErrNotFound = errors.New("moderation record not found")
ErrConflict = errors.New("optimistic lock conflict")
)
type ModerationRepository struct {
db *gorm.DB
}
func NewModerationRepository(db *gorm.DB) *ModerationRepository {
return &ModerationRepository{db: db}
}
// ===== Report =====
func (r *ModerationRepository) CreateReport(ctx context.Context, report *models.Report) error {
return r.db.WithContext(ctx).Create(report).Error
}
func (r *ModerationRepository) GetReportByID(ctx context.Context, id int64) (*models.Report, error) {
var report models.Report
err := r.db.WithContext(ctx).First(&report, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return &report, err
}
func (r *ModerationRepository) GetReportByReporterAndTarget(
ctx context.Context, reporterID int64, targetType string, targetID int64,
) (*models.Report, error) {
var report models.Report
err := r.db.WithContext(ctx).
Where("reporter_id = ? AND target_type = ? AND target_id = ? AND status IN ?",
reporterID, targetType, targetID, []string{"pending", "reviewing", "auto_hidden"}).
First(&report).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return &report, err
}
// Claim - 用 affected rows 判定(spec §4.1 并发认领防护)
func (r *ModerationRepository) ClaimReport(
ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64,
) (bool, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id = ? AND status IN ?", id, []string{"pending", "auto_hidden"}).
Updates(map[string]interface{}{
"status": "reviewing",
"claimed_by": adminID,
"claimed_at": now,
"updated_at": now,
})
if res.Error != nil { return false, res.Error }
return res.RowsAffected == 1, nil
}
func (r *ModerationRepository) ReleaseReport(
ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64,
) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID).
Updates(map[string]interface{}{
"status": "pending",
"claimed_by": nil,
"claimed_at": nil,
"updated_at": now,
})
return res.RowsAffected, res.Error
}
func (r *ModerationRepository) DismissReport(
ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64,
resolutionNote string, restore bool,
) (int64, error) {
db := r.useTx(ctx, tx)
// path C/D 需要 restore 参数;path A 不需要(pending → dismissed 直接)
// 此方法假定调用方已锁定 status(path B/C/D 都从 reviewing 入口;path A 单独方法)
res := db.Model(&models.Report{}).
Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID).
Updates(map[string]interface{}{
"status": "dismissed",
"resolved_action": "dismiss",
"resolved_by": adminID,
"resolved_at": now,
"claimed_by": nil,
"claimed_at": nil,
"resolution_note": resolutionNote,
"updated_at": now,
})
return res.RowsAffected, res.Error
}
// DismissPathA - pending → dismissed 快速通道(spec §4.1 路径 A)
func (r *ModerationRepository) DismissPathA(
ctx context.Context, tx *gorm.DB, id int64, adminID int64, now int64, note string,
) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id = ? AND status = 'pending'", id).
Updates(map[string]interface{}{
"status": "dismissed",
"resolved_action": "dismiss",
"claimed_by": nil,
"claimed_at": nil,
"resolved_by": adminID,
"resolved_at": now,
"resolution_note": note,
"updated_at": now,
})
return res.RowsAffected, res.Error
}
func (r *ModerationRepository) ResolveReport(
ctx context.Context, tx *gorm.DB, id int64, action string, adminID int64, now int64, note string,
) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id = ? AND status = 'reviewing' AND claimed_by = ?", id, adminID).
Updates(map[string]interface{}{
"status": "resolved",
"resolved_action": action,
"resolved_by": adminID,
"resolved_at": now,
"claimed_by": nil,
"claimed_at": nil,
"resolution_note": note,
"updated_at": now,
})
return res.RowsAffected, res.Error
}
// BulkDismiss - spec §5.2 bulk-dismiss 端点
func (r *ModerationRepository) BulkDismiss(
ctx context.Context, tx *gorm.DB, ids []int64, adminID int64, now int64,
) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id IN ? AND status IN ?", ids, []string{"pending", "auto_hidden"}).
Updates(map[string]interface{}{
"status": "dismissed",
"resolved_action": "dismiss",
"claimed_by": nil,
"claimed_at": nil,
"resolved_by": adminID,
"resolved_at": now,
"resolution_note": "bulk dismiss",
"updated_at": now,
})
return res.RowsAffected, res.Error
}
// ForceRelease - spec §5.2 force-release
func (r *ModerationRepository) ForceRelease(
ctx context.Context, tx *gorm.DB, id int64, now int64,
) (int64, int64, error) {
db := r.useTx(ctx, tx)
var report models.Report
err := db.First(&report, id).Error
if err != nil { return 0, 0, err }
prevClaimedBy := int64(0)
if report.ClaimedBy != nil { prevClaimedBy = *report.ClaimedBy }
res := db.Model(&models.Report{}).
Where("id = ? AND status = 'reviewing'", id).
Updates(map[string]interface{}{
"status": "pending",
"claimed_by": nil,
"claimed_at": nil,
"updated_at": now,
})
return res.RowsAffected, prevClaimedBy, res.Error
}
// WithdrawReport - spec §5.1 DELETE 客户端撤回
func (r *ModerationRepository) WithdrawReport(
ctx context.Context, tx *gorm.DB, id int64, reporterID int64, now int64,
) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Report{}).
Where("id = ? AND reporter_id = ? AND status = 'pending'", id, reporterID).
Updates(map[string]interface{}{
"status": "withdrawn",
"updated_at": now,
})
return res.RowsAffected, res.Error
}
// ListReports
func (r *ModerationRepository) ListMyReports(
ctx context.Context, reporterID int64, status string, page, pageSize int,
) ([]*models.Report, int64, error) {
var reports []*models.Report
var total int64
q := r.db.WithContext(ctx).Model(&models.Report{}).Where("reporter_id = ?", reporterID)
if status != "" {
q = q.Where("status = ?", status)
}
if err := q.Count(&total).Error; err != nil { return nil, 0, err }
if err := q.Order("created_at DESC").Offset((page-1)*pageSize).Limit(pageSize).Find(&reports).Error; err != nil {
return nil, 0, err
}
return reports, total, nil
}
// ===== Evidence =====
func (r *ModerationRepository) CreateReportEvidence(ctx context.Context, ev *models.ReportEvidence) error {
return r.db.WithContext(ctx).Create(ev).Error
}
func (r *ModerationRepository) GetReportEvidence(ctx context.Context, reportID int64) ([]*models.ReportEvidence, error) {
var evs []*models.ReportEvidence
err := r.db.WithContext(ctx).Where("report_id = ?", reportID).Order("sort_order ASC").Find(&evs).Error
return evs, err
}
// ===== Feedback ===== (同模式:CreateFeedback / GetFeedbackByID / ListMyFeedbacks /
// ClaimFeedback / ReleaseFeedback / ReplyFeedback / CloseFeedback / ArchiveFeedback / CreateFeedbackEvidence / GetFeedbackEvidence)
// ===== ModerationAction =====
func (r *ModerationRepository) CreateAction(ctx context.Context, tx *gorm.DB, action *models.ModerationAction) error {
return r.useTx(ctx, tx).Create(action).Error
}
func (r *ModerationRepository) ListActionsByReport(ctx context.Context, reportID int64) ([]*models.ModerationAction, error) {
var actions []*models.ModerationAction
err := r.db.WithContext(ctx).Where("report_id = ?", reportID).Order("created_at DESC").Find(&actions).Error
return actions, err
}
// ===== ModerationTargetStatus =====
func (r *ModerationRepository) UpsertTargetStatus(ctx context.Context, tx *gorm.DB, mts *models.ModerationTargetStatus) error {
return r.useTx(ctx, tx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "target_type"}, {Name: "target_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"last_action_type", "reason", "source", "source_report_id",
"operator_admin_id", "updated_at",
"is_warned", "warn_count", "last_warned_at",
}),
}).Create(mts).Error
}
// ===== AdminAuditLog =====
func (r *ModerationRepository) CreateAuditLog(ctx context.Context, log *models.AdminAuditLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// ===== Categories =====
func (r *ModerationRepository) ListReportCategories(ctx context.Context) ([]*models.ReportCategory, error) {
var cats []*models.ReportCategory
err := r.db.WithContext(ctx).Where("enabled = TRUE").Order("sort_order ASC").Find(&cats).Error
return cats, err
}
func (r *ModerationRepository) ListFeedbackCategories(ctx context.Context) ([]*models.FeedbackCategory, error) {
var cats []*models.FeedbackCategory
err := r.db.WithContext(ctx).Where("enabled = TRUE").Order("sort_order ASC").Find(&cats).Error
return cats, err
}
// ===== Asset / User =====
func (r *ModerationRepository) GetAsset(ctx context.Context, id int64) (*models.Asset, error) {
var a models.Asset
err := r.db.WithContext(ctx).First(&a, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound }
return &a, err
}
func (r *ModerationRepository) GetUser(ctx context.Context, id int64) (*models.User, error) {
var u models.User
err := r.db.WithContext(ctx).First(&u, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound }
return &u, err
}
// SoftDeleteAsset - asset 表 UPDATE is_active=false
func (r *ModerationRepository) SoftDeleteAsset(ctx context.Context, tx *gorm.DB, id int64, now int64) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Asset{}).
Where("id = ? AND is_active = TRUE", id).
Updates(map[string]interface{}{"is_active": false, "deleted_at": now})
return res.RowsAffected, res.Error
}
func (r *ModerationRepository) RestoreAsset(ctx context.Context, tx *gorm.DB, id int64) (int64, error) {
db := r.useTx(ctx, tx)
res := db.Model(&models.Asset{}).
Where("id = ? AND is_active = FALSE", id).
Updates(map[string]interface{}{"is_active": true, "deleted_at": nil})
return res.RowsAffected, res.Error
}
// SoftDeleteUser / RestoreUser 同模式
// helpers
func (r *ModerationRepository) useTx(ctx context.Context, tx *gorm.DB) *gorm.DB {
if tx != nil { return tx.WithContext(ctx) }
return r.db.WithContext(ctx)
}
- Step 2: 编译验证
cd backend/services/moderationService
go build ./repository/...
# 期望:编译通过
阶段 A.5:Service 层
Task A.5.1: 写 transaction_helper.go
Files:
- Create:
backend/services/moderationService/service/transaction_helper.go
package service
import (
"context"
"gorm.io/gorm"
)
type TxHelper struct {
db *gorm.DB
}
func NewTxHelper(db *gorm.DB) *TxHelper { return &TxHelper{db: db} }
func (h *TxHelper) WithTx(ctx context.Context, fn func(*gorm.DB) error) error {
return h.db.WithContext(ctx).Transaction(fn)
}
Task A.5.2: 写 Lua 脚本
Files:
- Create:
backend/services/moderationService/service/lua/auto_hide.lua
-- 自动隐藏 Lua 脚本 (spec §6.4)
-- KEYS[1]=counter, KEYS[2]=user_marker
-- ARGV[1]=threshold, ARGV[2]=ttl_counter, ARGV[3]=ttl_marker
local first = redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3])
if not first then
return {0, tonumber(redis.call('GET', KEYS[1]) or '0')}
end
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if n >= tonumber(ARGV[1]) then
return {1, n}
end
return {0, n}
Task A.5.3: 写 auto_hide_service.go
Files:
- Create:
backend/services/moderationService/service/auto_hide_service.go
package service
import (
"context"
_ "embed"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/services/moderationService/config"
"github.com/topfans/backend/services/moderationService/repository"
)
//go:embed lua/auto_hide.lua
var autoHideLuaScript string
type AutoHideService struct {
repo *repository.ModerationRepository
redis *redis.Client
tx *TxHelper
cfg config.ModerationConfig
}
func NewAutoHideService(
repo *repository.ModerationRepository,
redis *redis.Client,
tx *TxHelper,
cfg config.ModerationConfig,
) *AutoHideService {
return &AutoHideService{repo: repo, redis: redis, tx: tx, cfg: cfg}
}
// TryTrigger - 提交举报后调用(spec §6.1 step 6)
// 返回值:(triggered bool, counter int64)
func (s *AutoHideService) TryTrigger(
ctx context.Context, targetType string, targetID int64, reporterID int64,
) (bool, int64, error) {
// 应用层短锁(spec §6.4:5s 锁覆盖整个 auto-hide 流程)
lockKey := fmt.Sprintf("%s:lock:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID)
ok, err := s.redis.SetNX(ctx, lockKey, "1", s.cfg.LockTTL).Result()
if err != nil { return false, 0, err }
if !ok { return false, 0, nil } // 5s 内已有并发请求在跑
defer func() {
if err := s.redis.Del(ctx, lockKey).Err(); err != nil {
logger.Sugar.Warnw("failed to release auto-hide lock", "key", lockKey, "err", err)
}
}()
counterKey := fmt.Sprintf("%s:counter:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID)
userMarkerKey := fmt.Sprintf("%s:user:%s:%d:%d", s.cfg.RedisKeyPrefix, targetType, targetID, reporterID)
result, err := s.redis.Eval(ctx, autoHideLuaScript,
[]string{counterKey, userMarkerKey},
s.cfg.AutoHideThreshold,
int(s.cfg.CounterTTL.Seconds()),
int(s.cfg.UserMarkerTTL.Seconds()),
).Result()
if err != nil { return false, 0, err }
arr := result.([]interface{})
triggered := arr[0].(int64) == 1
counter := arr[1].(int64)
return triggered, counter, nil
}
// ExecuteAutoHide - 触发自动隐藏(事务内:业务表软删除 + reports UPDATE + mts UPSERT + 流水)
func (s *AutoHideService) ExecuteAutoHide(
ctx context.Context, targetType string, targetID int64, triggerReportID int64, reporterID int64,
) error {
now := time.Now().UnixMilli()
return s.tx.WithTx(ctx, func(tx *gorm.DB) error {
var affected int64
var err error
// ① 业务表软删除(按 target_type 路由)
switch targetType {
case "asset":
affected, err = s.repo.SoftDeleteAsset(ctx, tx, targetID, now)
case "user_profile":
affected, err = s.repo.SoftDeleteUser(ctx, tx, targetID, now)
default:
return fmt.Errorf("unsupported target_type: %s", targetType)
}
if err != nil { return err }
// ② reports UPDATE pending → auto_hidden
// v2.4 V5: triggered_auto_hide 仅触发者置 TRUE
res := tx.Model(&models.Report{}).
Where("target_type = ? AND target_id = ? AND status = 'pending'", targetType, targetID).
Updates(map[string]interface{}{
"status": "auto_hidden",
"is_auto_hidden": true,
"triggered_auto_hide": gorm.Expr("(id = ?)", triggerReportID),
"updated_at": now,
})
if res.Error != nil { return res.Error }
// ③ mts UPSERT
mts := &models.ModerationTargetStatus{
TargetType: targetType,
TargetID: targetID,
LastActionType: "autohide",
Source: "auto",
SourceReportID: &triggerReportID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.UpsertTargetStatus(ctx, tx, mts); err != nil { return err }
// ③.5 写 moderation_actions 流水(v2.4 V7 conditional)
actionType := "takedown"
note := "auto-hide threshold reached"
if affected == 0 {
actionType = "autohide_noop"
note = "auto-hide no-op: target already is_active=false"
}
reportIDCopy := triggerReportID
action := &models.ModerationAction{
ReportID: &reportIDCopy,
AdminID: 0, // 系统自动 sentinel (v2.2 B4)
ActionType: actionType,
TargetType: &targetType,
TargetID: &targetID,
Note: ¬e,
Success: true,
CreatedAt: now,
}
return s.repo.CreateAction(ctx, tx, action)
})
}
// ResetCounter - spec §6.2 path C: admin 判定误报,重置 Redis 计数
func (s *AutoHideService) ResetCounter(ctx context.Context, targetType string, targetID int64) error {
counterKey := fmt.Sprintf("%s:counter:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID)
lockKey := fmt.Sprintf("%s:lock:%s:%d", s.cfg.RedisKeyPrefix, targetType, targetID)
userMarkerPattern := fmt.Sprintf("%s:user:%s:%d:*", s.cfg.RedisKeyPrefix, targetType, targetID)
// DEL counter + lock
if err := s.redis.Del(ctx, counterKey, lockKey).Err(); err != nil {
return err
}
// SCAN MATCH user_marker 逐个 DEL(spec §6.4 v2.2 B1:DEL 不支持通配)
iter := s.redis.Scan(ctx, 0, userMarkerPattern, 100).Iterator()
var keys []string
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil { return err }
if len(keys) > 0 {
if err := s.redis.Del(ctx, keys...).Err(); err != nil { return err }
}
return nil
}
Task A.5.4: 写 report_service.go
Files:
- Create:
backend/services/moderationService/service/report_service.go
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/moderation"
"github.com/topfans/backend/services/moderationService/client"
"github.com/topfans/backend/services/moderationService/config"
"github.com/topfans/backend/services/moderationService/repository"
)
type ReportService struct {
repo *repository.ModerationRepository
assetClient *client.AssetClient
userClient *client.UserClient
notifClient *client.NotificationClient
autoHide *AutoHideService
cfg config.ModerationConfig
}
func NewReportService(
repo *repository.ModerationRepository,
assetClient *client.AssetClient,
userClient *client.UserClient,
notifClient *client.NotificationClient,
autoHide *AutoHideService,
cfg config.ModerationConfig,
) *ReportService {
return &ReportService{repo: repo, assetClient: assetClient, userClient: userClient,
notifClient: notifClient, autoHide: autoHide, cfg: cfg}
}
// SubmitReport - spec §6.1 完整流程
func (s *ReportService) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
now := time.Now().UnixMilli()
// 0. 限流(user/IP/device,每天 30/200/200)
if err := s.checkRateLimit(ctx, req); err != nil { return nil, err }
// 1. 验证 target_type、分类启用、description ≤ 500、evidence ≤ 5
if req.TargetType != "asset" && req.TargetType != "user_profile" {
return nil, fmt.Errorf("invalid target_type") // 50016
}
if len(req.Description) > s.cfg.MaxDescriptionLen {
return nil, fmt.Errorf("description too long") // 50005
}
if len(req.EvidenceKeys) > s.cfg.MaxEvidenceCount {
return nil, fmt.Errorf("too many evidence") // 50006
}
if !s.isCategoryEnabled(ctx, req.CategoryCode, "report") {
return nil, fmt.Errorf("invalid category") // 50001
}
// 2. 验证目标存在 + 抓取 snapshot
snapshot, ownerUID, starID, err := s.fetchTargetSnapshot(ctx, req.TargetType, req.TargetId)
if err != nil { return nil, fmt.Errorf("target not found") } // 50003
snapshotMap := map[string]interface{}{
"snapshot_at": now,
}
snapshotBytes, _ := json.Marshal(snapshotMap)
var snapshotJSON models.JSONB = snapshotBytes
snapshotJSON["snapshot_at"] = now
// ... 合并到 snapshot
// 2.5 自举报拦截
if req.TargetType == "asset" && req.ReporterId == ownerUID {
return nil, fmt.Errorf("cannot self-report") // 50011
}
if req.TargetType == "user_profile" && req.ReporterId == req.TargetId {
return nil, fmt.Errorf("cannot self-report") // 50011
}
// 3. 防重复检查
existing, _ := s.repo.GetReportByReporterAndTarget(ctx, req.ReporterId, req.TargetType, req.TargetId)
if existing != nil {
return nil, fmt.Errorf("already reported") // 50004
}
// 4. 写 reports(status='pending', claimed_by/claimed_at=NULL — chk_reports_claimed_pair 强制)
report := &models.Report{
ReporterID: req.ReporterId,
StarID: starID,
TargetType: req.TargetType,
TargetID: req.TargetId,
TargetSnapshot: snapshotJSON,
CategoryCode: req.CategoryCode,
Description: &req.Description,
IsAnonymous: req.IsAnonymous,
Status: "pending",
IsAutoHidden: false,
ClaimedBy: nil,
ClaimedAt: nil,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.CreateReport(ctx, report); err != nil { return nil, err }
// 5. 写 report_evidence
for i, key := range req.EvidenceKeys {
ev := &models.ReportEvidence{
ReportID: report.ID, OSSKey: key,
SortOrder: int32(i), CreatedAt: now,
}
if err := s.repo.CreateReportEvidence(ctx, ev); err != nil { return nil, err }
}
// 6. Redis Lua 计数 → 自动隐藏
triggered, _, _ := s.autoHide.TryTrigger(ctx, req.TargetType, req.TargetId, req.ReporterId)
autoHidden := false
targetHidden := false
if triggered {
if err := s.autoHide.ExecuteAutoHide(ctx, req.TargetType, req.TargetId, report.ID, req.ReporterId); err != nil {
// log
} else {
autoHidden = true
// target_hidden 取决于业务表 UPDATE 是否执行(affected > 0)
// 这里简化为 true,实际应查询 repo
targetHidden = true
}
}
status := "pending"
if autoHidden { status = "auto_hidden" }
return &pb.SubmitReportResponse{
ReportId: report.ID,
Status: status,
AutoHidden: autoHidden,
TargetHidden: targetHidden,
ClaimedBy: 0,
ClaimedAt: 0,
CreatedAt: now,
}, nil
}
// ... 其他方法(ListMyReports / GetReport / WithdrawReport)省略
完整实现按 spec §5.1 + §6.1 写,本计划仅给核心结构。
Task A.5.5: 写 feedback_service.go
Files:
- Create:
backend/services/moderationService/service/feedback_service.go
关键点:
ClaimFeedback—— 与 ReportService.ClaimReport 同模式(affected rows 判定)ReplyFeedback—— 终态,清 claimed_by/claimed_at(chk_feedbacks_claimed_pair 强制)CloseFeedback—— 终态,清 claimed_*ArchiveFeedback—— 仅从 reviewing 入口(spec §4.2 / v2.0 CRITICAL 修复)
func (s *FeedbackService) ReplyFeedback(ctx, feedbackID, adminID, content string) error {
now := time.Now().UnixMilli()
return s.tx.WithTx(ctx, func(tx *gorm.DB) error {
// UPDATE feedbacks SET status='replied', reply_content=?, replied_by=?, replied_at=$now, claimed_by=NULL, claimed_at=NULL
res := tx.Model(&models.Feedback{}).
Where("id = ? AND status = 'reviewing' AND claimed_by = ?", feedbackID, adminID).
Updates(map[string]interface{}{
"status": "replied",
"reply_content": content,
"replied_by": adminID,
"replied_at": now,
"claimed_by": nil,
"claimed_at": nil,
"updated_at": now,
})
if res.RowsAffected == 0 { return ErrNotClaimed } // 50008/50009
// 写 moderation_actions(action_type='reply')
return s.repo.CreateAction(ctx, tx, &models.ModerationAction{
FeedbackID: &feedbackID, AdminID: parseInt(adminID),
ActionType: "reply", Success: true, CreatedAt: now,
})
})
}
func (s *FeedbackService) ArchiveFeedback(ctx, feedbackID, adminID int64) error {
now := time.Now().UnixMilli()
// v2.0 CRITICAL: archive 仅从 reviewing 入口(replied 是终态不能再 archive)
return s.tx.WithTx(ctx, func(tx *gorm.DB) error {
res := tx.Model(&models.Feedback{}).
Where("id = ? AND status = 'reviewing' AND claimed_by = ?", feedbackID, adminID).
Updates(map[string]interface{}{
"status": "archived",
"archived_by": adminID,
"archived_at": now,
"claimed_by": nil,
"claimed_at": nil,
"updated_at": now,
})
if res.RowsAffected == 0 { return ErrNotClaimed }
return s.repo.CreateAction(ctx, tx, &models.ModerationAction{
FeedbackID: &feedbackID, AdminID: adminID,
ActionType: "archive", Success: true, CreatedAt: now,
})
})
}
Task A.5.6: 写 category_service.go
按 report / feedback 分类读取逻辑写,最简单(仅 GET 启用中的分类)。
Task A.5.7: 写 unit tests
Files:
- Create:
backend/services/moderationService/service/{report,feedback,auto_hide,concurrency}_test.go
关键测试覆盖(spec §7.2):
- 防重复举报
- 自动隐藏触发
- dismiss 4 路径
- claimed_* 字段一致性
- Lua 脚本逻辑
- 并发:100 用户同时举报同对象 → counter INCR 但只触发 1 次
- 并发:2 admin 同时 claim → 1 成功 1 返回 50008
- 自动隐藏 Lua + 5s 短锁交互(errgroup)
// report_service_test.go 示例
func TestClaimReport_Concurrent(t *testing.T) {
// 启动 2 个 goroutine 同时 claim 同一 report
// 期望:1 个成功(affected=1),1 个失败(affected=0)
}
func TestDismissPathA_ClearsClaimedBy(t *testing.T) {
// 路径 A 必须 SET claimed_by=NULL(pending 状态必为 NULL,chk_reports_claimed_pair)
}
func TestWithdrawReport_OnlyPending(t *testing.T) {
// 非 pending 状态 withdrawn 0 行匹配 → 返回 50019
}
func TestReportService_DuplicateCheck(t *testing.T) {
// 同 reporter+target+type pending 重复提交 → 50004
}
-
Step 1: 写所有测试
-
Step 2: 跑测试
cd backend/services/moderationService
go test ./service/... -v -race
# 期望:全部 PASS
阶段 A.6:Client 层
Task A.6.1: 写 redis_client.go
Files:
- Create:
backend/services/moderationService/client/redis_client.go
参考 aiChatService/main.go 初始化 Redis 客户端:
func NewRedisClient(host string, port int, password string, db int) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", host, port),
Password: password,
DB: db,
})
}
Task A.6.2: 写 asset_client.go + user_client.go + notification_client.go
Files:
-
Create:
backend/services/moderationService/client/{asset,user,notification}_client.go -
asset_client.go:Dubbo 调用
assetService.GetAssetRPC(参考socialService/client/user_rpc_client.go) -
user_client.go:Dubbo 调用
userService.GetUserRPC -
notification_client.go:v1.5 stub — 仅打日志,阶段 7.1 替换为真实现
// notification_client.go (stub)
type NotificationClient struct{}
func (c *NotificationClient) SendReportAutoHidden(ctx context.Context, userID int64, targetType string, targetID int64) error {
logger.Sugar.Infow("STUB: would send report_auto_hidden_notice", "user_id", userID, "target_type", targetType, "target_id", targetID)
return nil
}
// ... 6 个方法的 stub
阶段 A.7:Provider 层
Task A.7.1: 写 moderation_provider.go
Files:
- Create:
backend/services/moderationService/provider/moderation_provider.go
package provider
import (
"context"
pb "github.com/topfans/backend/pkg/proto/moderation"
"github.com/topfans/backend/services/moderationService/service"
)
type ModerationProvider struct {
pb.UnimplementedModerationServiceServer
report *service.ReportService
feedback *service.FeedbackService
category *service.CategoryService
}
func NewModerationProvider(r *service.ReportService, f *service.FeedbackService, c *service.CategoryService) *ModerationProvider {
return &ModerationProvider{report: r, feedback: f, category: c}
}
func (p *ModerationProvider) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
return p.report.SubmitReport(ctx, req)
}
func (p *ModerationProvider) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) {
return p.report.ListMyReports(ctx, req)
}
func (p *ModerationProvider) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) {
return p.report.GetReport(ctx, req)
}
func (p *ModerationProvider) GetReportCategories(ctx context.Context, req *pb.GetReportCategoriesRequest) (*pb.GetReportCategoriesResponse, error) {
return p.category.GetReportCategories(ctx, req)
}
func (p *ModerationProvider) SubmitFeedback(ctx context.Context, req *pb.SubmitFeedbackRequest) (*pb.SubmitFeedbackResponse, error) {
return p.feedback.SubmitFeedback(ctx, req)
}
// ... 其他 RPC 方法映射
阶段 A.8:main.go 与启动
Task A.8.1: 完善 main.go
Files:
- Modify:
backend/services/moderationService/main.go
参考 socialService/main.go,加:
- 端口 20011
notificationServiceURL=tri://localhost:20008- 注册所有 client(asset/user/notification/redis)
- 初始化 TxHelper / Config / AutoHideService / ReportService / FeedbackService / CategoryService
- 注册 Provider 到 Dubbo
- 暴露
promhttp.Handler()在:20011/metrics
Task A.8.2: 完善 start.sh
Files:
- Create:
backend/services/moderationService/start.sh
按 spec §阶段 2.1 模板(已复制到总计划文档)。
Task A.8.3: 编译 + 启动验证
- Step 1: 编译
cd backend/services/moderationService
./start.sh # build + start
# 期望:build 成功,服务监听 20011
- Step 2: metrics 端点验证
curl http://localhost:20011/metrics | head -30
# 期望:promhttp 输出
阶段 A.9:Gateway 集成
Task A.9.1: 写 moderation_client.go
Files:
- Create:
backend/gateway/client/moderation_client.go
参考 backend/gateway/client/social_client.go:
type ModerationRPCClient struct {
cli pb.ModerationServiceClient
}
func NewModerationRPCClient(cli *client.Client) (*ModerationRPCClient, error) {
pbCli, err := cli.ModerationServiceClient()
if err != nil { return nil, err }
return &ModerationRPCClient{cli: pbCli}, nil
}
func (c *ModerationRPCClient) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
return c.cli.SubmitReport(ctx, req)
}
Task A.9.2: 写 DTO + Converter
Files:
- Create:
backend/gateway/dto/moderation_dto.go - Create:
backend/gateway/dto/moderation_converter.go
// moderation_dto.go
type SubmitReportRequestDTO struct {
TargetType string `json:"target_type" binding:"required,oneof=asset user_profile"`
TargetID int64 `json:"target_id" binding:"required,gt=0"`
CategoryCode string `json:"category_code" binding:"required,max=50"`
Description string `json:"description" binding:"max=500"`
IsAnonymous bool `json:"is_anonymous"`
EvidenceKeys []string `json:"evidence_keys" binding:"max=5,dive,max=255"`
}
type SubmitReportResponseDTO struct {
Code int32 `json:"code"`
Data struct {
ReportID int64 `json:"report_id"`
Status string `json:"status"`
AutoHidden bool `json:"auto_hidden"`
TargetHidden bool `json:"target_hidden"`
ClaimedBy int64 `json:"claimed_by"`
ClaimedAt int64 `json:"claimed_at"`
CreatedAt int64 `json:"created_at"`
} `json:"data"`
Message string `json:"message"`
}
Task A.9.3: 写 Controller
Files:
- Create:
backend/gateway/controller/moderation_controller.go
参考 backend/gateway/controller/social_controller.go:
type ModerationController struct {
client *moderationClient.ModerationRPCClient
}
func NewModerationController(cli *client.Client) (*ModerationController, error) {
rpcClient, err := moderationClient.NewModerationRPCClient(cli)
if err != nil { return nil, err }
return &ModerationController{client: rpcClient}, nil
}
func (c *ModerationController) SubmitReport(ctx *gin.Context) {
var req dto.SubmitReportRequestDTO
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(400, dto.ErrorResponse{Code: 50005, Message: "参数错误: " + err.Error()})
return
}
reporterID := middleware.GetUserID(ctx)
ip := ctx.ClientIP()
deviceFP := ctx.GetHeader("X-Device-Fingerprint")
resp, err := c.client.SubmitReport(ctx, converter.ToRPCSubmitReport(&req, reporterID, ip, deviceFP))
if err != nil {
// 错误码映射(参考 spec §7)
ctx.JSON(500, dto.ErrorResponse{Code: 50000, Message: err.Error()})
return
}
ctx.JSON(200, converter.FromRPCSubmitReport(resp))
}
Task A.9.4: 在 router.go 注册路由
Files:
- Modify:
backend/gateway/router/router.go
// 客户端路由
v1.GET("/moderation/report-categories", moderationController.GetReportCategories)
v1.GET("/moderation/feedback-categories", moderationController.GetFeedbackCategories)
v1.POST("/moderation/reports", moderationController.SubmitReport)
v1.GET("/moderation/reports", moderationController.ListMyReports)
v1.GET("/moderation/reports/:id", moderationController.GetReportDetail)
v1.POST("/moderation/feedbacks", moderationController.SubmitFeedback)
v1.GET("/moderation/feedbacks", moderationController.ListMyFeedbacks)
v1.GET("/moderation/feedbacks/:id", moderationController.GetFeedbackDetail)
注意: 仅客户端路由(/api/v1/moderation/*)。后台路由 /api/admin/moderation/* 由 FastAPI 处理,不在本仓。
Task A.9.5: 启动 gateway,curl 验证
# 启动 moderationService + gateway
curl -X POST http://localhost:8080/api/v1/moderation/reports \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"target_type":"asset","target_id":12345,"category_code":"pornographic","description":"test","is_anonymous":false,"evidence_keys":[]}'
# 期望:200 + report_id
阶段 A.10:Docker + Makefile
Task A.10.1: Dockerfile
Files:
- Modify:
docker/Dockerfile.services
加 moderationservice 构建阶段。
Task A.10.2: docker-compose
Files:
- Modify:
docker/docker-compose.local.yml - Modify:
docker/docker-compose.prod.yml
加服务定义(端口 20011 + env)。
Task A.10.3: Makefile
Files:
- Modify:
Makefile
加:
start-moderationservice:
cd backend/services/moderationService && ./start.sh
自审检查清单(Plan A)
完成后逐项验证:
- 9 个序列
last_value >= MAX(id)(CLAUDE.md 强制) - 所有手动 INSERT 都带
SELECT setval chk_reports_status含'withdrawn'/'archived'chk_moderation_actions_action_type含'autohide_noop'/'withdraw'/'force_release'chk_mts_last_action_type含'warn_cleared'uk_reports_reporter_target_pending唯一索引存在- 所有终态迁移清
claimed_by=NULL, claimed_at=NULL - Lua 脚本并发 + 锁测试通过
- Gateway 路由
/api/v1/moderation/*注册成功 go test ./... -race全部 PASSmoderationService启动 +/metrics端点正常
执行
本子计划 A 由 subagent 派发执行:
- 创建 worktree(如需要)
- 派发 subagent 实施 Task A.1.1 ~ A.10.3
- 每阶段完成后 review
- 全部完成后回到主计划执行 Plan B/C/D