// 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 --level L1 --days N`)) return nil }