diff --git a/.workbuddy/memory/2026-10-19.md b/.workbuddy/memory/2026-10-19.md index c64c168..de89a78 100644 --- a/.workbuddy/memory/2026-10-19.md +++ b/.workbuddy/memory/2026-10-19.md @@ -63,3 +63,18 @@ hide-public、ban/restore、典型处置流程、排错表、明确不做的事 `go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。 遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。 + +## 第三轮:解除处置也发通知 + +- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选, + 解除通知不传该字段就整段省略。 +- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名; + note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。 +- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到), + 撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。 +- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。 +- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。 + +实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、 +**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。 +`go build ./...`、db/i18n/templates 测试、checktpl 全绿。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 2be95b3..3bbaffe 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -68,6 +68,8 @@ - `restrict` 默认给箱主发处置通知邮件(按 `users.language`,模板 `mail/restriction-notice.html`), **只含级别名/到期/规则链接,不含 `--reason`**;`--no-notify` 可跳过(L1 静默止血用)。 `--days > 90` 必须加 `--force`(防手误),交互模式还要再输一次级别名。 + `unrestrict` 也会发解除通知(同模板、无申诉段),双方都有 `--no-notify`。 + ⚠️ **到期自动解除不发信**(读表比较时间,无调度器)。 ## GORM diff --git a/docs/cli.md b/docs/cli.md index 9542368..8ac4e3e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -58,7 +58,7 @@ pg_dump -Fc > before-$(date +%F-%H%M).dump |---|---|---|---| | `reports` | 举报取证 / 待处理列表 / 全站统计 | 否 | 处置前看证据 | | `restrict` | 施加处置(L1–L4) | 是 | 停止分发、冻结发布、停用页面 | -| `unrestrict` | 撤销全部生效中处置 | 是 | 解封、误操作回滚 | +| `unrestrict` | 撤销全部生效中处置并发解除通知 | 是 | 解封、误操作回滚 | | `hide-public` | 批量把公开回答转为私密 | 是 | 清理存量挂人内容 | | `ban` | 永久封禁(删用户+内容) | 是(不可逆) | 严重违规账号 | | `restore` | 恢复已停用账号 | 是 | 用户申诉后恢复 | @@ -212,13 +212,18 @@ pg_dump -Fc > before-$(date +%F-%H%M).dump ```bash ./tamabox unrestrict --domain -./tamabox unrestrict --email +./tamabox unrestrict --email --no-notify ``` - 把该用户**所有生效中**的处置写 `revoked_at`(保留记录,不删除,审计可追溯)。 - 幂等:没有生效中处置时输出「没有生效中的处置,无需操作」。 - 撤销后:页面/发布/分享卡立即恢复;`hide-public` 已经转为私密的内容**不会**自动变回公开, 需要箱主自己逐条「设为公开」。 +- **会给箱主发一封「限制已解除」通知**(四语言,与处置通知同一模板,无申诉段), + 正文里明确写了「私密内容不会自动恢复」。`--no-notify` 可跳过。 + +⚠️ **到期自动解除不会发信**:那个判定是读表时比较 `expires_at`,没有定时任务。 +如果希望「到期也通知」,只能靠 `unrestrict` 手工解封,或将来加个调度器。 --- @@ -281,7 +286,7 @@ pg_dump -Fc > before-$(date +%F-%H%M).dump 若判断通知会导致其转移阵地、需要先静默收口,加 `--no-notify`。 5. **清存量**:`hide-public --domain --only-reported`。 6. **观察**:7 天内复发 → 升 L2/L3;双方互挂 → **两边同级别处理,不选边**。 -7. **收尾**:到期自动解除;需要提前解封用 `unrestrict`。 +7. **收尾**:到期自动解除(不发信);需要提前解封用 `unrestrict`(会给箱主发「已解除」通知)。 8. **留痕**:`reports --domain ` 的历史处置段落就是处置记录(谁、何时、级别、原因)。 判定「挂人」的三要件(同时成立才处理): diff --git a/internal/cmd/restrict.go b/internal/cmd/restrict.go index c16bf09..c74b92e 100644 --- a/internal/cmd/restrict.go +++ b/internal/cmd/restrict.go @@ -55,6 +55,7 @@ var Unrestrict = &cli.Command{ 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: "no-notify", Usage: "Do not email the lift notice to the user"}, }, Action: runUnrestrict, } @@ -247,23 +248,65 @@ func notifyRestrictionOwner(user *db.User, level string, expires time.Time) { RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules", Appeal: i18n.T(lang, "mail.restriction.appeal"), } - // 加超时:SMTP 不可达时 gomail 的裸 Dial 会一直等到系统 TCP 超时(可达两分钟), - // 运营者站在终端前会以为命令卡死。超时只影响通知,不影响已写入的处置。 + reportNoticeResult(user, sendNotice(notice), "处置已生效") +} + +// notifyRestrictionLifted 告知箱主「限制已解除」。 +// +// 与处置通知同等重要:只通知「你被罚了」而不通知「已经解除」,用户会一直以为还在限制里, +// 既不敢发内容,也可能反复来问。这封信也顺带说明「私密内容不会自动恢复」—— +// 否则他会以为解禁后一切照旧。 +func notifyRestrictionLifted(user *db.User, level string) { + 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.unrestrict.heading"), + Greeting: i18n.T(lang, "mail.restriction.greeting", user.Name), + Body: i18n.T(lang, "mail.unrestrict.body", user.Domain, levelDesc), + Note: i18n.T(lang, "mail.unrestrict.note"), + RulesText: i18n.T(lang, "mail.restriction.rules"), + RulesURL: strings.TrimRight(conf.App.ExternalURL, "/") + "/rules", + // 解除通知不需要申诉入口(Appeal 留空,模板会整段省略)。 + } + reportNoticeResult(user, sendNotice(notice), "解除已生效") +} + +// sendNotice 发送一封通知邮件,带 20 秒超时。 +// +// 为什么要超时:gomail 这版没有 Timeout 字段,裸 Dial 会一直等到系统 TCP 超时(可达两分钟), +// 运营者站在终端前会以为命令卡死。通知失败不影响已经写入的处置结果。 +func sendNotice(notice mail.RestrictionNotice) error { 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 - } + return err case <-time.After(20 * time.Second): - logrus.WithField("user_id", user.ID).Warn("处置通知邮件发送超时(处置已生效)") - fmt.Println("⚠️ 通知邮件发送超时(处置已生效)。请检查 SMTP 配置。") + return errors.New("发送通知邮件超时") + } +} + +// reportNoticeResult 统一输出通知结果(成功/失败都不影响已生效的处置)。 +func reportNoticeResult(user *db.User, err error, action string) { + if err != nil { + logrus.WithError(err).WithField("user_id", user.ID).Warn("通知邮件发送失败(" + action + ")") + fmt.Printf("⚠️ 通知邮件发送失败(%s)。请检查 [mail] 配置,或用 --no-notify 跳过通知。\n", action) return } - fmt.Printf("已发送处置通知邮件至 %s(语言 %s)\n", user.Email, i18n.Resolve(lang)) + lang := user.Language + if lang == "" { + lang = conf.App.DefaultLang + } + fmt.Printf("已发送通知邮件至 %s(语言 %s)\n", user.Email, i18n.Resolve(lang)) } func runUnrestrict(ctx *cli.Context) error { @@ -277,7 +320,14 @@ func runUnrestrict(ctx *cli.Context) error { return err } - affected, err := db.NewRestrictionsStore(database).RevokeActive(ctx.Context, user.ID) + store := db.NewRestrictionsStore(database) + // 先取级别:撤销之后就读不到了,而通知邮件需要写明解除的是哪一级。 + level, _, err := store.ActiveLevel(ctx.Context, user.ID) + if err != nil { + return errors.Wrap(err, "query active restriction") + } + + affected, err := store.RevokeActive(ctx.Context, user.ID) if err != nil { return errors.Wrap(err, "revoke restrictions") } @@ -287,8 +337,12 @@ func runUnrestrict(ctx *cli.Context) error { fmt.Println("没有生效中的处置,无需操作") return nil } - logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected}).Info("已撤销处置") + logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected, "level": level}).Info("已撤销处置") fmt.Printf("已撤销 %d 条生效中的处置\n", affected) + + if !ctx.Bool("no-notify") && level != "" { + notifyRestrictionLifted(user, string(level)) + } return nil } diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index d2d4e65..9c8fbb0 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -440,6 +440,11 @@ var enDict = map[string]string{ "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.", + // 解除处置通知(同一封模板,文案不同) + "mail.unrestrict.heading": "Your question box restriction has been lifted", + "mail.unrestrict.body": "The restriction on your question box @%s (%s) has been lifted and the related features are available again.", + "mail.unrestrict.note": "The enforcement record is kept for audit. Content that was set private is not restored automatically; set it back to public item by item from your dashboard.", + // 处置级别名称(邮件与文案共用) "restriction.level.L1": "Stop distribution", "restriction.level.L2": "Narrow publishing", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 435fad0..3018bc7 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -440,6 +440,11 @@ var jaDict = map[string]string{ "mail.restriction.rules": "コミュニティルールと対応方針を見る", "mail.restriction.appeal": "誤りだと思われる場合は、フッターの「管理人に連絡」からご連絡ください。確認のうえ対応します。", + // 解除处置通知(同一封模板,文案不同) + "mail.unrestrict.heading": "質問箱の制限が解除されました", + "mail.unrestrict.body": "あなたの質問箱 @%s の制限(%s)が解除され、関連する機能が利用可能になりました。", + "mail.unrestrict.note": "対応の記録は監査用に保持されます。非公開にされた内容は自動では戻りません。管理画面から個別に公開へ戻してください。", + // 处置级别名称(邮件与文案共用) "restriction.level.L1": "配信停止", "restriction.level.L2": "公開の縮小", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 5037185..ea8242d 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -440,6 +440,11 @@ var zhCNDict = map[string]string{ "mail.restriction.rules": "查看社区规则与处置说明", "mail.restriction.appeal": "如果你认为处置有误,请通过站点页脚的「找站长」联系我们,我们会复核。", + // 解除处置通知(同一封模板,文案不同) + "mail.unrestrict.heading": "你的提问箱限制已解除", + "mail.unrestrict.body": "你的提问箱 @%s 的限制(%s)已解除,相关功能已恢复。", + "mail.unrestrict.note": "处置记录会保留用于审计;此前被转为私密的内容不会自动恢复,需要你在后台逐条重新设为公开。", + // 处置级别名称(邮件与文案共用) "restriction.level.L1": "停止分发", "restriction.level.L2": "收窄公开", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index c21d213..b67ba79 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -440,6 +440,11 @@ var zhTWDict = map[string]string{ "mail.restriction.rules": "檢視社群規則與處置說明", "mail.restriction.appeal": "如果你認為處置有誤,請透過頁尾的「找站長」聯絡我們,我們會複核。", + // 解除处置通知(同一封模板,文案不同) + "mail.unrestrict.heading": "你的提問箱限制已解除", + "mail.unrestrict.body": "你的提問箱 @%s 的限制(%s)已解除,相關功能已恢復。", + "mail.unrestrict.note": "處置紀錄會保留用於稽核;先前被轉為私密的內容不會自動恢復,需要你在後台逐條重新設為公開。", + // 处置级别名称(邮件与文案共用) "restriction.level.L1": "停止分發", "restriction.level.L2": "收窄公開", diff --git a/templates/mail/restriction-notice.html b/templates/mail/restriction-notice.html index 78e93db..6fa49af 100644 --- a/templates/mail/restriction-notice.html +++ b/templates/mail/restriction-notice.html @@ -38,9 +38,11 @@ style="color: #1a73e8; text-decoration: none;">{{.rulesText}} + {{if .appeal}}
{{.appeal}}
+ {{end}}