134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
// 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 question
|
|
|
|
import (
|
|
stdctx "context"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/tamakyi/TamaBox/internal/conf"
|
|
"github.com/tamakyi/TamaBox/internal/db"
|
|
"github.com/tamakyi/TamaBox/internal/mail"
|
|
"github.com/tamakyi/TamaBox/internal/shared"
|
|
)
|
|
|
|
// 举报阈值:达到任一条件就发一封汇总邮件给运维。
|
|
//
|
|
// 为什么是 8 / 20 而不是更低:一次粉圈摩擦里,围观者顺手举报同一内容很容易攒到三五条,
|
|
// 阈值太低会天天误报,最后这个告警会被无视;而组织化挂人往往短时间内几十条。
|
|
const (
|
|
abuseAlertThreshold24h = 8
|
|
abuseAlertThreshold7d = 20
|
|
abuseAlertCooldown = 24 * time.Hour
|
|
)
|
|
|
|
// abuseAlertCooldowns 每个箱主上次发送阈值告警的时间。
|
|
//
|
|
// 用进程内 map 而不是加表:告警是「一次性提醒」,重启后丢失最坏结果是多发一封,
|
|
// 而为此新增一张表和一套清理逻辑不划算。并发下用互斥锁保证同一箱主不会连发。
|
|
var (
|
|
abuseAlertMu sync.Mutex
|
|
abuseAlertCooldowns = make(map[uint]time.Time)
|
|
)
|
|
|
|
func allowAbuseAlert(userID uint) bool {
|
|
abuseAlertMu.Lock()
|
|
defer abuseAlertMu.Unlock()
|
|
|
|
if last, ok := abuseAlertCooldowns[userID]; ok && time.Since(last) < abuseAlertCooldown {
|
|
return false
|
|
}
|
|
abuseAlertCooldowns[userID] = time.Now()
|
|
return true
|
|
}
|
|
|
|
// questionReportNoticeThreshold 单条内容被举报多少人次时提醒箱主自查。
|
|
//
|
|
// 取 3:一两个人可能是私人恩怨,三个人以上通常是内容本身有问题。
|
|
const questionReportNoticeThreshold = 3
|
|
|
|
// notifyQuestionOwner 在单条内容的待处理举报数**恰好**达到阈值时提醒箱主。
|
|
//
|
|
// 用「恰好等于」而不是「大于等于」:前者只发一次,后者会从第 3 条起每条举报都发一封信。
|
|
// 箱主关闭邮件通知(Notify=none)时不发 —— 后台的「被举报 N 次」标记仍然可见,
|
|
// 治理提醒不该以无视用户通知偏好为代价。
|
|
func notifyQuestionOwner(pageUser *db.User, question *db.Question) {
|
|
if pageUser.Email == "" || pageUser.Notify == db.NotifyTypeNone {
|
|
return
|
|
}
|
|
|
|
shared.SafeGo(func() {
|
|
ctx := stdctx.Background()
|
|
|
|
count, err := db.Reports.CountByQuestion(ctx, question.ID)
|
|
if err != nil {
|
|
logrus.WithError(err).Error("Failed to count reports for question")
|
|
return
|
|
}
|
|
if count != questionReportNoticeThreshold {
|
|
return
|
|
}
|
|
|
|
link := strings.TrimRight(conf.App.ExternalURL, "/") + "/_/" + pageUser.Domain + "/" + strconv.FormatUint(uint64(question.ID), 10)
|
|
if err := mail.SendQuestionReportedMail(pageUser.Email, pageUser.Domain, question.ID, count, link); err != nil {
|
|
logrus.WithError(err).Error("Failed to send question reported mail")
|
|
return
|
|
}
|
|
logrus.WithFields(logrus.Fields{
|
|
"user_id": pageUser.ID,
|
|
"question_id": question.ID,
|
|
"count": count,
|
|
}).Warn("Question owner notified about reports")
|
|
})
|
|
}
|
|
|
|
// notifyAbuseIfNeeded 在举报写入后判断是否达到告警阈值,异步发送汇总邮件。
|
|
//
|
|
// 只发「该看一眼」的信号,不做任何自动处置:举报数达标不等于内容违规,
|
|
// 尤其这类互殴场景,自动处置会直接变成站队。
|
|
func notifyAbuseIfNeeded(pageUser *db.User) {
|
|
if conf.Security.SecurityAlertEmail == "" {
|
|
return
|
|
}
|
|
|
|
shared.SafeGo(func() {
|
|
ctx := stdctx.Background()
|
|
|
|
count24, err := db.Reports.CountByUserSince(ctx, pageUser.ID, time.Now().Add(-24*time.Hour))
|
|
if err != nil {
|
|
logrus.WithError(err).Error("Failed to count reports within 24h")
|
|
return
|
|
}
|
|
count7d, err := db.Reports.CountByUserSince(ctx, pageUser.ID, time.Now().AddDate(0, 0, -7))
|
|
if err != nil {
|
|
logrus.WithError(err).Error("Failed to count reports within 7d")
|
|
return
|
|
}
|
|
|
|
if count24 < abuseAlertThreshold24h && count7d < abuseAlertThreshold7d {
|
|
return
|
|
}
|
|
if !allowAbuseAlert(pageUser.ID) {
|
|
return
|
|
}
|
|
|
|
if err := mail.SendAbuseAlertMail(pageUser.Domain, pageUser.Name, count24, count7d); err != nil {
|
|
logrus.WithError(err).Error("Failed to send abuse alert mail")
|
|
return
|
|
}
|
|
logrus.WithFields(logrus.Fields{
|
|
"user_id": pageUser.ID,
|
|
"domain": pageUser.Domain,
|
|
"count24": count24,
|
|
"count7d": count7d,
|
|
}).Warn("Abuse alert mail sent")
|
|
})
|
|
}
|