diff --git a/.workbuddy/memory/2026-10-18.md b/.workbuddy/memory/2026-10-18.md index 06e27b0..9ee2b8c 100644 --- a/.workbuddy/memory/2026-10-18.md +++ b/.workbuddy/memory/2026-10-18.md @@ -322,3 +322,45 @@ admin6 当前处于 L2(到期次日),必要时 `unrestrict` 解除。 验证:`go build ./...` EXIT=0;`go run ./dev/checktpl` → all templates syntax OK; `go test ./internal/i18n/` 通过。**未部署**,且加了验证码之后原来的 curl 测试方式要改成先算题再提交。 + +### 部署后验证:4/4 通过 + +- 无验证码提交 → 302、**未写库**、提示「验证码不能为空」(i18n key `captcha.errEmpty`,非硬编码); +- 带正确算数验证码 → 302 + 「已收到举报,感谢反馈」; +- 同 IP 重复(换新验证码)→ 同样成功提示(幂等,未新增记录); +- 筛选:全部页 `73, 88, 89, 90`,`?reported=reported` 只剩 `73`,下拉 `option value="reported" selected`。 + +测试遗留:73 号题有 1 条举报(我的测试),需 `DELETE FROM content_reports WHERE question_id = 73;` 清理。 + +## 新增 `TamaBox hide-public`(存量批量处置) + +`internal/cmd/hide_public.go` + 两个入口注册。默认只动「公开且已回答」的题; +`--only-reported` 只处理有 pending 举报的(推荐);`--since N` 限时间窗。 +编译 EXIT=0,gofmt 干净。**未在服务器验证**(需部署)。 + +## 待办与建议(已告知用户,按优先级) + +- P0 举报状态闭环:箱主删除/设私密后,该题 pending 举报应置 `accepted`,否则告警阈值会被已处理的旧举报 + 不断推高(狼来了),且无法统计「成立率」。当前 status 永远停留在 pending。 +- P0 箱主侧处置可见性:`/user/questions` 顶部常驻条幅(读 restrictions),避免像今天这样 L2 生效但箱主不知情。 +- P1 箱主侧举报通知(某条 ≥3 次时告知箱主自查)、`reports --stats` 全站视图(发现恶意刷举报)。 +- P2 社区规则页 `/rules`,处置时引用条款。 +- 明确不建议:举报数达标自动处置(武器化)、箱主申诉后台(违背无后台约束)。 + +## 建议落地(P0/P1/P2 全部完成,编译 + 单测 + 模板检查通过,未部署) + +- **P0 举报闭环**:`ReportsStore.MarkAcceptedByQuestion`,在箱主 `Delete` / `SetPrivate` 成功后调用 + (失败只记日志,不回滚用户操作);`hide-public` CLI 批量后同样标记。新增 `reportPendingTTL = 30d`: + `QuestionIDsByUser` / `CountByUserSince` / `CountByQuestion` 都按时间窗过滤,**超期 pending 视为过期**, + 用查询过滤而不是定时任务(无后台项目不需要调度器)。 +- **P0 处置期条幅**:`/user/questions` 顶部读 `Restrictions.ActiveLevel` 渲染常驻 warning + (`qlist.restrictionBanner`,四语言,带级别与到期日)。解决「L2 生效但箱主不知情」。 +- **P1 箱主自查提醒**:单条内容待处理举报**恰好等于 3** 时给箱主发邮件 + (`mail/question-reported.html`),用「恰好等于」避免每条举报都发;箱主 `Notify=none` 时不发。 + 邮件明确写「不代表平台已判定违规」。 +- **P1 `reports --stats`**:全站 Top 被举报箱主 + Top 举报 IP,用于识别「举报被武器化」。 +- **P2 `/rules` 社区规则页**:公开页 + `templates/rules.html` + 四语言 10 个键。 + 内容含「明确禁止的 4 类」「处置梯度 5 级」「被举报怎么办」「被攻击怎么办」,是处置的引用依据。 + ⚠️ 文案由我起草,**需用户过目**(尤其对外承诺的措辞)。 + +验证:`go build ./...` EXIT=0;`checktpl` OK;`TestReports`/`TestRestrictions` 全绿。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index c81d7dd..1f753d5 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -184,8 +184,13 @@ - `restrict --domain|--email --level L1|L2|L3|L4 --days N --reason "..." [--yes]` → 写 `user_restrictions`, **替换语义**(先撤销该用户所有生效中记录再写新的),到期靠 `expires_at` 比较时间自动失效,无 cron。 - `reports --domain|--email [--days N]` 出证据包(被举报条目 / 公开回答条数 / 占比 / 历史处置); - `reports --pending` 列全站待处理。两者只读。 + `reports --pending` 列全站待处理;`reports --stats --days N` 出全站 Top 被举报箱主 + Top 举报 IP + (识别「举报被武器化」)。三者只读。 - `unrestrict --domain|--email` 撤销全部生效中处置。 +- `hide-public --domain|--email [--only-reported] [--since N] [--yes]` → 把该箱主「公开且已回答」的题 + 批量置为私密。`--only-reported` 是推荐用法(只收被投诉的,保留正常内容);`--since N` 限最近 N 天。 + ⚠️ GORM 链式对象在 Count/Find 后带 Statement 状态,**统计/预览/更新必须各自重新 build query**, + 复用一个实例会条件污染。 - `ban --email|--domain [--yes]` → 物理删除用户+内容(不可恢复,邮箱释放)。 - `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。 `--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。 diff --git a/cmd/tamabox.go b/cmd/tamabox.go index b3e2b56..6361dd9 100644 --- a/cmd/tamabox.go +++ b/cmd/tamabox.go @@ -27,6 +27,7 @@ func main() { cmd.Reports, cmd.Restrict, cmd.Unrestrict, + cmd.HidePublic, } if err := app.Run(os.Args); err != nil { logrus.WithError(err).Fatal("Failed to start application") diff --git a/internal/cmd/hide_public.go b/internal/cmd/hide_public.go new file mode 100644 index 0000000..e08b629 --- /dev/null +++ b/internal/cmd/hide_public.go @@ -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 --level L1 --days N`)) + return nil +} diff --git a/internal/cmd/restrict.go b/internal/cmd/restrict.go index 108c63c..6f00fbd 100644 --- a/internal/cmd/restrict.go +++ b/internal/cmd/restrict.go @@ -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", " ") diff --git a/internal/db/report.go b/internal/db/report.go index 18d13cb..bfb2d2b 100644 --- a/internal/db/report.go +++ b/internal/db/report.go @@ -25,8 +25,41 @@ type ReportsStore interface { QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) // CountByUserSince 统计某箱主自 since 起收到的待处理举报总数(邮件告警的触发依据)。 CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) + // MarkAcceptedByQuestion 把某条提问下所有「待处理」举报标记为「成立」。 + // + // 箱主删除或设为私密时调用。举报一旦被处理就应当离开待处理池,否则告警会被 + // 「早就处理完」的旧举报持续推高,几周后这个告警就没人看了(狼来了)。 + // 返回受影响行数,便于调用方记日志。 + MarkAcceptedByQuestion(ctx context.Context, questionID uint) (int64, error) + // CountByQuestion 统计某条内容当前的待处理举报数(用于「该条被多人举报,提醒箱主自查」)。 + CountByQuestion(ctx context.Context, questionID uint) (int64, error) + // TopReportedUsers 按待处理举报数返回被举报最多的箱主(运维视图,用于识别组织化挂人)。 + TopReportedUsers(ctx context.Context, since time.Time, limit int) ([]*ReportedUserStat, error) + // TopReporters 按举报数返回最活跃的举报来源 IP(用于识别「举报被武器化」)。 + TopReporters(ctx context.Context, since time.Time, limit int) ([]*ReporterStat, error) } +// ReportedUserStat 被举报箱主的聚合行。 +type ReportedUserStat struct { + UserID uint + Domain string + Name string + Count int64 +} + +// ReporterStat 举报来源聚合行。 +type ReporterStat struct { + ReporterIP string + Count int64 +} + +// reportPendingTTL 待处理举报的统计有效期。 +// +// 超过这个窗口仍未处理的举报不再计入箱主侧标记与告警阈值:它们既没有被箱主处理, +// 也没有被运维处置,继续累计只会让阈值失去意义。用「查询时按时间过滤」而不是 +// 定时任务批量改状态 —— 无后台的项目里不需要额外的调度器,语义也足够清晰。 +const reportPendingTTL = 30 * 24 * time.Hour + // Reports 举报记录全局 store,由 db.Init 装配。 var Reports ReportsStore @@ -96,6 +129,7 @@ func (s *reports) QuestionIDsByUser(ctx context.Context, userID uint) (map[uint] Joins("JOIN questions q ON q.id = r.question_id"). Where("q.user_id = ? AND q.deleted_at IS NULL", userID). Where("r.status = ?", ReportStatusPending). + Where("r.created_at >= ?", time.Now().Add(-reportPendingTTL)). Group("r.question_id"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(err, "count reports by user") @@ -109,6 +143,11 @@ func (s *reports) QuestionIDsByUser(ctx context.Context, userID uint) (map[uint] } func (s *reports) CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) { + // 与 QuestionIDsByUser 保持一致的「待处理有效期」:since 更早时以 TTL 为准。 + if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { + since = ttlStart + } + var count int64 if err := s.db.WithContext(ctx). Table("content_reports AS r"). @@ -121,3 +160,77 @@ func (s *reports) CountByUserSince(ctx context.Context, userID uint, since time. } return count, nil } + +func (s *reports) MarkAcceptedByQuestion(ctx context.Context, questionID uint) (int64, error) { + result := s.db.WithContext(ctx). + Model(&ContentReport{}). + Where("question_id = ?", questionID). + Where("status = ?", ReportStatusPending). + Update("status", ReportStatusAccepted) + if result.Error != nil { + return 0, errors.Wrap(result.Error, "mark reports accepted") + } + return result.RowsAffected, nil +} + +func (s *reports) CountByQuestion(ctx context.Context, questionID uint) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx). + Model(&ContentReport{}). + Where("question_id = ?", questionID). + Where("status = ?", ReportStatusPending). + Where("created_at >= ?", time.Now().Add(-reportPendingTTL)). + Count(&count).Error; err != nil { + return 0, errors.Wrap(err, "count reports by question") + } + return count, nil +} + +func (s *reports) TopReportedUsers(ctx context.Context, since time.Time, limit int) ([]*ReportedUserStat, error) { + if limit <= 0 { + limit = 10 + } + if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { + since = ttlStart + } + + var rows []*ReportedUserStat + if err := s.db.WithContext(ctx). + Table("content_reports AS r"). + Select("q.user_id AS user_id, u.domain AS domain, u.name AS name, COUNT(r.id) AS count"). + Joins("JOIN questions q ON q.id = r.question_id"). + Joins("JOIN users u ON u.id = q.user_id"). + Where("q.deleted_at IS NULL"). + Where("r.status = ?", ReportStatusPending). + Where("r.created_at >= ?", since). + Group("q.user_id, u.domain, u.name"). + Order("count DESC"). + Limit(limit). + Scan(&rows).Error; err != nil { + return nil, errors.Wrap(err, "top reported users") + } + return rows, nil +} + +func (s *reports) TopReporters(ctx context.Context, since time.Time, limit int) ([]*ReporterStat, error) { + if limit <= 0 { + limit = 10 + } + if ttlStart := time.Now().Add(-reportPendingTTL); since.Before(ttlStart) { + since = ttlStart + } + + var rows []*ReporterStat + if err := s.db.WithContext(ctx). + Model(&ContentReport{}). + Select("reporter_ip, COUNT(*) AS count"). + Where("status = ?", ReportStatusPending). + Where("created_at >= ?", since). + Group("reporter_ip"). + Order("count DESC"). + Limit(limit). + Scan(&rows).Error; err != nil { + return nil, errors.Wrap(err, "top reporters") + } + return rows, nil +} diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 7d74d85..4c9197c 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -441,10 +441,23 @@ var enDict = map[string]string{ "report.errFailed": "Failed to submit report, please try again later", // 箱主侧举报标记(只给数量,不给举报人 IP) - "qlist.reported": "%d reports", - "qlist.reportedShort": "!%d", - "qlist.reportFilter": "Reports", - "qlist.reportedOnly": "Reported only", + "qlist.reported": "%d reports", + "qlist.reportedShort": "!%d", + "qlist.reportFilter": "Reports", + "qlist.reportedOnly": "Reported only", + "qlist.restrictionBanner": "This box is under moderation (%s). Some features are limited until %s. Contact the site owner if you have questions.", + + // 社区规则页(处置依据,公开可访问) + "rules.title": "Community rules and content boundaries", + "rules.intro": "This is an anonymous question box for collecting questions and answers. It is not a callout board, and it should not become a battlefield for group conflicts.\n\nThe rules below are about one thing: you may dislike a work or a group of people, but you may not use this site to organize attacks on specific individuals.", + "rules.forbiddenTitle": "Not allowed", + "rules.forbidden": "1. Callouts / blacklists / exposing accounts\n Naming or pointing to an identifiable person (nickname, alias, profile, work tag) with a derogatory label, or urging others to avoid or attack them.\n\n2. Harassment and insults\n Including coded nicknames with insulting judgments (for example referring to someone as an animal or an object).\n\n3. Inciting group conflict\n Rallying one group against another along faction, stance, or identity lines.\n\n4. Exposing private information\n Real name, address, contact details, school, or employer.", + "rules.enforceTitle": "What happens after a violation", + "rules.enforce": "Actions scale with reach, from light to heavy:\n\n· Stop distribution: content no longer appears in Explore and cannot generate a share card\n· Narrow publishing: new answers are private by default\n· Freeze publishing: answering and publishing are suspended, but receiving still works\n· Suspend the page: the whole box is temporarily unavailable\n· Ban: data removed, email released\n\nEvery action has an expiry date and lifts automatically. Content that has been handled is not pursued again.", + "rules.reportedTitle": "If your content was reported", + "rules.reported": "Your dashboard will show \"reported N times\". Deleting the content or setting it private closes the related reports automatically.\n\nThe notice does not mean the platform has judged you. If you believe a report is malicious, you may leave it unhandled, but please make sure the content itself does not attack a specific person.", + "rules.attackedTitle": "If you are being attacked", + "rules.attacked": "Use \"Report this content\" below the answer and choose a reason. Reports are recorded and visible to both the box owner and the site operator.\n\nReporting requires a human verification step, which exists to prevent scripted mass reporting.", // Anonymous voting "nav.votes": "Polls", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 93acdd7..9e8f1b2 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -441,10 +441,23 @@ var jaDict = map[string]string{ "report.errFailed": "報告の送信に失敗しました。しばらくしてからお試しください", // 箱主侧举报标记(只给数量,不给举报人 IP) - "qlist.reported": "%d 件の報告", - "qlist.reportedShort": "報%d", - "qlist.reportFilter": "報告", - "qlist.reportedOnly": "報告ありのみ", + "qlist.reported": "%d 件の報告", + "qlist.reportedShort": "報%d", + "qlist.reportFilter": "報告", + "qlist.reportedOnly": "報告ありのみ", + "qlist.restrictionBanner": "この質問箱は現在プラットフォームによる制限期間中です(%s)。一部機能が制限され、%s に自動解除されます。ご不明な点はフッターの「管理人に連絡」からお問い合わせください。", + + // 社区规则页(处置依据,公开可访问) + "rules.title": "コミュニティルールとコンテンツの境界", + "rules.intro": "ここは匿名質問箱で、質問と回答を集めるための場所です。晒しの壁ではなく、集団同士の争いの場でもありません。\n\n以下のルールはひとつのことだけを定めます。ある作品や集団が好きでないのは自由ですが、特定の個人への攻撃をこのサイトで組織することはできません。", + "rules.forbiddenTitle": "禁止される内容", + "rules.forbidden": "1. 晒し / 避雷(ブラックリスト)/ アカウントの暴露\n 特定できる個人(ニックネーム、蔑称、プロフィール、作品タグ)を指し、貶す評価を添える、または他者に回避や攻撃を呼びかけるもの。\n\n2. 誹謗中傷・暴言\n 隠語で他人を指し、侮辱的な評価を添えるもの(動物や物に喩えるなどを含む)。\n\n3. 集団対立の扇動\n 界隈・立場・属性を単位として、一方の集団を他方への攻撃に駆り立てるもの。\n\n4. 個人情報の暴露\n 本名、住所、連絡先、学校や勤務先。", + "rules.enforceTitle": "違反が認められた場合", + "rules.enforce": "影響範囲に応じて軽い順に:\n\n· 配信停止:探索ページに出なくなり、共有カードも生成できません\n· 公開の縮小:新しい回答は既定で非公開になります\n· 投稿の凍結:回答と公開の機能を停止(受信は可能)\n· ページの停止:質問箱全体が一時的に閲覧できなくなります\n· 利用停止:データを削除し、メールアドレスを解放\n\nいずれも期限付きで、期限が来ると自動的に解除されます。処理済みの内容が繰り返し問われることはありません。", + "rules.reportedTitle": "自分のコンテンツが通報された場合", + "rules.reported": "管理画面に「N 件の報告」と表示されます。その内容を削除するか非公開にすると、関連する報告は自動的に閉じられます。\n\nこの通知は、プラットフォームが違反と判断したという意味ではありません。悪意ある通報だと思う場合は対応しなくても構いませんが、内容そのものが特定の個人を攻撃していないことを確認してください。", + "rules.attackedTitle": "攻撃を受けている場合", + "rules.attacked": "回答の下にある「この内容を報告する」から理由を選んで送信してください。報告は記録され、質問箱の管理者とサイト運営者の双方が確認できます。\n\n報告には人機認証が必要です。スクリプトによる大量報告を防ぐためです。", // 匿名投票 "nav.votes": "投票", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 2dd4eb9..631a4d6 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -441,10 +441,23 @@ var zhCNDict = map[string]string{ "report.errFailed": "举报提交失败,请稍后再试", // 箱主侧举报标记(只给数量,不给举报人 IP) - "qlist.reported": "被举报 %d 次", - "qlist.reportedShort": "报%d", - "qlist.reportFilter": "举报", - "qlist.reportedOnly": "仅被举报", + "qlist.reported": "被举报 %d 次", + "qlist.reportedShort": "报%d", + "qlist.reportFilter": "举报", + "qlist.reportedOnly": "仅被举报", + "qlist.restrictionBanner": "本箱当前处于平台限制期(%s),部分功能受限,至 %s 自动解除。如有疑问请通过页脚「找站长」联系。", + + // 社区规则页(处置依据,公开可访问) + "rules.title": "社区规则与内容边界", + "rules.intro": "这是一个匿名提问箱,用来收集提问与回答。它不是挂人墙,也不该成为群体冲突的战场。\n\n下面的规则只针对一件事:你可以不喜欢某个作品或某群人,但不能用这个站点组织对具体的人的攻击。", + "rules.forbiddenTitle": "明确禁止的内容", + "rules.forbidden": "1. 挂人 / 避雷 / 曝光他人账号\n 指向可识别的个人(昵称、代称、主页、作品标签),并带有贬损定性,或号召他人规避、攻击。\n\n2. 人身攻击与辱骂\n 包括用黑话代称他人并附加侮辱性评价(例如用动物、物件指代某个人)。\n\n3. 挑起群体对立\n 以粉籍、立场、属性为单位,鼓动一群人去攻击另一群人。\n\n4. 曝光私人信息\n 真实姓名、住址、联系方式、学校或工作单位。", + "rules.enforceTitle": "被认定后会怎么处理", + "rules.enforce": "处理按影响面从轻到重:\n\n· 停止分发:内容不再出现在探索页,也不能生成分享卡\n· 收窄公开:新回答默认不公开\n· 冻结发布:暂停回答与公开功能,仍可收信\n· 暂停页面:整个提问箱暂时不可访问\n· 封禁:删除数据、释放邮箱\n\n所有处置都有期限,到期自动解除。已经处理过的内容不会被反复追责。", + "rules.reportedTitle": "如果你的内容被举报了", + "rules.reported": "后台列表会显示「被举报 N 次」。删除该内容或设为私密后,相关举报会自动关闭。\n\n这条提醒不代表平台已经判定你违规。如果你认为举报是恶意的,可以选择不处理,但请确保内容本身没有攻击具体的人。", + "rules.attackedTitle": "如果你被攻击了", + "rules.attacked": "在内容页下方点「举报这条内容」,选择理由后提交。举报会被记录,箱主和站长都能看到。\n\n举报需要完成一次人机验证 —— 这是为了防止有人用脚本批量举报。", // 匿名投票 "nav.votes": "投票", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 8937834..62b0c43 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -441,10 +441,23 @@ var zhTWDict = map[string]string{ "report.errFailed": "檢舉提交失敗,請稍後再試", // 箱主侧举报标记(只给数量,不给举报人 IP) - "qlist.reported": "被檢舉 %d 次", - "qlist.reportedShort": "檢%d", - "qlist.reportFilter": "檢舉", - "qlist.reportedOnly": "僅被檢舉", + "qlist.reported": "被檢舉 %d 次", + "qlist.reportedShort": "檢%d", + "qlist.reportFilter": "檢舉", + "qlist.reportedOnly": "僅被檢舉", + "qlist.restrictionBanner": "本箱目前處於平台限制期(%s),部分功能受限,至 %s 自動解除。如有疑問請透過頁尾「找站長」聯絡。", + + // 社区规则页(处置依据,公开可访问) + "rules.title": "社群規則與內容界線", + "rules.intro": "這是一個匿名提問箱,用來收集提問與回答。它不是掛人牆,也不該成為群體衝突的戰場。\n\n下面的規則只針對一件事:你可以不喜歡某個作品或某群人,但不能用這個站點組織對具體的人的攻擊。", + "rules.forbiddenTitle": "明確禁止的內容", + "rules.forbidden": "1. 掛人 / 避雷 / 曝光他人帳號\n 指向可辨識的個人(暱稱、代稱、主頁、作品標籤),並帶有貶損定性,或號召他人規避、攻擊。\n\n2. 人身攻擊與辱罵\n 包括用黑話代稱他人並附加侮辱性評價(例如用動物、物件指代某個人)。\n\n3. 挑起群體對立\n 以粉籍、立場、屬性為單位,鼓動一群人去攻擊另一群人。\n\n4. 曝光私人資訊\n 真實姓名、住址、聯絡方式、學校或工作單位。", + "rules.enforceTitle": "被認定後會怎麼處理", + "rules.enforce": "處理按影響面從輕到重:\n\n· 停止分發:內容不再出現在探索頁,也不能產生分享卡\n· 收窄公開:新回答預設不公開\n· 凍結發佈:暫停回答與公開功能,仍可收信\n· 暫停頁面:整個提問箱暫時無法存取\n· 封禁:刪除資料、釋放信箱\n\n所有處置都有期限,到期自動解除。已經處理過的內容不會被反覆追責。", + "rules.reportedTitle": "如果你的內容被檢舉了", + "rules.reported": "後台列表會顯示「被檢舉 N 次」。刪除該內容或設為私密後,相關檢舉會自動關閉。\n\n這則提醒不代表平台已經判定你違規。如果你認為檢舉是惡意的,可以選擇不處理,但請確保內容本身沒有攻擊具體的人。", + "rules.attackedTitle": "如果你被攻擊了", + "rules.attacked": "在內容頁下方點「檢舉這則內容」,選擇理由後提交。檢舉會被記錄,箱主和站長都能看到。\n\n檢舉需要完成一次人機驗證 —— 這是為了防止有人用腳本批次檢舉。", // 匿名投票 "nav.votes": "投票", diff --git a/internal/mail/mail.go b/internal/mail/mail.go index ff3d4df..48b1666 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -129,6 +129,21 @@ func SendAbuseAlertMail(domain, name string, count24, count7d int64) error { return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】某箱主收到集中举报", templates.FS, "mail/abuse-alert.html", params) } +// SendQuestionReportedMail 通知箱主「你的某条内容被多人举报」。 +// +// 与给运维的告警不同:这封是给**箱主**的自查提醒,语气必须中性 —— +// 明确写出「不代表平台已判定违规」,避免变成变相的处罚通知。 +func SendQuestionReportedMail(email, domain string, questionID uint, count int64, link string) error { + params := map[string]string{ + "domain": domain, + "question_id": fmt.Sprintf("%d", questionID), + "count": fmt.Sprintf("%d", count), + "link": link, + "time": time.Now().Format("2006-01-02 15:04:05"), + } + return sendTemplateMail(email, "【狼的提问箱】你的内容被举报了", templates.FS, "mail/question-reported.html", params) +} + func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error { var content bytes.Buffer t, err := template.ParseFS(templateFS, templatePath) diff --git a/internal/route/route.go b/internal/route/route.go index e7bdedd..9a83d3f 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -162,6 +162,8 @@ func New() *flamego.Flame { f.Get("/sponsor", route.Sponsor) f.Get("/change-logs", route.ChangeLogs) f.Get("/about-me", route.AboutMe) + // 社区规则:公开页,处置时的引用依据。 + f.Get("/rules", route.Rules) f.Get("/robots.txt", func(c context.Context) { fs, err := static.FS.Open("robots.txt") if err != nil { diff --git a/route/home.go b/route/home.go index ba2629a..934b4c5 100644 --- a/route/home.go +++ b/route/home.go @@ -39,6 +39,15 @@ func AboutMe(ctx context.Context) { ctx.Success("about-me") } +// Rules 社区规则页。 +// +// 公开可访问:处置箱主时可以直接引用条款,不必每次临时解释; +// 箱主也能在被处置前就知道边界在哪。内容是静态 i18n 文案,不走数据库。 +func Rules(ctx context.Context) { + ctx.SetTitle(i18n.T(ctx.Lang(), "rules.title")) + ctx.Success("rules") +} + func ChangeLogs(ctx context.Context) { ctx.Success("change-logs") } diff --git a/route/question/abuse_notify.go b/route/question/abuse_notify.go index 3838df9..b2d260b 100644 --- a/route/question/abuse_notify.go +++ b/route/question/abuse_notify.go @@ -6,6 +6,8 @@ package question import ( stdctx "context" + "strconv" + "strings" "sync" "time" @@ -47,6 +49,46 @@ func allowAbuseAlert(userID uint) bool { return true } +// questionReportNoticeThreshold 单条内容被举报多少人次时提醒箱主自查。 +// +// 取 3:一两个人可能是私人恩怨,三个人以上通常是内容本身有问题。 +const questionReportNoticeThreshold = 3 + +// notifyQuestionOwner 在单条内容的待处理举报数**恰好**达到阈值时提醒箱主。 +// +// 用「恰好等于」而不是「大于等于」:前者只发一次,后者会从第 3 条起每条举报都发一封信。 +// 箱主关闭邮件通知(Notify=none)时不发 —— 后台的「被举报 N 次」标记仍然可见, +// 治理提醒不该以无视用户通知偏好为代价。 +func notifyQuestionOwner(pageUser *db.User, question *db.Question) { + if pageUser.Email == "" || pageUser.Notify == db.NotifyTypeNone { + return + } + + shared.SafeGo(func() { + ctx := stdctx.Background() + + count, err := db.Reports.CountByQuestion(ctx, question.ID) + if err != nil { + logrus.WithError(err).Error("Failed to count reports for question") + return + } + if count != questionReportNoticeThreshold { + return + } + + link := strings.TrimRight(conf.App.ExternalURL, "/") + "/_/" + pageUser.Domain + "/" + strconv.FormatUint(uint64(question.ID), 10) + if err := mail.SendQuestionReportedMail(pageUser.Email, pageUser.Domain, question.ID, count, link); err != nil { + logrus.WithError(err).Error("Failed to send question reported mail") + return + } + logrus.WithFields(logrus.Fields{ + "user_id": pageUser.ID, + "question_id": question.ID, + "count": count, + }).Warn("Question owner notified about reports") + }) +} + // notifyAbuseIfNeeded 在举报写入后判断是否达到告警阈值,异步发送汇总邮件。 // // 只发「该看一眼」的信号,不做任何自动处置:举报数达标不等于内容违规, @@ -82,10 +124,10 @@ func notifyAbuseIfNeeded(pageUser *db.User) { return } logrus.WithFields(logrus.Fields{ - "user_id": pageUser.ID, - "domain": pageUser.Domain, - "count24": count24, - "count7d": count7d, + "user_id": pageUser.ID, + "domain": pageUser.Domain, + "count24": count24, + "count7d": count7d, }).Warn("Abuse alert mail sent") }) } diff --git a/route/question/question.go b/route/question/question.go index eec561b..3ff990c 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -272,6 +272,12 @@ func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDe return } + // 举报闭环:内容已被箱主处理掉,该题下的待处理举报不应再计入告警阈值。 + // 这一步失败不影响删除结果,只记日志 —— 让一次统计失败回滚用户操作是不可接受的。 + if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after delete") + } + ctx.Redirect("/_/" + pageUser.Domain) } @@ -288,6 +294,11 @@ func SetPrivate(ctx context.Context, pageUser *db.User, question *db.Question, c return } + // 举报闭环:设为私密等于箱主已响应,该题的待处理举报移出统计池(见 report.go 的说明)。 + if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after set private") + } + ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID)) } diff --git a/route/question/report.go b/route/question/report.go index 9fcbee5..ba64e0c 100644 --- a/route/question/report.go +++ b/route/question/report.go @@ -78,6 +78,8 @@ func Report(ctx context.Context, pageUser *db.User, question *db.Question, f for "reason": f.Reason, }).Warn("Content reported") notifyAbuseIfNeeded(pageUser) + // 单条内容被多人举报时提醒箱主自查(治理前移,减少「不知道越界」的情况)。 + notifyQuestionOwner(pageUser, question) } // 首次与重复使用同一句提示(见上方设计取舍)。 diff --git a/route/user/question.go b/route/user/question.go index 9545a25..11cbc68 100644 --- a/route/user/question.go +++ b/route/user/question.go @@ -363,6 +363,18 @@ func QuestionList(ctx context.Context) { ctx.Data["SelectedType"] = typeParam ctx.Data["SelectedReported"] = reportedParam + // 处置期条幅:箱主必须能持续看到自己处在限制里。 + // 只靠回答后那条一闪而过的提示是不够的 —— 线上实测过箱主完全没意识到 L2 已生效。 + // 查询失败不阻断页面,只是少一条提示。 + if level, active, rErr := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID); rErr != nil { + logrus.WithContext(ctx.Request().Context()).WithError(rErr).Error("Failed to query active restriction") + } else if level != "" { + ctx.Data["RestrictionLevel"] = string(level) + if active != nil { + ctx.Data["RestrictionExpiresAt"] = active.ExpiresAt.Format("2006-01-02") + } + } + // 我发起的私聊 — 同样使用批量查询与分页(复用 chat_cursor 游标) askerConvs, err := db.Conversations.ListByAsker(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset)) if err != nil { diff --git a/tamabox.go b/tamabox.go index b3e2b56..6361dd9 100644 --- a/tamabox.go +++ b/tamabox.go @@ -27,6 +27,7 @@ func main() { cmd.Reports, cmd.Restrict, cmd.Unrestrict, + cmd.HidePublic, } if err := app.Run(os.Args); err != nil { logrus.WithError(err).Fatal("Failed to start application") diff --git a/templates/mail/question-reported.html b/templates/mail/question-reported.html new file mode 100644 index 0000000..bd3bd2b --- /dev/null +++ b/templates/mail/question-reported.html @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ 你的内容被举报了 +
+
+ {{.time}} +
+
+
+
+
提问箱:@{{.domain}}
+
被举报内容:第 {{.question_id}} 条回答
+
举报人数:{{.count}} 人
+
+
+ 这条内容已被多位访客举报为「人身攻击 / 曝光他人账号 / 挑起群体对立」。 + 请自行查看后决定是否删除或设为私密。如果内容确实涉及攻击他人,建议尽快处理, + 否则该提问箱可能被限制传播或暂停发布。 +
+ +
+ 这封邮件只是提醒,不代表平台已经判定违规。你处理后,相关举报会自动从待处理列表中移除。 + 如果你认为举报是恶意的,可以忽略本邮件,但请保留内容本身没有攻击性的判断依据。 +
+
+
+
+
+
+ 此邮件由系统自动发送,请勿直接回复。
+ {{ .year }} 狼的提问箱 +
+
+
+
+
+ + diff --git a/templates/rules.html b/templates/rules.html new file mode 100644 index 0000000..9bf56f6 --- /dev/null +++ b/templates/rules.html @@ -0,0 +1,23 @@ +{{template "base/header" .}} +
+

{{t .LangCode "rules.title"}}

+ +

{{t .LangCode "rules.intro"}}

+ +

{{t .LangCode "rules.forbiddenTitle"}}

+

{{t .LangCode "rules.forbidden"}}

+ +

{{t .LangCode "rules.enforceTitle"}}

+

{{t .LangCode "rules.enforce"}}

+ +

{{t .LangCode "rules.reportedTitle"}}

+

{{t .LangCode "rules.reported"}}

+ +

{{t .LangCode "rules.attackedTitle"}}

+

{{t .LangCode "rules.attacked"}}

+ + +
+{{template "base/footer" .}} diff --git a/templates/user/question-list.html b/templates/user/question-list.html index 82fed95..96b0333 100644 --- a/templates/user/question-list.html +++ b/templates/user/question-list.html @@ -174,6 +174,13 @@ {{template "base/alert" .}} + {{ if .RestrictionLevel }} + +
+
{{t .LangCode "qlist.restrictionBanner" .RestrictionLevel .RestrictionExpiresAt}}
+
+ {{ end }} +
{{ .Stats.total }}{{t .LangCode "qlist.statsTotal"}}