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", " ")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user