fix v14
This commit is contained in:
@@ -45,3 +45,21 @@ hide-public、ban/restore、典型处置流程、排错表、明确不做的事
|
|||||||
|
|
||||||
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
|
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
|
||||||
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
|
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
|
||||||
|
|
||||||
|
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
|
||||||
|
|
||||||
|
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
|
||||||
|
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
|
||||||
|
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
|
||||||
|
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
|
||||||
|
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
|
||||||
|
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
|
||||||
|
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
|
||||||
|
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
|
||||||
|
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
|
||||||
|
|
||||||
|
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
|
||||||
|
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
|
||||||
|
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
|
||||||
|
|
||||||
|
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却,进程内);单条恰好 3 人举报 → 提醒箱主。
|
- 告警:24h≥8 或 7d≥20 → 邮件到 `[security] security_alert_email`(24h 冷却,进程内);单条恰好 3 人举报 → 提醒箱主。
|
||||||
⚠️ 该邮箱为空时静默跳过,无日志;改配置必须重启。
|
⚠️ 该邮箱为空时静默跳过,无日志;改配置必须重启。
|
||||||
- `/rules` 是处置依据页(四语言);注册页必须勾选 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
|
- `/rules` 是处置依据页(四语言);注册页必须勾选 `agree_rules`(服务端同样校验),改 e2e 脚本要同步。
|
||||||
|
- `restrict` 默认给箱主发处置通知邮件(按 `users.language`,模板 `mail/restriction-notice.html`),
|
||||||
|
**只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 可跳过(L1 静默止血用)。
|
||||||
|
`--days > 90` 必须加 `--force`(防手误),交互模式还要再输一次级别名。
|
||||||
|
|
||||||
## GORM
|
## GORM
|
||||||
|
|
||||||
|
|||||||
+32
-3
@@ -151,8 +151,33 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
| `--domain` / `--email` | 二选一;都填时按 email 查 |
|
| `--domain` / `--email` | 二选一;都填时按 email 查 |
|
||||||
| `--level` | `L1` / `L2` / `L3` / `L4`,默认 L1,大小写敏感 |
|
| `--level` | `L1` / `L2` / `L3` / `L4`,默认 L1,大小写敏感 |
|
||||||
| `--days` | 生效天数,从当前时间起算,必须 > 0 |
|
| `--days` | 生效天数,从当前时间起算,必须 > 0 |
|
||||||
| `--reason` | **必填**,写入审计记录 |
|
| `--reason` | **必填**,写入审计记录(**不会展示给用户**,只用于内部留痕) |
|
||||||
| `--yes` | 跳过 `YES` 确认 |
|
| `--yes` | 跳过 `YES` 确认 |
|
||||||
|
| `--force` | `--days` 超过 90 天时**必须**加,否则直接拒绝执行 |
|
||||||
|
| `--no-notify` | 不给箱主发处置通知邮件(默认发) |
|
||||||
|
|
||||||
|
### 异常时长与二次确认
|
||||||
|
|
||||||
|
`--days` 不设硬上限(长期封禁是真实需求),但超过 **90 天**时:
|
||||||
|
|
||||||
|
- 不加 `--force` → 直接报错退出(多打一个 0 不会被执行)
|
||||||
|
- 交互模式下还要**再输入一次级别名**(如 `L3`)才继续
|
||||||
|
- 脚本里 `--force --yes` 两个显式开关即构成两次确认
|
||||||
|
|
||||||
|
### 处置通知
|
||||||
|
|
||||||
|
处置生效后会自动给箱主发一封邮件(按箱主自己的界面语言,四语言齐全),内容只有:
|
||||||
|
|
||||||
|
- 级别与级别名称(如 L3 冻结发布)
|
||||||
|
- 到期时间
|
||||||
|
- 社区规则链接
|
||||||
|
- 申诉渠道(页脚「找站长」)
|
||||||
|
|
||||||
|
**邮件里不包含 `--reason` 原文,也不引用被举报内容** —— 沿用公告模板的口径:只写级别、期限、依据。
|
||||||
|
|
||||||
|
需要「静默止血」(不惊动对方、避免其转移阵地)时加 `--no-notify`。
|
||||||
|
|
||||||
|
邮件发送失败**不会回滚处置**:只打一条 warn 并在终端提示,处置仍然生效。
|
||||||
|
|
||||||
### 级别语义
|
### 级别语义
|
||||||
|
|
||||||
@@ -178,7 +203,8 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
- **L1 就强制 strict**:L1 只停分发、内容仍能发布,但审核已按最严处理,
|
- **L1 就强制 strict**:L1 只停分发、内容仍能发布,但审核已按最严处理,
|
||||||
正常的吐槽式提问也可能被拦。若线上发现大面积误拒,把 `moderation.EffectiveStrictness`
|
正常的吐槽式提问也可能被拦。若线上发现大面积误拒,把 `moderation.EffectiveStrictness`
|
||||||
改成「L1 沿用原设置」即可(一行)。
|
改成「L1 沿用原设置」即可(一行)。
|
||||||
- **没有任何邮件通知箱主**。当前只有登录后的条幅,纯邮件用户不会知道。
|
- **处置会给箱主发通知邮件**(见上)。若用 `--no-notify` 跳过,箱主只剩登录后的橙色条幅。
|
||||||
|
- 邮件发送依赖 `[mail]` 段的 SMTP 配置;未配置时处置仍会生效,但终端会提示发送失败。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -251,7 +277,8 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
|
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
|
||||||
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
|
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
|
||||||
3. **冷静期**:举报后 24 小时内不作判定(避免被集中举报推着走,也避免情绪化处置)。
|
3. **冷静期**:举报后 24 小时内不作判定(避免被集中举报推着走,也避免情绪化处置)。
|
||||||
4. **止血**:`restrict --level L1 --days 7 --reason "..."`(静默生效,不单独通知)。
|
4. **止血**:`restrict --level L1 --days 7 --reason "..."`。默认会通知箱主;
|
||||||
|
若判断通知会导致其转移阵地、需要先静默收口,加 `--no-notify`。
|
||||||
5. **清存量**:`hide-public --domain <domain> --only-reported`。
|
5. **清存量**:`hide-public --domain <domain> --only-reported`。
|
||||||
6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。
|
6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。
|
||||||
7. **收尾**:到期自动解除;需要提前解封用 `unrestrict`。
|
7. **收尾**:到期自动解除;需要提前解封用 `unrestrict`。
|
||||||
@@ -278,6 +305,8 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
|
|||||||
| 告警邮件一直没收到 | `[security] security_alert_email` 为空(静默跳过,无日志) | 配好并**重启** |
|
| 告警邮件一直没收到 | `[security] security_alert_email` 为空(静默跳过,无日志) | 配好并**重启** |
|
||||||
| 举报数始终是 1 | `trust_proxy` 未按部署环境配置,全部识别为代理 IP | 见 §2.4 |
|
| 举报数始终是 1 | `trust_proxy` 未按部署环境配置,全部识别为代理 IP | 见 §2.4 |
|
||||||
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
|
| 箱主说「我改了但没生效」 | 处置期内严格度被锁定 / L2 禁止设为公开 | 看后台条幅;`reports` 查当前处置 |
|
||||||
|
| `--days 超过 90 天` 被拒绝 | 防手误设计,需要 `--force` | 确认时长后重试,或改用常规天数 |
|
||||||
|
| 处置成功但箱主说没收到邮件 | SMTP 未配 / 发送失败 / 用了 `--no-notify` | 看终端 warn;`[mail]` 配好后可 `restrict` 重发(同级别重设会替换记录) |
|
||||||
|
|
||||||
改完配置**必须重启**才生效(配置只在启动时读取一次)。
|
改完配置**必须重启**才生效(配置只在启动时读取一次)。
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
|
|
||||||
"github.com/tamakyi/TamaBox/internal/conf"
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
|
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
|
||||||
@@ -38,8 +40,10 @@ var Restrict = &cli.Command{
|
|||||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain 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.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.IntFlag{Name: "days", Value: 7, Usage: "Effective days, counted from now"},
|
||||||
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required)"},
|
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required, not shown to the user)"},
|
||||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||||
|
&cli.BoolFlag{Name: "force", Usage: "Required when --days exceeds 90 (abnormal duration)"},
|
||||||
|
&cli.BoolFlag{Name: "no-notify", Usage: "Do not email the restriction notice to the user"},
|
||||||
},
|
},
|
||||||
Action: runRestrict,
|
Action: runRestrict,
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,12 @@ func runRestrict(ctx *cli.Context) error {
|
|||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
return errors.New("--days must be positive")
|
return errors.New("--days must be positive")
|
||||||
}
|
}
|
||||||
|
// 不设硬上限(确实存在长期封禁场景),但超过 90 天必须显式加 --force:
|
||||||
|
// 这类值几乎都是手误(多打一个 0),靠 --yes 挡不住重复执行的历史命令。
|
||||||
|
if days > restrictDaysForceThreshold && !ctx.Bool("force") {
|
||||||
|
return errors.Errorf("--days %d 超过 %d 天,属于异常时长;确认无误请加 --force 重试",
|
||||||
|
days, restrictDaysForceThreshold)
|
||||||
|
}
|
||||||
reason := strings.TrimSpace(ctx.String("reason"))
|
reason := strings.TrimSpace(ctx.String("reason"))
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
return errors.New("--reason is required (recorded for audit)")
|
return errors.New("--reason is required (recorded for audit)")
|
||||||
@@ -152,6 +162,23 @@ func runRestrict(ctx *cli.Context) error {
|
|||||||
fmt.Println("当前处置: 无")
|
fmt.Println("当前处置: 无")
|
||||||
}
|
}
|
||||||
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
|
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
|
||||||
|
if days > restrictDaysForceThreshold {
|
||||||
|
fmt.Printf("⚠️ 超长处置:%d 天,到期 %s(常规处置为 7–30 天)\n",
|
||||||
|
days, time.Now().AddDate(0, 0, days).Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次确认:异常时长时,即使已给 --force,交互模式下还要再输入一次级别。
|
||||||
|
// 脚本里 `--force --yes` 两个显式开关本身就是两次确认,不再额外拦截。
|
||||||
|
if days > restrictDaysForceThreshold && !ctx.Bool("yes") {
|
||||||
|
logrus.Warn("这是超长处置。请再次输入级别名(如 L3)确认:")
|
||||||
|
var confirmLevel string
|
||||||
|
if _, err := fmtScan(&confirmLevel); err != nil {
|
||||||
|
return errors.Wrap(err, "read confirmation")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(confirmLevel) != level {
|
||||||
|
return errors.New("已取消(级别不匹配)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !ctx.Bool("yes") {
|
if !ctx.Bool("yes") {
|
||||||
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
|
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
|
||||||
@@ -182,9 +209,63 @@ func runRestrict(ctx *cli.Context) error {
|
|||||||
}).Info("处置已生效")
|
}).Info("处置已生效")
|
||||||
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
|
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
|
||||||
restriction.ExpiresAt.Format("2006-01-02 15:04"))
|
restriction.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
|
|
||||||
|
if !ctx.Bool("no-notify") {
|
||||||
|
notifyRestrictionOwner(user, level, restriction.ExpiresAt)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restrictDaysForceThreshold 超过这个天数需要 --force。不做硬上限:长期封禁是真实需求。
|
||||||
|
const restrictDaysForceThreshold = 90
|
||||||
|
|
||||||
|
// notifyRestrictionOwner 把处置结果通知箱主(按他自己的界面语言)。
|
||||||
|
//
|
||||||
|
// 为什么必须通知:处置是平台单方面限制用户功能,不告知就变成「暗箱」,
|
||||||
|
// 用户既无法自查也无法申诉。邮件里只给「级别 + 到期时间 + 规则链接」,
|
||||||
|
// 不引用 --reason(那是运维审计备注),也不引用被举报内容原文。
|
||||||
|
//
|
||||||
|
// 发送失败只告警、不回滚:处置已经写入,邮件不可用不应让运营者误以为没生效。
|
||||||
|
func notifyRestrictionOwner(user *db.User, level string, expires time.Time) {
|
||||||
|
if user.Email == "" {
|
||||||
|
fmt.Println("(该账号没有邮箱,跳过处置通知)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := user.Language
|
||||||
|
if lang == "" {
|
||||||
|
lang = conf.App.DefaultLang
|
||||||
|
}
|
||||||
|
levelDesc := i18n.T(lang, "restriction.level."+level)
|
||||||
|
notice := mail.RestrictionNotice{
|
||||||
|
To: user.Email,
|
||||||
|
Heading: i18n.T(lang, "mail.restriction.heading"),
|
||||||
|
Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name),
|
||||||
|
Body: i18n.T(lang, "mail.restriction.body", user.Domain, level, levelDesc, expires.Format("2006-01-02 15:04")),
|
||||||
|
Note: i18n.T(lang, "mail.restriction.note"),
|
||||||
|
RulesText: i18n.T(lang, "mail.restriction.rules"),
|
||||||
|
RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules",
|
||||||
|
Appeal: i18n.T(lang, "mail.restriction.appeal"),
|
||||||
|
}
|
||||||
|
// 加超时:SMTP 不可达时 gomail 的裸 Dial 会一直等到系统 TCP 超时(可达两分钟),
|
||||||
|
// 运营者站在终端前会以为命令卡死。超时只影响通知,不影响已写入的处置。
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- mail.SendRestrictionNoticeMail(notice) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithError(err).WithField("user_id", user.ID).Warn("处置通知邮件发送失败(处置已生效)")
|
||||||
|
fmt.Println("⚠️ 通知邮件发送失败(处置已生效)。请检查 [mail] 配置,或用 --no-notify 跳过通知。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
logrus.WithField("user_id", user.ID).Warn("处置通知邮件发送超时(处置已生效)")
|
||||||
|
fmt.Println("⚠️ 通知邮件发送超时(处置已生效)。请检查 SMTP 配置。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("已发送处置通知邮件至 %s(语言 %s)\n", user.Email, i18n.Resolve(lang))
|
||||||
|
}
|
||||||
|
|
||||||
func runUnrestrict(ctx *cli.Context) error {
|
func runUnrestrict(ctx *cli.Context) error {
|
||||||
database, err := openDatabase()
|
database, err := openDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ var enDict = map[string]string{
|
|||||||
"restricted.title": "This question box is temporarily unavailable",
|
"restricted.title": "This question box is temporarily unavailable",
|
||||||
"restricted.desc": "This question box has been suspended for violating community guidelines.",
|
"restricted.desc": "This question box has been suspended for violating community guidelines.",
|
||||||
|
|
||||||
|
// 处置通知邮件(发给箱主,四语言)
|
||||||
|
"mail.restriction.heading": "Your question box has been restricted",
|
||||||
|
"mail.restriction.greeting": "Hi %s,",
|
||||||
|
"mail.restriction.body": "Your question box @%s has been restricted at level %s (%s) until %s.",
|
||||||
|
"mail.restriction.note": "Some features are unavailable during the restriction. It lifts automatically on expiry, with no action needed.",
|
||||||
|
"mail.restriction.rules": "Read the community rules and enforcement policy",
|
||||||
|
"mail.restriction.appeal": "If you believe this is a mistake, contact the site operator through the footer link and we will review it.",
|
||||||
|
|
||||||
|
// 处置级别名称(邮件与文案共用)
|
||||||
|
"restriction.level.L1": "Stop distribution",
|
||||||
|
"restriction.level.L2": "Narrow publishing",
|
||||||
|
"restriction.level.L3": "Freeze publishing",
|
||||||
|
"restriction.level.L4": "Suspend page",
|
||||||
|
|
||||||
// 举报(单题页,公开访客可用)
|
// 举报(单题页,公开访客可用)
|
||||||
"report.entry": "Report this content",
|
"report.entry": "Report this content",
|
||||||
"report.submit": "Submit",
|
"report.submit": "Submit",
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ var jaDict = map[string]string{
|
|||||||
"restricted.title": "この質問箱は一時的に利用できません",
|
"restricted.title": "この質問箱は一時的に利用できません",
|
||||||
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
|
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
|
||||||
|
|
||||||
|
// 处置通知邮件(发给箱主,四语言)
|
||||||
|
"mail.restriction.heading": "質問箱が制限されました",
|
||||||
|
"mail.restriction.greeting": "%s さん、こんにちは。",
|
||||||
|
"mail.restriction.body": "あなたの質問箱 @%s は制限されました。レベル %s(%s)、%s まで有効です。",
|
||||||
|
"mail.restriction.note": "制限期間中は一部の機能が利用できません。期限が来ると自動的に解除されます。",
|
||||||
|
"mail.restriction.rules": "コミュニティルールと対応方針を見る",
|
||||||
|
"mail.restriction.appeal": "誤りだと思われる場合は、フッターの「管理人に連絡」からご連絡ください。確認のうえ対応します。",
|
||||||
|
|
||||||
|
// 处置级别名称(邮件与文案共用)
|
||||||
|
"restriction.level.L1": "配信停止",
|
||||||
|
"restriction.level.L2": "公開の縮小",
|
||||||
|
"restriction.level.L3": "投稿の凍結",
|
||||||
|
"restriction.level.L4": "アクセス停止",
|
||||||
|
|
||||||
// 举报(单题页,公开访客可用)
|
// 举报(单题页,公开访客可用)
|
||||||
"report.entry": "この内容を報告する",
|
"report.entry": "この内容を報告する",
|
||||||
"report.submit": "送信",
|
"report.submit": "送信",
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ var zhCNDict = map[string]string{
|
|||||||
"restricted.title": "提问箱暂时不可访问",
|
"restricted.title": "提问箱暂时不可访问",
|
||||||
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
|
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
|
||||||
|
|
||||||
|
// 处置通知邮件(发给箱主,四语言)
|
||||||
|
"mail.restriction.heading": "你的提问箱已被限制",
|
||||||
|
"mail.restriction.greeting": "%s,你好:",
|
||||||
|
"mail.restriction.body": "你的提问箱 @%s 已被限制,级别 %s(%s),有效期至 %s。",
|
||||||
|
"mail.restriction.note": "限制期间部分功能不可用,到期后自动解除,无需手工操作。",
|
||||||
|
"mail.restriction.rules": "查看社区规则与处置说明",
|
||||||
|
"mail.restriction.appeal": "如果你认为处置有误,请通过站点页脚的「找站长」联系我们,我们会复核。",
|
||||||
|
|
||||||
|
// 处置级别名称(邮件与文案共用)
|
||||||
|
"restriction.level.L1": "停止分发",
|
||||||
|
"restriction.level.L2": "收窄公开",
|
||||||
|
"restriction.level.L3": "冻结发布",
|
||||||
|
"restriction.level.L4": "暂停访问",
|
||||||
|
|
||||||
// 举报(单题页,公开访客可用)
|
// 举报(单题页,公开访客可用)
|
||||||
"report.entry": "举报这条内容",
|
"report.entry": "举报这条内容",
|
||||||
"report.submit": "提交",
|
"report.submit": "提交",
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ var zhTWDict = map[string]string{
|
|||||||
"restricted.title": "提問箱暫時無法存取",
|
"restricted.title": "提問箱暫時無法存取",
|
||||||
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
|
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
|
||||||
|
|
||||||
|
// 处置通知邮件(发给箱主,四语言)
|
||||||
|
"mail.restriction.heading": "你的提問箱已被限制",
|
||||||
|
"mail.restriction.greeting": "%s,你好:",
|
||||||
|
"mail.restriction.body": "你的提問箱 @%s 已被限制,級別 %s(%s),有效期至 %s。",
|
||||||
|
"mail.restriction.note": "限制期間部分功能無法使用,到期後自動解除,無需手動操作。",
|
||||||
|
"mail.restriction.rules": "檢視社群規則與處置說明",
|
||||||
|
"mail.restriction.appeal": "如果你認為處置有誤,請透過頁尾的「找站長」聯絡我們,我們會複核。",
|
||||||
|
|
||||||
|
// 处置级别名称(邮件与文案共用)
|
||||||
|
"restriction.level.L1": "停止分發",
|
||||||
|
"restriction.level.L2": "收窄公開",
|
||||||
|
"restriction.level.L3": "凍結發佈",
|
||||||
|
"restriction.level.L4": "暫停存取",
|
||||||
|
|
||||||
// 举报(单题页,公开访客可用)
|
// 举报(单题页,公开访客可用)
|
||||||
"report.entry": "檢舉這則內容",
|
"report.entry": "檢舉這則內容",
|
||||||
"report.submit": "提交",
|
"report.submit": "提交",
|
||||||
|
|||||||
@@ -144,6 +144,38 @@ func SendQuestionReportedMail(email, domain string, questionID uint, count int64
|
|||||||
return sendTemplateMail(email, "【狼的提问箱】你的内容被举报了", templates.FS, "mail/question-reported.html", params)
|
return sendTemplateMail(email, "【狼的提问箱】你的内容被举报了", templates.FS, "mail/question-reported.html", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestrictionNotice 处置通知邮件的内容。
|
||||||
|
//
|
||||||
|
// 全部文案由调用方按「箱主自己的界面语言」预先翻译好再传进来:
|
||||||
|
// 邮件模板本身是单语言的,而箱主的语言存在 users.language 里,只能在调用侧解析。
|
||||||
|
//
|
||||||
|
// 刻意不包含 --reason 原文:那是运维的审计备注(可能写得很直白),
|
||||||
|
// 对外只用「级别 + 到期时间 + 规则链接」,与公告模板的口径一致。
|
||||||
|
type RestrictionNotice struct {
|
||||||
|
To string
|
||||||
|
Heading string
|
||||||
|
Greeting string
|
||||||
|
Body string
|
||||||
|
Note string
|
||||||
|
RulesText string
|
||||||
|
RulesURL string
|
||||||
|
Appeal string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRestrictionNoticeMail 告知箱主「你的提问箱已被限制」。
|
||||||
|
func SendRestrictionNoticeMail(n RestrictionNotice) error {
|
||||||
|
params := map[string]string{
|
||||||
|
"heading": n.Heading,
|
||||||
|
"greeting": n.Greeting,
|
||||||
|
"body": n.Body,
|
||||||
|
"note": n.Note,
|
||||||
|
"rulesText": n.RulesText,
|
||||||
|
"rulesURL": n.RulesURL,
|
||||||
|
"appeal": n.Appeal,
|
||||||
|
}
|
||||||
|
return sendTemplateMail(n.To, n.Heading, templates.FS, "mail/restriction-notice.html", params)
|
||||||
|
}
|
||||||
|
|
||||||
func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error {
|
func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error {
|
||||||
var content bytes.Buffer
|
var content bytes.Buffer
|
||||||
t, err := template.ParseFS(templateFS, templatePath)
|
t, err := template.ParseFS(templateFS, templatePath)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const (
|
|||||||
abuseAlertThreshold24h = 8
|
abuseAlertThreshold24h = 8
|
||||||
abuseAlertThreshold7d = 20
|
abuseAlertThreshold7d = 20
|
||||||
abuseAlertCooldown = 24 * time.Hour
|
abuseAlertCooldown = 24 * time.Hour
|
||||||
|
// abuseAlertCooldownMaxEntries 冷却表的清理阈值(超过即回收已过期的条目)。
|
||||||
|
abuseAlertCooldownMaxEntries = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// abuseAlertCooldowns 每个箱主上次发送阈值告警的时间。
|
// abuseAlertCooldowns 每个箱主上次发送阈值告警的时间。
|
||||||
@@ -46,6 +48,17 @@ func allowAbuseAlert(userID uint) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
abuseAlertCooldowns[userID] = time.Now()
|
abuseAlertCooldowns[userID] = time.Now()
|
||||||
|
|
||||||
|
// 惰性清理:条目数超过阈值时删掉已经过冷却窗口的历史项。
|
||||||
|
// 没有这一步这个 map 只增不减(每个箱主一个时间戳,量不大但没有任何回收路径)。
|
||||||
|
if len(abuseAlertCooldowns) > abuseAlertCooldownMaxEntries {
|
||||||
|
now := time.Now()
|
||||||
|
for id, last := range abuseAlertCooldowns {
|
||||||
|
if now.Sub(last) >= abuseAlertCooldown {
|
||||||
|
delete(abuseAlertCooldowns, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/context"
|
"github.com/tamakyi/TamaBox/internal/context"
|
||||||
"github.com/tamakyi/TamaBox/internal/db"
|
"github.com/tamakyi/TamaBox/internal/db"
|
||||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||||
|
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
type conversationDetail struct {
|
type conversationDetail struct {
|
||||||
@@ -370,6 +371,8 @@ func QuestionList(ctx context.Context) {
|
|||||||
logrus.WithContext(ctx.Request().Context()).WithError(rErr).Error("Failed to query active restriction")
|
logrus.WithContext(ctx.Request().Context()).WithError(rErr).Error("Failed to query active restriction")
|
||||||
} else if level != "" {
|
} else if level != "" {
|
||||||
ctx.Data["RestrictionLevel"] = string(level)
|
ctx.Data["RestrictionLevel"] = string(level)
|
||||||
|
// 条幅里给「人话」而不是裸 L2:箱主看不懂级别代号,只会以为被打了个莫名其妙的标记。
|
||||||
|
ctx.Data["RestrictionLevelName"] = i18n.T(ctx.Lang(), "restriction.level."+string(level))
|
||||||
if active != nil {
|
if active != nil {
|
||||||
ctx.Data["RestrictionExpiresAt"] = active.ExpiresAt.Format("2006-01-02")
|
ctx.Data["RestrictionExpiresAt"] = active.ExpiresAt.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta name="format-detection" content="email=no"/>
|
||||||
|
<meta name="format-detection" content="date=no"/>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
|
||||||
|
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
|
||||||
|
<tbody>
|
||||||
|
<tr height="32" style="height: 32px;">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr align="center">
|
||||||
|
<td>
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0"
|
||||||
|
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="8" style="width: 8px;"></td>
|
||||||
|
<td>
|
||||||
|
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
|
||||||
|
align="center">
|
||||||
|
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
|
||||||
|
<div style="font-size: 22px;">
|
||||||
|
{{.heading}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
|
||||||
|
<div>{{.greeting}}</div>
|
||||||
|
|
||||||
|
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fff8e1; border: 1px solid #ffe082; margin: 14px 0;">
|
||||||
|
<div>{{.body}}</div>
|
||||||
|
<div style="margin-top: 6px; color: rgba(0,0,0,0.6); font-size: 13px;">{{.note}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
<a href="{{.rulesURL}}" target="_blank" rel="noopener"
|
||||||
|
style="color: #1a73e8; text-decoration: none;">{{.rulesText}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 14px; color: rgba(0,0,0,0.6); font-size: 13px;">
|
||||||
|
{{.appeal}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; font-size: 12px; color: rgba(0,0,0,0.4); padding-top: 16px;">
|
||||||
|
© {{.year}} TamaBox
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td width="8" style="width: 8px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -406,4 +406,20 @@ func TestRenderUserQuestionList(t *testing.T) {
|
|||||||
if err := renderWithData(t, "user/question-list", base); err != nil {
|
if err := renderWithData(t, "user/question-list", base); err != nil {
|
||||||
t.Fatalf("render user/question-list (keyword) failed: %v", err)
|
t.Fatalf("render user/question-list (keyword) failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处置期条幅:必须显示「人话」级别名而不是裸代码(裸 L2 对箱主没有意义)。
|
||||||
|
base["Keyword"] = ""
|
||||||
|
base["RestrictionLevel"] = "L2"
|
||||||
|
base["RestrictionLevelName"] = i18n.T("zh-CN", "restriction.level.L2")
|
||||||
|
base["RestrictionExpiresAt"] = "2026-11-01"
|
||||||
|
out, err := renderToString(t, "user/question-list", base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render user/question-list (restriction banner) failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "收窄公开") {
|
||||||
|
t.Fatal("restriction banner should show level name (收窄公开), got none")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "2026-11-01") {
|
||||||
|
t.Fatal("restriction banner should show expiry date")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
{{ if .RestrictionLevel }}
|
{{ if .RestrictionLevel }}
|
||||||
<!-- 处置期常驻提示:L1 停分发 / L2 新回答不公开 / L3 禁止发布 -->
|
<!-- 处置期常驻提示:L1 停分发 / L2 新回答不公开 / L3 禁止发布 -->
|
||||||
<div class="uk-alert-warning" uk-alert>
|
<div class="uk-alert-warning" uk-alert>
|
||||||
<div>{{t .LangCode "qlist.restrictionBanner" .RestrictionLevel .RestrictionExpiresAt}} <span class="uk-text-meta">·</span> <a href="/rules">{{t .LangCode "qlist.restrictionRulesLink"}}</a></div>
|
<div>{{t .LangCode "qlist.restrictionBanner" (or .RestrictionLevelName .RestrictionLevel) .RestrictionExpiresAt}} <span class="uk-text-meta">·</span> <a href="/rules">{{t .LangCode "qlist.restrictionRulesLink"}}</a></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user