owpengram-server/internal/store/postgres/moderation.go
2026-07-24 11:56:59 +08:00

441 lines
16 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ModerationReportStore struct {
db sqlcgen.DBTX
}
func NewModerationReportStore(db sqlcgen.DBTX) *ModerationReportStore {
return &ModerationReportStore{db: db}
}
func (s *ModerationReportStore) CreateModerationReport(ctx context.Context, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin moderation report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
stored, created, err := createModerationReportTx(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit moderation report: %w", err)
}
return stored, created, nil
}
func createModerationReportTx(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
var (
reportID int64
err error
)
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
err = tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup moderation report fingerprint: %w", err)
}
var hourly, daily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '1 hour'),
count(*) FILTER (WHERE created_at >= $2::timestamptz - interval '24 hours')
FROM moderation_reports
WHERE reporter_user_id = $1
AND created_at <= $2::timestamptz`,
report.ReporterUserID, report.CreatedAt).Scan(&hourly, &daily); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("count moderation reporter submissions: %w", err)
}
if hourly >= domain.MaxModerationReportsPerHour || daily >= domain.MaxModerationReportsPerDay {
return domain.ModerationReport{}, false, domain.ErrModerationRateLimited
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !created {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("duplicate moderation report disappeared")
}
return existing, false, nil
}
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
func (s *ModerationReportStore) ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if legacyReportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, err
}
if report.Source != domain.ModerationSourceEphemeral {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("begin legacy ephemeral report import: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-legacy-ephemeral:' || $1::bigint::text, 0)
)`, legacyReportID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock legacy ephemeral report: %w", err)
}
var reportID int64
err = tx.QueryRow(ctx, `
SELECT moderation_report_id
FROM moderation_legacy_ephemeral_migrations
WHERE legacy_report_id = $1`, legacyReportID).Scan(&reportID)
if err == nil {
existing, found, err := getModerationReport(ctx, tx, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("legacy ephemeral report mapping points to missing moderation report")
}
return existing, false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, fmt.Errorf("lookup legacy ephemeral report mapping: %w", err)
}
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('moderation-report:' || $1::bigint::text, 0)
)`, report.ReporterUserID); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("lock moderation reporter: %w", err)
}
reportID, created, err := insertModerationReport(ctx, tx, report)
if err != nil {
return domain.ModerationReport{}, false, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_legacy_ephemeral_migrations (
legacy_report_id, moderation_report_id, migrated_at
) VALUES ($1,$2,clock_timestamp())`,
legacyReportID, reportID,
); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("insert legacy ephemeral report mapping: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("commit legacy ephemeral report import: %w", err)
}
if created {
report.ID = reportID
return domain.CloneModerationReport(report), true, nil
}
existing, found, err := getModerationReport(ctx, s.db, reportID)
if err != nil {
return domain.ModerationReport{}, false, err
}
if !found {
return domain.ModerationReport{}, false, fmt.Errorf("imported duplicate moderation report disappeared")
}
return existing, false, nil
}
func insertModerationReport(ctx context.Context, tx pgx.Tx, report domain.ModerationReport) (int64, bool, error) {
var reportID int64
err := tx.QueryRow(ctx, `
INSERT INTO moderation_reports (
reporter_user_id, source, target_peer_type, target_peer_id, reason,
report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (reporter_user_id, fingerprint) DO NOTHING
RETURNING id`,
report.ReporterUserID, string(report.Source), string(report.Target.Type),
report.Target.ID, string(report.Reason), report.Option, report.Comment,
report.CommentHash[:], report.Fingerprint[:], report.TaxonomyVersion,
report.CreatedAt,
).Scan(&reportID)
if errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_reports
WHERE reporter_user_id = $1 AND fingerprint = $2`,
report.ReporterUserID, report.Fingerprint[:]).Scan(&reportID); err != nil {
return 0, false, fmt.Errorf("lookup duplicate moderation report: %w", err)
}
return reportID, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("insert moderation report: %w", err)
}
for ordinal, item := range report.Items {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_report_items (
report_id, ordinal, item_kind, peer_type, peer_id, item_id,
secondary_id, author_user_id, evidence_schema_version, evidence,
evidence_hash
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11)`,
reportID, ordinal, string(item.Kind), string(item.Peer.Type),
item.Peer.ID, item.ItemID, item.SecondaryID, item.AuthorUserID,
item.EvidenceSchemaVersion, []byte(item.Evidence), item.EvidenceHash[:],
); err != nil {
return 0, false, fmt.Errorf("insert moderation report item %d: %w", ordinal, err)
}
}
for _, hold := range report.MediaHolds {
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_media_holds (
report_id, item_ordinal, media_kind, storage_key, created_at
) VALUES ($1,$2,$3,$4,$5)`,
reportID, hold.ItemIndex, string(hold.Kind), hold.StorageKey,
report.CreatedAt,
); err != nil {
return 0, false, fmt.Errorf("insert moderation media hold: %w", err)
}
}
if err := attachModerationReportToCase(ctx, tx, reportID, report); err != nil {
return 0, false, err
}
return reportID, true, nil
}
func attachModerationReportToCase(ctx context.Context, tx pgx.Tx, reportID int64, report domain.ModerationReport) error {
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended(
'moderation-case:' || $1::text || ':' || $2::bigint::text,
0
)
)`, string(report.Target.Type), report.Target.ID); err != nil {
return fmt.Errorf("lock moderation case target: %w", err)
}
var caseID int64
err := tx.QueryRow(ctx, `
SELECT id
FROM moderation_cases
WHERE target_peer_type = $1
AND target_peer_id = $2
AND status IN ('open', 'in_review')
FOR UPDATE`,
string(report.Target.Type), report.Target.ID,
).Scan(&caseID)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, assigned_to,
version, report_count, distinct_reporter_count, first_report_at,
last_report_at, created_at, updated_at
) VALUES ($1,$2,'open',$3,'',1,1,1,$4,$4,$4,$4)
RETURNING id`,
string(report.Target.Type), report.Target.ID,
int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
).Scan(&caseID)
if err != nil {
return fmt.Errorf("create moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to new moderation case: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("find active moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO moderation_case_reports (case_id, report_id, attached_at)
VALUES ($1,$2,$3)`, caseID, reportID, report.CreatedAt); err != nil {
return fmt.Errorf("attach report to moderation case: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE moderation_cases c
SET severity = greatest(c.severity, $2),
version = c.version + 1,
report_count = (
SELECT count(*)::integer
FROM moderation_case_reports cr
WHERE cr.case_id = c.id
),
distinct_reporter_count = (
SELECT count(DISTINCT r.reporter_user_id)::integer
FROM moderation_case_reports cr
JOIN moderation_reports r ON r.id = cr.report_id
WHERE cr.case_id = c.id
),
first_report_at = least(c.first_report_at, $3),
last_report_at = greatest(c.last_report_at, $3),
updated_at = greatest(c.updated_at, $3)
WHERE c.id = $1`,
caseID, int16(domain.ModerationSeverityForReason(report.Reason)),
report.CreatedAt,
); err != nil {
return fmt.Errorf("update moderation case aggregates: %w", err)
}
return nil
}
func (s *ModerationReportStore) GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error) {
if s == nil || s.db == nil {
return domain.ModerationReport{}, false, fmt.Errorf("moderation report store is not configured")
}
if reportID <= 0 {
return domain.ModerationReport{}, false, domain.ErrModerationReportInvalid
}
return getModerationReport(ctx, s.db, reportID)
}
func getModerationReport(ctx context.Context, db sqlcgen.DBTX, reportID int64) (domain.ModerationReport, bool, error) {
var (
report domain.ModerationReport
source, target, reason string
commentHash []byte
fingerprint []byte
)
err := db.QueryRow(ctx, `
SELECT id, reporter_user_id, source, target_peer_type, target_peer_id,
reason, report_option, report_comment, comment_hash, fingerprint,
taxonomy_version, created_at
FROM moderation_reports
WHERE id = $1`, reportID).Scan(
&report.ID, &report.ReporterUserID, &source, &target,
&report.Target.ID, &reason, &report.Option, &report.Comment,
&commentHash, &fingerprint, &report.TaxonomyVersion,
&report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ModerationReport{}, false, nil
}
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: %w", err)
}
if len(commentHash) != len(report.CommentHash) || len(fingerprint) != len(report.Fingerprint) {
return domain.ModerationReport{}, false, fmt.Errorf("get moderation report: invalid persisted hash length")
}
copy(report.CommentHash[:], commentHash)
copy(report.Fingerprint[:], fingerprint)
report.Source = domain.ModerationReportSource(source)
report.Target.Type = domain.PeerType(target)
report.Reason = domain.ModerationReason(reason)
rows, err := db.Query(ctx, `
SELECT ordinal, item_kind, peer_type, peer_id, item_id, secondary_id,
author_user_id, evidence_schema_version, evidence, evidence_hash
FROM moderation_report_items
WHERE report_id = $1
ORDER BY ordinal`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation report items: %w", err)
}
for rows.Next() {
var (
ordinal int
item domain.ModerationReportItem
kind, peerType string
evidence, evidenceHash []byte
)
if err := rows.Scan(
&ordinal, &kind, &peerType, &item.Peer.ID, &item.ItemID,
&item.SecondaryID, &item.AuthorUserID,
&item.EvidenceSchemaVersion, &evidence, &evidenceHash,
); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: %w", err)
}
if ordinal != len(report.Items) || len(evidenceHash) != len(item.EvidenceHash) {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item: invalid persisted ordering or hash")
}
canonical, err := domain.CanonicalModerationEvidence(evidence)
if err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation report item evidence: %w", err)
}
item.Kind = domain.ModerationReportItemKind(kind)
item.Peer.Type = domain.PeerType(peerType)
item.Evidence = canonical
copy(item.EvidenceHash[:], evidenceHash)
report.Items = append(report.Items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation report items: %w", err)
}
rows.Close()
holdRows, err := db.Query(ctx, `
SELECT item_ordinal, media_kind, storage_key
FROM moderation_media_holds
WHERE report_id = $1
ORDER BY item_ordinal, media_kind, storage_key`, reportID)
if err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("list moderation media holds: %w", err)
}
defer holdRows.Close()
for holdRows.Next() {
var hold domain.ModerationMediaHold
var kind string
if err := holdRows.Scan(&hold.ItemIndex, &kind, &hold.StorageKey); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("scan moderation media hold: %w", err)
}
hold.Kind = domain.ModerationMediaKind(kind)
report.MediaHolds = append(report.MediaHolds, hold)
}
if err := holdRows.Err(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("iterate moderation media holds: %w", err)
}
if err := report.Validate(); err != nil {
return domain.ModerationReport{}, false, fmt.Errorf("validate persisted moderation report: %w", err)
}
return report, true, nil
}