topfans/docs/superpowers/plans/2026-06-17-plan-a-db-go-moderation-service.md
2026-06-22 17:19:48 +08:00

72 KiB
Raw Blame History

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 非空;其余状态必 NULL
  • chk_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=TRUE
    • uk_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 个终态 CHECKchk_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_pair
  • chk_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.2proto 定义 + 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.3moderationService 微服务骨架

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 直接)
    // 此方法假定调用方已锁定 statuspath 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.5Service 层

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.45s 锁覆盖整个 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:       &note,
            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 逐个 DELspec §6.4 v2.2 B1DEL 不支持通配)
    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. 写 reportsstatus='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_atchk_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=11 个失败affected=0
}

func TestDismissPathA_ClearsClaimedBy(t *testing.T) {
    // 路径 A 必须 SET claimed_by=NULLpending 状态必为 NULLchk_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.6Client 层

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.goDubbo 调用 assetService.GetAssetRPC(参考 socialService/client/user_rpc_client.go

  • user_client.goDubbo 调用 userService.GetUserRPC

  • notification_client.gov1.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.7Provider 层

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.8main.go 与启动

Task A.8.1: 完善 main.go

Files:

  • Modify: backend/services/moderationService/main.go

参考 socialService/main.go,加:

  • 端口 20011
  • notificationServiceURL=tri://localhost:20008
  • 注册所有 clientasset/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.9Gateway 集成

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: 启动 gatewaycurl 验证

# 启动 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.10Docker + 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 全部 PASS
  • moderationService 启动 + /metrics 端点正常

执行

本子计划 A 由 subagent 派发执行:

  1. 创建 worktree如需要
  2. 派发 subagent 实施 Task A.1.1 ~ A.10.3
  3. 每阶段完成后 review
  4. 全部完成后回到主计划执行 Plan B/C/D