restrict v1

This commit is contained in:
tamakyi
2026-10-18 21:14:59 +08:00
parent da7e76a7db
commit 6bd90779f8
23 changed files with 1754 additions and 1 deletions
+382
View File
@@ -0,0 +1,382 @@
// 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)"},
},
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("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
}
// 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]) + "..."
}