From 0e0c16f7861e142dae81d10a09eda2007e90af0e Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 15 Sep 2026 12:10:42 +0800 Subject: [PATCH] fix deactivate --- .workbuddy/memory/2026-09-15.md | 33 +++++++++++++++++++++++++++++++++ .workbuddy/memory/MEMORY.md | 16 ++++++++++++++-- internal/i18n/dict_en.go | 6 +++++- internal/i18n/dict_ja.go | 6 +++++- internal/i18n/dict_zh_cn.go | 6 +++++- internal/i18n/dict_zh_tw.go | 6 +++++- internal/route/route.go | 4 ++++ route/user/profile.go | 13 ++++++++++--- templates/user/deactivated.html | 14 ++++++++++++++ 9 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 templates/user/deactivated.html diff --git a/.workbuddy/memory/2026-09-15.md b/.workbuddy/memory/2026-09-15.md index 379e01e..277d111 100644 --- a/.workbuddy/memory/2026-09-15.md +++ b/.workbuddy/memory/2026-09-15.md @@ -250,3 +250,36 @@ GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。** ### ⚠️ 新踩到的 gofmt 陷阱 在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`), 导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。 + +--- + +## 账号停用体验修复:独立成功页(/deactivated) + +### 问题 +停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。 +用户会觉得「被丢回登录页」,下意识以为操作没生效。 + +诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页), +真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。 + +### 方案(用户选「独立成功页(推荐)」) +1. `route/user/profile.go` + - 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。 + - `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。 + - 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。 +2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。 + **必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。 +3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。 + `templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。 +4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome` + (`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。 + 同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。 + +### 验证 +- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**, + 其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。 +- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。 + +### 备注 +- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。 + diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 5dfda01..5114a8f 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -44,8 +44,9 @@ ⚠️ `go env -w` 在本沙箱**会失败**(`cannot find go env config: %AppData% is not defined`,Git Bash 无该变量), 无法持久化,只能每条命令 export。GROUP1(security/censor、conf、form、template、shared、captcha)已验证 EXIT=0(~10s)。 - **行尾:仓库是「混合行尾」,不是统一 CRLF**(`core.autocrlf=true`,提交 blob 为 LF,Git 自动归一,**不污染 diff**)。 - 实测:`internal/db/*`、`internal/mail/mail.go` 是 **LF**;`route/**`、`internal/route/route.go`、 - `internal/db/users.go` 是 **CRLF**。→ 编辑时**保持各文件原行尾**,不要整体转换。 + 实测:`internal/db/*`、`internal/mail/mail.go`、`route/user/profile.go` 是 **LF**; + 而 `internal/route/route.go`、`internal/db/users.go`、`internal/i18n/dict_*.go` 是 **CRLF**。 + → **不要按「route/** 皆 CRLF」这类规律推断**,编辑前逐个文件用 Python 数 `b'\r\n'` 确认。 `gofmt -l` 会把 CRLF 文件**误报**为未格式化(false positive)。 ✅ 改注释/字段后统一用 `dev/gofmt_keep_crlf.py `(原 Temp 版被清,已重建到 `dev/`):跑 gofmt 后按原文件行尾还原, 既应用格式化又保留工作区 CRLF、避免 gofmt -l 噪声。(`grep -c $'\r'` 在 Git Bash 不可靠,查行尾用 Python 数 `b'\r\n'`。) @@ -122,6 +123,17 @@ - ⚠️ `gofmt` 是 **Windows 原生程序,看不到 MSYS 的 `/tmp` 路径**(报 `GetFileAttributesEx ... cannot find`)。 临时比对文件要放到 Windows 可见路径(如 `C:/Users//AppData/Local/Temp/...`)。 +## 会话失效后的落点(闪信 vs 独立页) + +- **终止性操作(账号停用/注销)用「独立公开成功页」,不要落登录页**。 + 既有实现 `/deactivated`(`user.DeactivatedProfile` → `templates/user/deactivated.html`): + 停用后 `Session.Flush()` 会清空会话,若落 `/login`,用户第一直觉是「没生效」。 + → 该路由必须注册在**公开区**(`reqUserSignIn` 之外),否则会被弹回登录页,正好复现要避免的现象。 +- **继续可登录的操作(如改密码后强制重登)才用闪信 + `/login`**:顺序 **先 `Session.Flush()` 再 `SetSuccessFlash()`**—— + Flush 只清 session 数据、保留同一个 session,故 flash 仍能带到下一跳渲染。 +- i18n 键名区分:`deactivate.*` = 停用**表单页**;`deactivated.*` = 停用**成功页**(仅 title/desc/backHome)。 +- `templates/embed.go` 用整目录 `//go:embed ... user ...`,**新增模板文件自动嵌入**,无需改 embed 指令。 + ## 仓库状态 - **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。 备份/回滚一律用 tar.gz(排除 `.git`、`.workbuddy`),放在项目同级目录: diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index c6784a1..bb6c10f 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -419,6 +419,11 @@ var enDict = map[string]string{ "deactivate.confirm": "I confirm I want to deactivate my account", "deactivate.submit": "Deactivate account", + // Deactivated page + "deactivated.title": "Account deactivated", + "deactivated.desc": "Your question box and all data have been deleted. Thank you for using Wolf's Question Box. We hope to see you again.", + "deactivated.backHome": "Back to home", + // Anonymous voting "nav.votes": "Polls", @@ -722,7 +727,6 @@ var enDict = map[string]string{ "admin.flash.blockWordTooLong": "A blocked word cannot exceed 10 characters: %s", "admin.flash.harassmentUpdated": "Anti-harassment settings updated successfully", "admin.flash.blockWordsUpdated": "Blocked words updated", - "admin.flash.deactivated": "Your account has been deactivated. Thank you for using Wolf's Question Box. We hope to see you again 👋🏻", "admin.export.errUser": "Export failed: could not get user information", "admin.export.errQuestion": "Export failed: could not get question information", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 5968abd..e6dcb40 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -419,6 +419,11 @@ var jaDict = map[string]string{ "deactivate.confirm": "アカウントを停止することに同意します", "deactivate.submit": "アカウントを停止", + // アカウント停止完了ページ + "deactivated.title": "アカウントを停止しました", + "deactivated.desc": "質問箱とすべてのデータを削除しました。ご利用いただきありがとうございました。またのお越しをお待ちしています。", + "deactivated.backHome": "ホームに戻る", + // 匿名投票 "nav.votes": "投票", @@ -722,7 +727,6 @@ var jaDict = map[string]string{ "admin.flash.blockWordTooLong": "ブロックワードの長さは 10 文字以内である必要があります:%s", "admin.flash.harassmentUpdated": "迷惑行為対策を更新しました", "admin.flash.blockWordsUpdated": "ブロックワードを更新しました", - "admin.flash.deactivated": "アカウントを停止しました。狼の質問箱をご利用いただきありがとうございます。またのお越しをお待ちしています 👋🏻", "admin.export.errUser": "エクスポート失敗:ユーザー情報の取得に失敗しました", "admin.export.errQuestion": "エクスポート失敗:質問情報の取得に失敗しました", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 166ec01..5d19411 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -419,6 +419,11 @@ var zhCNDict = map[string]string{ "deactivate.confirm": "我确认要停用账号", "deactivate.submit": "停用账号", + // 停用成功页 + "deactivated.title": "账号已停用", + "deactivated.desc": "你的提问箱及所有数据已删除,感谢你的使用,期待未来再见。", + "deactivated.backHome": "返回首页", + // 匿名投票 "nav.votes": "投票", @@ -722,7 +727,6 @@ var zhCNDict = map[string]string{ "admin.flash.blockWordTooLong": "屏蔽词长度不能超过 10 个字符:%s", "admin.flash.harassmentUpdated": "更新防骚扰设置成功", "admin.flash.blockWordsUpdated": "屏蔽词已更新", - "admin.flash.deactivated": "您的账号已停用,感谢您使用 狼的提问箱。期待未来还能再见 👋🏻", "admin.export.errUser": "导出失败:获取用户信息失败", "admin.export.errQuestion": "导出失败:获取问题信息失败", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 8575d31..67e5a0e 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -419,6 +419,11 @@ var zhTWDict = map[string]string{ "deactivate.confirm": "我確認要停用帳號", "deactivate.submit": "停用帳號", + // 停用成功頁 + "deactivated.title": "帳號已停用", + "deactivated.desc": "你的提問箱及所有資料已刪除,感謝你的使用,期待未來再見。", + "deactivated.backHome": "返回首頁", + // 匿名投票 "nav.votes": "投票", @@ -722,7 +727,6 @@ var zhTWDict = map[string]string{ "admin.flash.blockWordTooLong": "屏蔽詞長度不能超過 10 個字元:%s", "admin.flash.harassmentUpdated": "更新防騷擾設定成功", "admin.flash.blockWordsUpdated": "屏蔽詞已更新", - "admin.flash.deactivated": "您的帳號已停用,感謝您使用 狼的提問箱。期待未來還能再見 👋🏻", "admin.export.errUser": "匯出失敗:獲取使用者資訊失敗", "admin.export.errQuestion": "匯出失敗:獲取問題資訊失敗", diff --git a/internal/route/route.go b/internal/route/route.go index cc3dc46..a39aa82 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -284,6 +284,10 @@ func New() *flamego.Flame { f.Get("/explore", user.Explore) + // 账号停用成功页(公开):停用后会话已清空,不能再要求登录, + // 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。 + f.Get("/deactivated", user.DeactivatedProfile) + // 匿名投票参与端(公开):token 链接入口 f.Group("/vote/{token}", func() { f.Get("", vote.Page) diff --git a/route/user/profile.go b/route/user/profile.go index 1a53e43..3177c70 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -225,7 +225,7 @@ func UpdateProfile(ctx context.Context, f form.UpdateProfile) { ctx.SetInternalErrorFlash() } else if passwordChanged { // 密码修改成功且其余资料也保存成功:强制登出重登。 - // 顺序与 DeactivateProfileAction 保持一致:先 Flush 会话再写入 flash,随后跳登录页。 + // 先 Flush 清会话再写入 flash,保证提示能带到登录页显示。 ctx.Session.Flush() ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged")) ctx.Redirect("/login") @@ -431,6 +431,13 @@ func DeactivateProfile(ctx context.Context) { ctx.Success("user/deactivate") } +// DeactivatedProfile 账号停用成功页。停用后会话已被清空、无法再登录, +// 故为公开页,用于明确告知用户「已停用」而不是把其丢回登录页。 +func DeactivatedProfile(ctx context.Context) { + ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title")) + ctx.Success("user/deactivated") +} + // ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息), // 符合可携带权要求,供用户备份/迁移。 func ExportProfileJSON(ctx context.Context) { @@ -594,6 +601,6 @@ func DeactivateProfileAction(ctx context.Context) { return } ctx.Session.Flush() - ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.deactivated")) - ctx.Redirect("/login") + // 跳独立成功页而非登录页:账号已停用、无法再登录,落在登录页易让用户误以为操作未生效。 + ctx.Redirect("/deactivated") } diff --git a/templates/user/deactivated.html b/templates/user/deactivated.html new file mode 100644 index 0000000..79e09a7 --- /dev/null +++ b/templates/user/deactivated.html @@ -0,0 +1,14 @@ +{{template "base/header" .}} +
+
+ +

{{t .LangCode "deactivated.title"}}

+
+ +

{{t .LangCode "deactivated.desc"}}

+ + +
+{{template "base/footer" .}}