// 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" ) // 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 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)"}, &cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"}, }, 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"}, }, 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") } 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 != "" { fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n", activeLevel, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason) } else { fmt.Println("当前处置: 无") } fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason) 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")) return nil } 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 } affected, err := db.NewRestrictionsStore(database).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}).Info("已撤销处置") fmt.Printf("已撤销 %d 条生效中的处置\n", affected) return nil } // reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。 type reportedQuestionRow struct { ID uint Content string CreatedAt time.Time ReportCount int64 } // 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 { fmt.Printf("当前处置: %s(至 %s,原因:%s)\n", level, active.ExpiresAt.Format("2006-01-02 15:04"), active.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 FROM questions q JOIN content_reports r ON r.question_id = q.id WHERE q.user_id = ? AND r.created_at >= ? GROUP BY q.id, q.content, q.created_at ORDER BY report_count DESC, q.created_at DESC LIMIT ?`, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil { return errors.Wrap(err, "query reported questions") } var totalReports int64 for _, row := range rows { totalReports += row.ReportCount } // 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」—— // ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数, // 实测会出现 166.7% 这种无意义的数值(5 次举报 / 3 条回答)。 ratio := 0.0 if publicAnswered > 0 { ratio = float64(len(rows)) / float64(publicAnswered) * 100 } fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(%.1f%%),累计 %d 次举报\n", days, len(rows), publicAnswered, ratio, totalReports) if len(rows) == 0 { fmt.Println("没有举报记录") } else { fmt.Println("被举报条目(按举报数降序):") for _, row := range rows { fmt.Printf(" 举报 %-3d #%-6d %s %s\n", row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"), truncateForCLI(row.Content, 48)) } } 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 = ? GROUP BY u.domain, q.id, q.content ORDER BY 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 天(仅待处理举报)\n", days) 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]) + "..." }