// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "time" "github.com/pkg/errors" "gorm.io/gorm" ) // ReportsStore 举报记录的读写。 // // 举报只是「信号」,处置仍然由箱主(在 /user 面板删/设私密)或运维(CLI)完成 —— // 本项目没有管理后台,所以这里不提供「审核队列」之类的方法,只做写入与计数。 type ReportsStore interface { // Create 写入一条举报。返回 created=false 表示该 IP 已举报过同一内容(唯一索引冲突), // 属于幂等成功:调用方不应据此提示用户「已举报过」,否则会成为内容存在性的探测接口。 Create(ctx context.Context, opts CreateReportOptions) (bool, error) // QuestionIDsByUser 返回某箱主名下「被举报的提问 ID -> 待处理举报数」。 // 箱主列表用它打标;已删除的提问不计入。 QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) // CountByUserSince 统计某箱主自 since 起收到的待处理举报总数(邮件告警的触发依据)。 CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) // MarkAcceptedByQuestion 把某条提问下所有「待处理」举报标记为「成立」。 // // 箱主删除或设为私密时调用。举报一旦被处理就应当离开待处理池,否则告警会被 // 「早就处理完」的旧举报持续推高,几周后这个告警就没人看了(狼来了)。 // 返回受影响行数,便于调用方记日志。 MarkAcceptedByQuestion(ctx context.Context, questionID uint) (int64, error) // CountByQuestion 统计某条内容当前的待处理举报数(用于「该条被多人举报,提醒箱主自查」)。 CountByQuestion(ctx context.Context, questionID uint) (int64, error) // TopReportedUsers 按待处理举报数返回被举报最多的箱主(运维视图,用于识别组织化挂人)。 TopReportedUsers(ctx context.Context, since time.Time, limit int) ([]*ReportedUserStat, error) // TopReporters 按举报数返回最活跃的举报来源 IP(用于识别「举报被武器化」)。 TopReporters(ctx context.Context, since time.Time, limit int) ([]*ReporterStat, error) } // ReportedUserStat 被举报箱主的聚合行。 type ReportedUserStat struct { UserID uint Domain string Name string Count int64 } // ReporterStat 举报来源聚合行。 type ReporterStat struct { ReporterIP string Count int64 } // reportPendingTTL 待处理举报的统计有效期。 // // 超过这个窗口仍未处理的举报不再计入箱主侧标记与告警阈值:它们既没有被箱主处理, // 也没有被运维处置,继续累计只会让阈值失去意义。用「查询时按时间过滤」而不是 // 定时任务批量改状态 —— 无后台的项目里不需要额外的调度器,语义也足够清晰。 const reportPendingTTL = 30 * 24 * time.Hour // Reports 举报记录全局 store,由 db.Init 装配。 var Reports ReportsStore var _ ReportsStore = (*reports)(nil) // 举报理由白名单。做成固定枚举而不是自由文本:自由填写等于又开了一个可以写辱骂内容的输入框, // 而举报本身并不需要精确描述。 const ( ReportReasonHarassment = "harassment" // 人身攻击 / 辱骂 ReportReasonDoxxing = "doxxing" // 曝光他人账号或作品 ReportReasonDivisive = "divisive" // 挑起群体对立 ReportReasonOther = "other" // 其他 ) // IsValidReportReason 校验举报理由。 func IsValidReportReason(reason string) bool { switch reason { case ReportReasonHarassment, ReportReasonDoxxing, ReportReasonDivisive, ReportReasonOther: return true default: return false } } // CreateReportOptions 写入举报的入参。 type CreateReportOptions struct { QuestionID uint ReporterIP string Reason string } func NewReportsStore(db *gorm.DB) ReportsStore { return &reports{db: db} } type reports struct { db *gorm.DB } func (s *reports) Create(ctx context.Context, opts CreateReportOptions) (bool, error) { report := &ContentReport{ QuestionID: opts.QuestionID, ReporterIP: opts.ReporterIP, Reason: opts.Reason, Status: ReportStatusPending, } if err := s.db.WithContext(ctx).Create(report).Error; err != nil { // (question_id, reporter_ip) 唯一索引:同一个人对同一条内容只能报一次。 // 静默视为成功,避免通过「已举报过」的提示反查内容是否存在。 if isDuplicateKeyError(err) { return false, nil } return false, errors.Wrap(err, "create content report") } return true, nil } func (s *reports) QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) { type row struct { QuestionID uint Count int64 } var rows []*row if err := s.db.WithContext(ctx). Table("content_reports AS r"). Select("r.question_id AS question_id, COUNT(r.id) AS count"). Joins("JOIN questions q ON q.id = r.question_id"). Where("q.user_id = ? AND q.deleted_at IS NULL", userID). Where("r.status = ?", ReportStatusPending). Where("r.created_at >= ?", time.Now().Add(-reportPendingTTL)). Group("r.question_id"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(err, "count reports by user") } counts := make(map[uint]int64, len(rows)) for _, r := range rows { counts[r.QuestionID] = r.Count } return counts, nil } func (s *reports) CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) { // 与 QuestionIDsByUser 保持一致的「待处理有效期」:since 更早时以 TTL 为准。 if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { since = ttlStart } var count int64 if err := s.db.WithContext(ctx). Table("content_reports AS r"). Joins("JOIN questions q ON q.id = r.question_id"). Where("q.user_id = ? AND q.deleted_at IS NULL", userID). Where("r.status = ?", ReportStatusPending). Where("r.created_at >= ?", since). Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count reports since") } return count, nil } func (s *reports) MarkAcceptedByQuestion(ctx context.Context, questionID uint) (int64, error) { result := s.db.WithContext(ctx). Model(&ContentReport{}). Where("question_id = ?", questionID). Where("status = ?", ReportStatusPending). Update("status", ReportStatusAccepted) if result.Error != nil { return 0, errors.Wrap(result.Error, "mark reports accepted") } return result.RowsAffected, nil } func (s *reports) CountByQuestion(ctx context.Context, questionID uint) (int64, error) { var count int64 if err := s.db.WithContext(ctx). Model(&ContentReport{}). Where("question_id = ?", questionID). Where("status = ?", ReportStatusPending). Where("created_at >= ?", time.Now().Add(-reportPendingTTL)). Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count reports by question") } return count, nil } func (s *reports) TopReportedUsers(ctx context.Context, since time.Time, limit int) ([]*ReportedUserStat, error) { if limit <= 0 { limit = 10 } if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { since = ttlStart } var rows []*ReportedUserStat if err := s.db.WithContext(ctx). Table("content_reports AS r"). Select("q.user_id AS user_id, u.domain AS domain, u.name AS name, COUNT(r.id) AS count"). Joins("JOIN questions q ON q.id = r.question_id"). Joins("JOIN users u ON u.id = q.user_id"). Where("q.deleted_at IS NULL"). Where("r.status = ?", ReportStatusPending). Where("r.created_at >= ?", since). Group("q.user_id, u.domain, u.name"). Order("count DESC"). Limit(limit). Scan(&rows).Error; err != nil { return nil, errors.Wrap(err, "top reported users") } return rows, nil } func (s *reports) TopReporters(ctx context.Context, since time.Time, limit int) ([]*ReporterStat, error) { if limit <= 0 { limit = 10 } if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { since = ttlStart } var rows []*ReporterStat if err := s.db.WithContext(ctx). Model(&ContentReport{}). Select("reporter_ip, COUNT(*) AS count"). Where("status = ?", ReportStatusPending). Where("created_at >= ?", since). Group("reporter_ip"). Order("count DESC"). Limit(limit). Scan(&rows).Error; err != nil { return nil, errors.Wrap(err, "top reporters") } return rows, nil }