588 lines
21 KiB
Go
588 lines
21 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 cmd
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
"github.com/urfave/cli/v2"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/conf"
|
||
"github.com/tamakyi/TamaBox/internal/db"
|
||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||
"github.com/tamakyi/TamaBox/internal/mail"
|
||
)
|
||
|
||
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
|
||
//
|
||
// TamaBox reports --domain xxx 出证据包(只读)
|
||
// TamaBox restrict --domain xxx --level L1 --days 7 --reason "..."
|
||
// TamaBox unrestrict --domain xxx
|
||
//
|
||
// 处置记录由运行时读表生效(见 db.Restrictions.ActiveLevel),到期自动失效,
|
||
// 不需要定时任务,也不需要任何后台页面。
|
||
|
||
// Restrict 对箱主施加一级处置。
|
||
var Restrict = &cli.Command{
|
||
Name: "restrict",
|
||
Usage: "Restrict a user (L1 stop distribution / L2 narrow publishing / L3 freeze publishing / L4 suspend page)",
|
||
Flags: []cli.Flag{
|
||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||
&cli.StringFlag{Name: "level", Value: string(db.RestrictionL1), Usage: "Restriction level: L1 / L2 / L3 / L4"},
|
||
&cli.IntFlag{Name: "days", Value: 7, Usage: "Effective days, counted from now"},
|
||
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required, not shown to the user)"},
|
||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||
&cli.BoolFlag{Name: "force", Usage: "Required when --days exceeds 90 (abnormal duration)"},
|
||
&cli.BoolFlag{Name: "no-notify", Usage: "Do not email the restriction notice to the user"},
|
||
},
|
||
Action: runRestrict,
|
||
}
|
||
|
||
// Unrestrict 撤销箱主当前生效的全部处置。
|
||
var Unrestrict = &cli.Command{
|
||
Name: "unrestrict",
|
||
Usage: "Revoke all active restrictions of a user",
|
||
Flags: []cli.Flag{
|
||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||
&cli.BoolFlag{Name: "no-notify", Usage: "Do not email the lift notice to the user"},
|
||
},
|
||
Action: runUnrestrict,
|
||
}
|
||
|
||
// Reports 输出某箱主的举报证据包,或全站待处理举报列表(只读,不改数据)。
|
||
var Reports = &cli.Command{
|
||
Name: "reports",
|
||
Usage: "Show abuse reports of a user, or list all pending reports",
|
||
Flags: []cli.Flag{
|
||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||
&cli.IntFlag{Name: "days", Value: 30, Usage: "Only count reports created within N days"},
|
||
&cli.IntFlag{Name: "limit", Value: 30, Usage: "Max rows to print"},
|
||
&cli.BoolFlag{Name: "pending", Usage: "List all pending reports across the site (ignores --email/--domain)"},
|
||
&cli.BoolFlag{Name: "stats", Usage: "Show site-wide report statistics (ignores --email/--domain)"},
|
||
},
|
||
Action: runReports,
|
||
}
|
||
|
||
// openDatabase 复用 ban / censor 的初始化流程:读配置 → 拼 DSN → 连库并 AutoMigrate。
|
||
func openDatabase() (*gorm.DB, error) {
|
||
if err := conf.Init(); err != nil {
|
||
return nil, errors.Wrap(err, "load configuration")
|
||
}
|
||
|
||
dbType := conf.Database.Type
|
||
if dbType == "postgres" {
|
||
conf.Database.DSN = conf.PostgresDsn()
|
||
} else {
|
||
conf.Database.DSN = conf.MySQLDsn()
|
||
}
|
||
|
||
database, err := db.Init(dbType, conf.Database.DSN)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "connect to database")
|
||
}
|
||
return database, nil
|
||
}
|
||
|
||
// findTargetUser 按 email / domain 定位用户。
|
||
// 用 Unscoped 以支持已自助停用(软删除)的账号 —— 处置历史必须能追溯到停用账号。
|
||
func findTargetUser(database *gorm.DB, email, domain string) (*db.User, error) {
|
||
if email == "" && domain == "" {
|
||
return nil, errors.New("must provide --email or --domain")
|
||
}
|
||
|
||
var user db.User
|
||
query := database.Unscoped()
|
||
if email != "" {
|
||
query = query.Where("email = ?", email)
|
||
} else {
|
||
query = query.Where("domain = ?", domain)
|
||
}
|
||
if err := query.First(&user).Error; err != nil {
|
||
return nil, errors.Wrap(err, "find user")
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
func runRestrict(ctx *cli.Context) error {
|
||
level := strings.TrimSpace(ctx.String("level"))
|
||
if !db.IsValidRestrictionLevel(level) {
|
||
return errors.Errorf("invalid --level %q (want L1 / L2 / L3 / L4)", level)
|
||
}
|
||
days := ctx.Int("days")
|
||
if days <= 0 {
|
||
return errors.New("--days must be positive")
|
||
}
|
||
// 不设硬上限(确实存在长期封禁场景),但超过 90 天必须显式加 --force:
|
||
// 这类值几乎都是手误(多打一个 0),靠 --yes 挡不住重复执行的历史命令。
|
||
if days > restrictDaysForceThreshold && !ctx.Bool("force") {
|
||
return errors.Errorf("--days %d 超过 %d 天,属于异常时长;确认无误请加 --force 重试",
|
||
days, restrictDaysForceThreshold)
|
||
}
|
||
reason := strings.TrimSpace(ctx.String("reason"))
|
||
if reason == "" {
|
||
return errors.New("--reason is required (recorded for audit)")
|
||
}
|
||
|
||
database, err := openDatabase()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
activeLevel, active, err := db.NewRestrictionsStore(database).ActiveLevel(ctx.Context, user.ID)
|
||
if err != nil {
|
||
return errors.Wrap(err, "query active restriction")
|
||
}
|
||
|
||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||
if activeLevel != "" {
|
||
// active 理论上不会为 nil(有级别就有记录),但脏数据/并发下宁可少打一行也不要 panic。
|
||
until, reason := "-", "-"
|
||
if active != nil {
|
||
until = active.ExpiresAt.Format("2006-01-02 15:04")
|
||
reason = active.Reason
|
||
}
|
||
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n", activeLevel, until, reason)
|
||
} else {
|
||
fmt.Println("当前处置: 无")
|
||
}
|
||
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
|
||
if days > restrictDaysForceThreshold {
|
||
fmt.Printf("⚠️ 超长处置:%d 天,到期 %s(常规处置为 7–30 天)\n",
|
||
days, time.Now().AddDate(0, 0, days).Format("2006-01-02 15:04"))
|
||
}
|
||
|
||
// 二次确认:异常时长时,即使已给 --force,交互模式下还要再输入一次级别。
|
||
// 脚本里 `--force --yes` 两个显式开关本身就是两次确认,不再额外拦截。
|
||
if days > restrictDaysForceThreshold && !ctx.Bool("yes") {
|
||
logrus.Warn("这是超长处置。请再次输入级别名(如 L3)确认:")
|
||
var confirmLevel string
|
||
if _, err := fmtScan(&confirmLevel); err != nil {
|
||
return errors.Wrap(err, "read confirmation")
|
||
}
|
||
if strings.TrimSpace(confirmLevel) != level {
|
||
return errors.New("已取消(级别不匹配)")
|
||
}
|
||
}
|
||
|
||
if !ctx.Bool("yes") {
|
||
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
|
||
var answer string
|
||
if _, err := fmtScan(&answer); err != nil {
|
||
return errors.Wrap(err, "read confirmation")
|
||
}
|
||
if answer != "YES" {
|
||
return errors.New("已取消")
|
||
}
|
||
}
|
||
|
||
restriction, err := db.NewRestrictionsStore(database).Create(ctx.Context, db.CreateRestrictionOptions{
|
||
UserID: user.ID,
|
||
Level: level,
|
||
Reason: reason,
|
||
CreatedBy: "cli",
|
||
Days: days,
|
||
})
|
||
if err != nil {
|
||
return errors.Wrap(err, "create restriction")
|
||
}
|
||
|
||
logrus.WithFields(logrus.Fields{
|
||
"user_id": user.ID,
|
||
"level": level,
|
||
"expires_at": restriction.ExpiresAt,
|
||
}).Info("处置已生效")
|
||
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
|
||
restriction.ExpiresAt.Format("2006-01-02 15:04"))
|
||
|
||
if !ctx.Bool("no-notify") {
|
||
notifyRestrictionOwner(user, level, restriction.ExpiresAt)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// restrictDaysForceThreshold 超过这个天数需要 --force。不做硬上限:长期封禁是真实需求。
|
||
const restrictDaysForceThreshold = 90
|
||
|
||
// notifyRestrictionOwner 把处置结果通知箱主(按他自己的界面语言)。
|
||
//
|
||
// 为什么必须通知:处置是平台单方面限制用户功能,不告知就变成「暗箱」,
|
||
// 用户既无法自查也无法申诉。邮件里只给「级别 + 到期时间 + 规则链接」,
|
||
// 不引用 --reason(那是运维审计备注),也不引用被举报内容原文。
|
||
//
|
||
// 发送失败只告警、不回滚:处置已经写入,邮件不可用不应让运营者误以为没生效。
|
||
func notifyRestrictionOwner(user *db.User, level string, expires time.Time) {
|
||
if user.Email == "" {
|
||
fmt.Println("(该账号没有邮箱,跳过处置通知)")
|
||
return
|
||
}
|
||
|
||
lang := user.Language
|
||
if lang == "" {
|
||
lang = conf.App.DefaultLang
|
||
}
|
||
levelDesc := i18n.T(lang, "restriction.level."+level)
|
||
notice := mail.RestrictionNotice{
|
||
To: user.Email,
|
||
Heading: i18n.T(lang, "mail.restriction.heading"),
|
||
Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name),
|
||
Body: i18n.T(lang, "mail.restriction.body", user.Domain, level, levelDesc, expires.Format("2006-01-02 15:04")),
|
||
Note: i18n.T(lang, "mail.restriction.note"),
|
||
RulesText: i18n.T(lang, "mail.restriction.rules"),
|
||
RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules",
|
||
Appeal: i18n.T(lang, "mail.restriction.appeal"),
|
||
}
|
||
reportNoticeResult(user, sendNotice(notice), "处置已生效")
|
||
}
|
||
|
||
// notifyRestrictionLifted 告知箱主「限制已解除」。
|
||
//
|
||
// 与处置通知同等重要:只通知「你被罚了」而不通知「已经解除」,用户会一直以为还在限制里,
|
||
// 既不敢发内容,也可能反复来问。这封信也顺带说明「私密内容不会自动恢复」——
|
||
// 否则他会以为解禁后一切照旧。
|
||
func notifyRestrictionLifted(user *db.User, level string) {
|
||
if user.Email == "" {
|
||
fmt.Println("(该账号没有邮箱,跳过解除通知)")
|
||
return
|
||
}
|
||
|
||
lang := user.Language
|
||
if lang == "" {
|
||
lang = conf.App.DefaultLang
|
||
}
|
||
levelDesc := i18n.T(lang, "restriction.level."+level)
|
||
notice := mail.RestrictionNotice{
|
||
To: user.Email,
|
||
Heading: i18n.T(lang, "mail.unrestrict.heading"),
|
||
Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name),
|
||
Body: i18n.T(lang, "mail.unrestrict.body", user.Domain, levelDesc),
|
||
Note: i18n.T(lang, "mail.unrestrict.note"),
|
||
RulesText: i18n.T(lang, "mail.restriction.rules"),
|
||
RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules",
|
||
// 解除通知不需要申诉入口(Appeal 留空,模板会整段省略)。
|
||
}
|
||
reportNoticeResult(user, sendNotice(notice), "解除已生效")
|
||
}
|
||
|
||
// sendNotice 发送一封通知邮件,带 20 秒超时。
|
||
//
|
||
// 为什么要超时:gomail 这版没有 Timeout 字段,裸 Dial 会一直等到系统 TCP 超时(可达两分钟),
|
||
// 运营者站在终端前会以为命令卡死。通知失败不影响已经写入的处置结果。
|
||
func sendNotice(notice mail.RestrictionNotice) error {
|
||
done := make(chan error, 1)
|
||
go func() { done <- mail.SendRestrictionNoticeMail(notice) }()
|
||
select {
|
||
case err := <-done:
|
||
return err
|
||
case <-time.After(20 * time.Second):
|
||
return errors.New("发送通知邮件超时")
|
||
}
|
||
}
|
||
|
||
// reportNoticeResult 统一输出通知结果(成功/失败都不影响已生效的处置)。
|
||
func reportNoticeResult(user *db.User, err error, action string) {
|
||
if err != nil {
|
||
logrus.WithError(err).WithField("user_id", user.ID).Warn("通知邮件发送失败(" + action + ")")
|
||
fmt.Printf("⚠️ 通知邮件发送失败(%s)。请检查 [mail] 配置,或用 --no-notify 跳过通知。\n", action)
|
||
return
|
||
}
|
||
lang := user.Language
|
||
if lang == "" {
|
||
lang = conf.App.DefaultLang
|
||
}
|
||
fmt.Printf("已发送通知邮件至 %s(语言 %s)\n", user.Email, i18n.Resolve(lang))
|
||
}
|
||
|
||
func runUnrestrict(ctx *cli.Context) error {
|
||
database, err := openDatabase()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
store := db.NewRestrictionsStore(database)
|
||
// 先取级别:撤销之后就读不到了,而通知邮件需要写明解除的是哪一级。
|
||
level, _, err := store.ActiveLevel(ctx.Context, user.ID)
|
||
if err != nil {
|
||
return errors.Wrap(err, "query active restriction")
|
||
}
|
||
|
||
affected, err := store.RevokeActive(ctx.Context, user.ID)
|
||
if err != nil {
|
||
return errors.Wrap(err, "revoke restrictions")
|
||
}
|
||
|
||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||
if affected == 0 {
|
||
fmt.Println("没有生效中的处置,无需操作")
|
||
return nil
|
||
}
|
||
logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected, "level": level}).Info("已撤销处置")
|
||
fmt.Printf("已撤销 %d 条生效中的处置\n", affected)
|
||
|
||
if !ctx.Bool("no-notify") && level != "" {
|
||
notifyRestrictionLifted(user, string(level))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
|
||
type reportedQuestionRow struct {
|
||
ID uint
|
||
Content string
|
||
CreatedAt time.Time
|
||
ReportCount int64
|
||
PendingCount int64 // 其中仍为 pending 的条数(已处理的举报仍保留在证据里)
|
||
}
|
||
|
||
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
|
||
type pendingReportRow struct {
|
||
Domain string
|
||
QuestionID uint
|
||
Content string
|
||
ReportCount int64
|
||
LastAt time.Time
|
||
}
|
||
|
||
func runReports(ctx *cli.Context) error {
|
||
database, err := openDatabase()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if ctx.Bool("stats") {
|
||
return printReportStats(ctx.Context, database, ctx.Int("days"))
|
||
}
|
||
if ctx.Bool("pending") {
|
||
return printPendingReports(ctx.Context, database, ctx.Int("limit"))
|
||
}
|
||
return printUserReport(database, ctx)
|
||
}
|
||
|
||
func printUserReport(database *gorm.DB, ctx *cli.Context) error {
|
||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
days := ctx.Int("days")
|
||
if days <= 0 {
|
||
days = 30
|
||
}
|
||
since := time.Now().AddDate(0, 0, -days)
|
||
|
||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||
fmt.Println(strings.Repeat("-", 60))
|
||
|
||
store := db.NewRestrictionsStore(database)
|
||
level, active, err := store.ActiveLevel(ctx.Context, user.ID)
|
||
if err != nil {
|
||
return errors.Wrap(err, "query active restriction")
|
||
}
|
||
if level == "" {
|
||
fmt.Println("当前处置: 无")
|
||
} else {
|
||
until, reason := "-", "-"
|
||
if active != nil {
|
||
until = active.ExpiresAt.Format("2006-01-02 15:04")
|
||
reason = active.Reason
|
||
}
|
||
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n", level, until, reason)
|
||
}
|
||
|
||
// 公开回答总数:用于计算「被举报内容占其公开产出」的比例,
|
||
// 这是判断「组织化挂人」与「偶发吐槽」最直接的一个量。
|
||
var publicAnswered int64
|
||
if err := database.WithContext(ctx.Context).
|
||
Model(&db.Question{}).
|
||
Where("user_id = ? AND answer != '' AND is_private = false", user.ID).
|
||
Count(&publicAnswered).Error; err != nil {
|
||
return errors.Wrap(err, "count public answered questions")
|
||
}
|
||
|
||
var rows []*reportedQuestionRow
|
||
if err := database.WithContext(ctx.Context).
|
||
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at,
|
||
COUNT(r.id) AS report_count,
|
||
SUM(CASE WHEN r.status = ? THEN 1 ELSE 0 END) AS pending_count
|
||
FROM questions q
|
||
JOIN content_reports r ON r.question_id = q.id
|
||
WHERE q.user_id = ? AND q.deleted_at IS NULL AND r.created_at >= ?
|
||
GROUP BY q.id, q.content, q.created_at
|
||
ORDER BY report_count DESC, q.created_at DESC
|
||
LIMIT ?`, db.ReportStatusPending, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
|
||
return errors.Wrap(err, "query reported questions")
|
||
}
|
||
|
||
var totalReports, totalPending int64
|
||
for _, row := range rows {
|
||
totalReports += row.ReportCount
|
||
totalPending += row.PendingCount
|
||
}
|
||
// 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」——
|
||
// ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数,
|
||
// 实测会出现 166.7% 这种无意义的数值(5 次举报 / 3 条回答)。
|
||
ratio := 0.0
|
||
if publicAnswered > 0 {
|
||
ratio = float64(len(rows)) / float64(publicAnswered) * 100
|
||
}
|
||
|
||
// 分母是「当前累计公开回答」,不是窗口内的产出 —— 文案必须写清「累计」,
|
||
// 否则读者会以为分子分母同窗口(实测箱主被 L2 收窄后分母骤降,比例会超过 100%)。
|
||
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(累计,%.1f%%);举报 %d 次,其中待处理 %d 次\n",
|
||
days, len(rows), publicAnswered, ratio, totalReports, totalPending)
|
||
if len(rows) == 0 {
|
||
fmt.Println("没有举报记录")
|
||
} else {
|
||
fmt.Println("被举报条目(按举报数降序):")
|
||
for _, row := range rows {
|
||
// 明确区分「总举报」与「仍待处理」:箱主处理过内容后举报会自动变 accepted,
|
||
// 只报总数会让人误以为还有 3 条挂着。
|
||
state := "已处理"
|
||
if row.PendingCount > 0 {
|
||
state = fmt.Sprintf("待处理 %d", row.PendingCount)
|
||
}
|
||
fmt.Printf(" 举报 %-3d #%-6d %s %s(%s)\n",
|
||
row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"),
|
||
truncateForCLI(row.Content, 48), state)
|
||
}
|
||
}
|
||
|
||
history, err := store.ListByUserID(ctx.Context, user.ID)
|
||
if err != nil {
|
||
return errors.Wrap(err, "list restriction history")
|
||
}
|
||
fmt.Println(strings.Repeat("-", 60))
|
||
if len(history) == 0 {
|
||
fmt.Println("历史处置: 无")
|
||
return nil
|
||
}
|
||
fmt.Println("历史处置:")
|
||
for _, item := range history {
|
||
state := "生效中"
|
||
if item.RevokedAt != nil {
|
||
state = "已撤销 " + item.RevokedAt.Format("2006-01-02")
|
||
} else if item.ExpiresAt.Before(time.Now()) {
|
||
state = "已到期"
|
||
}
|
||
fmt.Printf(" %s %s 至 %s %s(%s)\n",
|
||
item.CreatedAt.Format("2006-01-02 15:04"), item.Level,
|
||
item.ExpiresAt.Format("2006-01-02"), item.Reason, state)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func printPendingReports(ctx context.Context, database *gorm.DB, limit int) error {
|
||
if limit <= 0 {
|
||
limit = 30
|
||
}
|
||
|
||
var rows []*pendingReportRow
|
||
if err := database.WithContext(ctx).
|
||
Raw(`SELECT u.domain AS domain, q.id AS question_id, q.content AS content,
|
||
COUNT(r.id) AS report_count, MAX(r.created_at) AS last_at
|
||
FROM content_reports r
|
||
JOIN questions q ON q.id = r.question_id
|
||
JOIN users u ON u.id = q.user_id
|
||
WHERE r.status = ? AND q.deleted_at IS NULL
|
||
GROUP BY u.domain, q.id, q.content
|
||
ORDER BY u.domain, report_count DESC, last_at DESC
|
||
LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
|
||
return errors.Wrap(err, "query pending reports")
|
||
}
|
||
|
||
if len(rows) == 0 {
|
||
fmt.Println("没有待处理的举报")
|
||
return nil
|
||
}
|
||
|
||
fmt.Printf("待处理举报 %d 条(按举报数降序):\n", len(rows))
|
||
lastDomain := ""
|
||
for _, row := range rows {
|
||
if row.Domain != lastDomain {
|
||
fmt.Printf("\n@%s\n", row.Domain)
|
||
lastDomain = row.Domain
|
||
}
|
||
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
|
||
row.ReportCount, row.QuestionID, row.LastAt.Format("2006-01-02 15:04"),
|
||
truncateForCLI(row.Content, 48))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// printReportStats 输出全站维度的举报统计。
|
||
//
|
||
// 这是「举报是否被武器化」的唯一观测手段:正常举报是分散的,组织化举报会表现为
|
||
// 同一批 IP 集中打同一批箱主。没有这个视图,运营者只能一个 domain 一个 domain 地猜。
|
||
func printReportStats(ctx context.Context, database *gorm.DB, days int) error {
|
||
if days <= 0 {
|
||
days = 7
|
||
}
|
||
since := time.Now().AddDate(0, 0, -days)
|
||
store := db.NewReportsStore(database)
|
||
|
||
users, err := store.TopReportedUsers(ctx, since, 10)
|
||
if err != nil {
|
||
return errors.Wrap(err, "query top reported users")
|
||
}
|
||
reporters, err := store.TopReporters(ctx, since, 10)
|
||
if err != nil {
|
||
return errors.Wrap(err, "query top reporters")
|
||
}
|
||
|
||
fmt.Printf("统计窗口:近 %d 天(仅待处理举报;待处理最长回溯 %d 天)\n", days, 30)
|
||
fmt.Println(strings.Repeat("-", 60))
|
||
|
||
fmt.Println("被举报最多的箱主:")
|
||
if len(users) == 0 {
|
||
fmt.Println(" 无")
|
||
}
|
||
for i, u := range users {
|
||
fmt.Printf(" %2d. @%-20s %-14s %d 条\n", i+1, u.Domain, u.Name, u.Count)
|
||
}
|
||
|
||
fmt.Println()
|
||
fmt.Println("举报来源 IP 最多:")
|
||
if len(reporters) == 0 {
|
||
fmt.Println(" 无")
|
||
}
|
||
for i, r := range reporters {
|
||
fmt.Printf(" %2d. %-18s %d 次\n", i+1, r.ReporterIP, r.Count)
|
||
}
|
||
|
||
fmt.Println()
|
||
fmt.Println("提示:同一 IP 集中举报同一批内容,或某箱主举报数远高于其他箱主时,")
|
||
fmt.Println(" 先判断是组织化挂人还是举报被武器化,再决定是否处置。")
|
||
return nil
|
||
}
|
||
|
||
// truncateForCLI 按 rune 截断并压平换行,避免终端输出被长内容冲散。
|
||
func truncateForCLI(s string, max int) string {
|
||
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", " "), "\n", " ")
|
||
if utf8.RuneCountInString(s) <= max {
|
||
return s
|
||
}
|
||
runes := []rune(s)
|
||
return string(runes[:max]) + "..."
|
||
}
|