Files
tamabox/internal/cmd/restrict.go
T
2026-10-19 02:25:19 +08:00

534 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"},
},
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"),
}
// 加超时:SMTP 不可达时 gomail 的裸 Dial 会一直等到系统 TCP 超时(可达两分钟),
// 运营者站在终端前会以为命令卡死。超时只影响通知,不影响已写入的处置。
done := make(chan error, 1)
go func() { done <- mail.SendRestrictionNoticeMail(notice) }()
select {
case err := <-done:
if err != nil {
logrus.WithError(err).WithField("user_id", user.ID).Warn("处置通知邮件发送失败(处置已生效)")
fmt.Println("⚠️ 通知邮件发送失败(处置已生效)。请检查 [mail] 配置,或用 --no-notify 跳过通知。")
return
}
case <-time.After(20 * time.Second):
logrus.WithField("user_id", user.ID).Warn("处置通知邮件发送超时(处置已生效)")
fmt.Println("⚠️ 通知邮件发送超时(处置已生效)。请检查 SMTP 配置。")
return
}
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
}
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
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]) + "..."
}