restrict v8
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// HidePublic 把某箱主已公开的回答批量转为私密。
|
||||
//
|
||||
// 存在意义:处置一个「组织挂人」的箱主时,L1 只停分发、L2 只影响新内容,
|
||||
// 已经挂在上面的几十上百条旧内容仍然公开可访问。逐条点后台不现实,
|
||||
// 而直接封号又过重(这类账号往往同时有大量正常内容)。
|
||||
//
|
||||
// 默认**只动「公开且已回答」的题**:未回答的本来就不公开,已私密的不必再动,
|
||||
// 不会误伤草稿态数据。加上 --only-reported 可进一步只处理「有未处理举报」的条,
|
||||
// 这是最精准的用法 —— 只收掉被投诉的部分,其余正常内容保留。
|
||||
var HidePublic = &cli.Command{
|
||||
Name: "hide-public",
|
||||
Usage: "Hide all public answers of a user (set is_private = true)",
|
||||
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: "only-reported", Usage: "Only hide questions that have pending reports"},
|
||||
&cli.IntFlag{Name: "since", Usage: "Only hide questions created within the last N days (0 = all)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHidePublic,
|
||||
}
|
||||
|
||||
func runHidePublic(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
|
||||
}
|
||||
|
||||
onlyReported := ctx.Bool("only-reported")
|
||||
sinceDays := ctx.Int("since")
|
||||
if sinceDays < 0 {
|
||||
return errors.New("--since must not be negative")
|
||||
}
|
||||
|
||||
// ⚠️ 每次都重新构建 query:GORM 的链式对象在 Count/Find 之后会带上 Statement 状态,
|
||||
// 复用同一个实例做「统计 → 预览 → 更新」会导致条件互相污染。
|
||||
build := func() *gorm.DB {
|
||||
q := database.Model(&db.Question{}).
|
||||
Where("user_id = ? AND answer != '' AND is_private = false", user.ID)
|
||||
if onlyReported {
|
||||
q = q.Where("id IN (?)", database.Model(&db.ContentReport{}).
|
||||
Select("question_id").
|
||||
Where("status = ?", db.ReportStatusPending))
|
||||
}
|
||||
if sinceDays > 0 {
|
||||
q = q.Where("created_at >= ?", time.Now().AddDate(0, 0, -sinceDays))
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := build().Count(&total).Error; err != nil {
|
||||
return errors.Wrap(err, "count public questions")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if onlyReported {
|
||||
fmt.Println("范围: 仅「有未处理举报」的公开回答")
|
||||
} else {
|
||||
fmt.Println("范围: 全部公开回答")
|
||||
}
|
||||
if sinceDays > 0 {
|
||||
fmt.Printf("时间: 仅最近 %d 天\n", sinceDays)
|
||||
}
|
||||
if total == 0 {
|
||||
fmt.Println("没有需要隐藏的内容")
|
||||
return nil
|
||||
}
|
||||
|
||||
var preview []*db.Question
|
||||
if err := build().Order("id DESC").Limit(10).Find(&preview).Error; err != nil {
|
||||
return errors.Wrap(err, "preview questions")
|
||||
}
|
||||
fmt.Printf("将把 %d 条回答转为私密(最新 10 条预览):\n", total)
|
||||
for _, q := range preview {
|
||||
fmt.Printf(" #%-6d %s %s\n", q.ID, q.CreatedAt.Format("2006-01-02 15:04"), truncateForCLI(q.Content, 44))
|
||||
}
|
||||
if total > int64(len(preview)) {
|
||||
fmt.Printf(" ...(其余 %d 条略)\n", total-int64(len(preview)))
|
||||
}
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行(可逆:箱主可在后台逐条重新设为公开):")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
result := build().Update("is_private", true)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(result.Error, "hide public questions")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_id": user.ID,
|
||||
"affected": result.RowsAffected,
|
||||
"only_reported": onlyReported,
|
||||
}).Warn("公开回答已批量转为私密")
|
||||
|
||||
// 举报闭环:被隐藏的内容等同于已处理,对应的待处理举报一并标记为成立,
|
||||
// 否则这些举报会继续计入告警阈值(详见 internal/db/report.go 的说明)。
|
||||
accepted := database.WithContext(ctx.Context).
|
||||
Model(&db.ContentReport{}).
|
||||
Where("question_id IN (?)", build().Select("id")).
|
||||
Where("status = ?", db.ReportStatusPending).
|
||||
Update("status", db.ReportStatusAccepted)
|
||||
if accepted.Error != nil {
|
||||
logrus.WithError(accepted.Error).Warn("标记举报为已处理时异常(内容隐藏已生效,可稍后手动处理)")
|
||||
} else if accepted.RowsAffected > 0 {
|
||||
fmt.Printf("同时把 %d 条待处理举报标记为已处理\n", accepted.RowsAffected)
|
||||
}
|
||||
|
||||
fmt.Printf("完成:%d 条已转为私密\n", result.RowsAffected)
|
||||
fmt.Println(strings.TrimSpace(`
|
||||
注意:这只改变可见性,不改变回答内容;箱主仍可在自己的后台逐条改回公开。
|
||||
如果目的是「停止分发」,还需要配合:TamaBox restrict --domain <domain> --level L1 --days N`))
|
||||
return nil
|
||||
}
|
||||
@@ -65,6 +65,7 @@ var Reports = &cli.Command{
|
||||
&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,
|
||||
}
|
||||
@@ -228,6 +229,9 @@ func runReports(ctx *cli.Context) error {
|
||||
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"))
|
||||
}
|
||||
@@ -371,6 +375,52 @@ func printPendingReports(ctx context.Context, database *gorm.DB, limit int) erro
|
||||
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", " ")
|
||||
|
||||
Reference in New Issue
Block a user