From 5ee2dc15401e78d6c3ea6995e8f76ba373737fd4 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 15 Sep 2026 13:10:57 +0800 Subject: [PATCH] fix login error --- .workbuddy/memory/2026-09-15.md | 97 ++++++++ .workbuddy/memory/MEMORY.md | 42 ++++ cmd/tamabox.go | 2 + dev/__pycache__/devcheck-e2e.cpython-313.pyc | Bin 0 -> 16214 bytes dev/devcheck-e2e.py | 249 +++++++++++++++++++ internal/cmd/restore.go | 123 +++++++++ internal/db/users.go | 216 +++++++++++----- internal/db/users_test.go | 115 +++++++++ internal/i18n/dict_en.go | 4 + internal/i18n/dict_ja.go | 4 + internal/i18n/dict_zh_cn.go | 4 + internal/i18n/dict_zh_tw.go | 4 + route/auth/register.go | 6 + templates/user/deactivate.html | 3 + 14 files changed, 801 insertions(+), 68 deletions(-) create mode 100644 dev/__pycache__/devcheck-e2e.cpython-313.pyc create mode 100644 dev/devcheck-e2e.py create mode 100644 internal/cmd/restore.go diff --git a/.workbuddy/memory/2026-09-15.md b/.workbuddy/memory/2026-09-15.md index 277d111..3124c22 100644 --- a/.workbuddy/memory/2026-09-15.md +++ b/.workbuddy/memory/2026-09-15.md @@ -283,3 +283,100 @@ GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。** ### 备注 - `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。 +--- + +## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册 + +用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500: +``` +SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0 +INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505) +ERRO Failed to create new user error="create user: ERROR: duplicate key ..." +``` + +### 根因(本地已实测复现,日志形态与线上一致) +`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**: +`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。 +而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是: +- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行; +- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。 + +复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。 +**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」—— +说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。 +同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。 + +顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`), +数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。 + +### 改动(`internal/db/users.go`) +1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。 +2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。 +3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占, + 返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的) +4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。 + +### ⚠️ 过程中自己踩的 GORM 大坑 +把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」—— +**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**, +SQL 变成 `DELETE FROM follow_ups WHERE AND ` → `字段 conversation_id 不存在 (42703)`。 +✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。 +这条已写进 MEMORY.md。 + +### 验证 +- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。 +- 测试环境实测(脚本 `dev/devcheck-e2e.py`): + - ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常; + - ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました); + - ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。 +- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理: + `tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位); + 用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。 + 清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。 + +--- + +## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」 + +用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。 +经询问确认两点: +1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立); +2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。 + +→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。 + +### 最终实现 +- `internal/db/users.go` + - `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。 + - `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。 + - `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。 + - `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行 + → 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。 + - 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。 + - 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。 +- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。 +- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。 +- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted), + 提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。 +- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`, + 清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。 + +### 🐛 顺带修掉的隐藏问题 +**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。 +现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。 + +### 验证 +- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。 +- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得 + ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、 + `TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。 +- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物): + - `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅ + - `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅ + - `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅ +- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用 + `NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。 +- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」; + 在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。 + + diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 5114a8f..f2f402c 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -107,6 +107,24 @@ - **启动期「历史数据归一」的既有惯例**:在 `db.Init` 的 `AutoMigrate` 之后追加幂等函数 (`backfillFeatureControls` / `reconcileLegacyChatEnabled` / `normalizeChatQuota` / `normalizeHarassmentSetting`), 失败仅告警、不阻断启动。新增此类归一照此模式即可。 +- ⚠️ **事务里复用同一个 `tx` 句柄会串条件**:事务句柄的 `clone == 0`,`tx.Where(A)...; tx.Where(B)...` + 会把 A 的条件累加到 B 的语句上(实测生成 `DELETE FROM follow_ups WHERE AND ` + → `字段 conversation_id 不存在 (42703)`)。**每条语句都必须重新取句柄**:`tx.WithContext(ctx).<链式调用>`。 + (`db.WithContext(ctx)` / `.Session(...)` 返回 clone==1 的新句柄,链式调用才会 clone。) +- ⚠️ **软删除 + 唯一索引 = 唯一槽位被永久占用**。User 的 `email`/`domain`、`Conversation.token`、 + `VoteSubmission` 的复合唯一索引都**不含 deleted_at**,而 gorm 的查重查询默认带 `deleted_at IS NULL` → + 「查重放行、INSERT 被索引拦下」,表现为 500。**处置分两种语义,别混**: + - **停用(可恢复)**:`users.Deactivate` = `purgeUserContent()` 物理删内容 + **软删除用户行**(身份占位)。 + 邮箱/域名继续被占用是有意为之(防他人抢占身份),注册侧必须用 `Unscoped` 识别并给出 + `ErrDeactivatedEmail` / `ErrDeactivatedDomain`(见 `validate` / `fieldTaken`),**不能**当成普通重复邮箱。 + 站长用 `TamaBox restore --email|--domain [--password] --yes` 恢复。 + - **封禁(不可恢复)**:`users.BanUserAndCleanup` = `purgeUserContent()` + **物理删除用户行**,邮箱彻底释放。 + - 级联内容删除一律 `Unscoped()` 物理删除;`voteSubmissions.DeleteByVoteID` 同理(`vote_submissions.go:234`)。 + **新增「删除后要能重建」的路径时务必 Unscoped;新增「删除后仍占位」的路径时务必让注册侧能识别。** +- 唯一键冲突兜底:`isDuplicateKeyError(err)`(`vote_submissions.go`,MySQL 1062 / PG 23505)。 + 注册不需要额外兜底 —— `validate` 已覆盖活跃 + 已停用两种占用。 +- ⚠️ **`cmd/tamabox.go` 的 `app.Commands` 容易漏注册**:`internal/cmd/ban.go` 曾写好但没注册, + `TamaBox ban` 实际不可达。新增 CLI 命令记得同时改这里。 ## 文件工具陷阱(重要) @@ -138,3 +156,27 @@ - **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。 备份/回滚一律用 tar.gz(排除 `.git`、`.workbuddy`),放在项目同级目录: `C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。 + +## 测试环境(box-dev-api.337845818.best) + +- **该域名跑的就是本工作区的构建**(2026-09-15 实测:本地新建的模板/路由/文案能在线访问到), + 但部署有延迟 —— 需重新构建/重启才会生效。**改完代码要提醒用户重新部署**,不要假定已生效。 +- 站点默认语言是 **ja**(`shared.App.Lang()`),匿名页断言别写中文文案,用语言无关标记(如 `ratio: 3`)。 +- 验证码是**数学题**(`conf.Recaptcha.CaptchaType == "math"`),可用脚本自动求解: + `GET /api/captcha/generate` 取 `{data:{id,question}}`,把 `a op b =` 算出来,提交 `g-recaptcha-response = ":<答案>"`; + 表单还需页面里的 `_csrf`(`name="_csrf"` 隐藏域)。 +- 可复用的端到端巡检脚本:`dev/devcheck-e2e.py`(注册→登录→提问→回答→投票→停用→同邮箱重注册)。 +- 表单字段名易错:提问是 `content` + `g-recaptcha-response`;回答是 `answer`(不是 content)。 + `GET /user/votes` **故意** 302 到 `/user/questions#votes`,不是 bug。 + 匿名无邮箱提问成功后**直接 200 渲染提问成功页**(不重定向),也不是 bug。 + +## 管理 CLI(`TamaBox `) + +- 注册在 `cmd/tamabox.go` 的 `app.Commands`。现有:`web` / `censor` / `uid` / `ban` / `restore`。 +- `ban --email|--domain [--yes]` → 物理删除用户 + 内容(不可恢复,邮箱释放)。 +- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。 + `--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域,软删除状态下找不到用户)。 +- 本地冒烟:仓库里**只有 `conf/app.sample.ini`**(真 `app.ini` 不在版本库), + 用 `NEKOBOX_CONFIG_PATH=<你的 ini>` 指定配置即可;`conf.Init` 强制要求 + `app.external_url`、`server.salt`、`server.xsrf_key` 三者非空。 + 跑 `TamaBox ` 会自动 `AutoMigrate`,可直接指向一个临时库做端到端验证(用完 DROP)。 diff --git a/cmd/tamabox.go b/cmd/tamabox.go index 6f2a60d..6734d90 100644 --- a/cmd/tamabox.go +++ b/cmd/tamabox.go @@ -22,6 +22,8 @@ func main() { cmd.Web, cmd.Censor, cmd.Uid, + cmd.Ban, + cmd.Restore, } if err := app.Run(os.Args); err != nil { logrus.WithError(err).Fatal("Failed to start application") diff --git a/dev/__pycache__/devcheck-e2e.cpython-313.pyc b/dev/__pycache__/devcheck-e2e.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80c777f663de2217c8082fbe59fb794e38fff991 GIT binary patch literal 16214 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 登录 -> 个人页/提问列表/投票面板 -> 匿名提问 -> 停用 + -> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长** + -> 公开页只读巡检 -> /deactivated 四语言渲染。 + +语义约定(2026-09-15 起):停用 = 清空内容 + 保留用户行作「身份占位」, +故邮箱/域名继续被占用,重注册应得到「账号已停用,请找站长」的提示而不是 302 或 500。 + +用法: + python dev/devcheck-e2e.py [--base https://box-dev-api.337845818.best] + +注意: +- 会在目标环境**真实创建账号并发送提问**(使用 tbe2e<时间戳>@example.com 之类的可识别测试邮箱), + 仅在测试/开发环境运行。 +- 每次完整运行都会**留下一个已停用的身份占位**(邮箱/域名被占用)。清理方式: + `TamaBox ban --email <该邮箱> --yes`(连行物理删除)或 `TamaBox restore --email <该邮箱> --yes`(恢复账号)。 +- 依赖数学验证码(conf.Recaptcha.CaptchaType == "math");若站点改用 recaptcha/turnstile 则无法自动通过。 +""" +import argparse +import http.cookiejar +import json +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36") + +RESULTS = [] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102 + return None + + +class Resp: + def __init__(self, status, headers, body): + self.status = status + self.h = {k.lower(): v for k, v in headers.items()} + self.body = body + + @property + def loc(self): + return self.h.get("location", "") + + def alert(self): + m = re.search(r'class="uk-alert[^"]*"[^>]*>(.*?)', self.body, re.S) + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(0))).strip()[:200] if m else "" + + +class Client: + def __init__(self, base): + self.base = base + self.cj = http.cookiejar.CookieJar() + self.op = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.cj), NoRedirect()) + + def req(self, method, path, data=None, timeout=90): + url = path if path.startswith("http") else self.base + path + headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]} + body = urllib.parse.urlencode(data).encode() if data is not None else None + if body: + headers["Content-Type"] = "application/x-www-form-urlencoded" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with self.op.open(req, timeout=timeout) as r: + return Resp(r.status, dict(r.headers), r.read().decode("utf-8", "replace")) + except urllib.error.HTTPError as e: + return Resp(e.code, dict(e.headers), e.read().decode("utf-8", "replace")) + except Exception as e: # noqa: BLE001 + return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e)) + + def get(self, p, **kw): + # 只读 GET 重试一次:远端偶发慢请求会产生 TRANSPORT_ERROR,避免巡检出现假失败。 + r = self.req("GET", p, **kw) + if r.status == -1: + r = self.req("GET", p, **kw) + return r + + def post(self, p, data=None, **kw): + return self.req("POST", p, data=data, **kw) + + +def csrf_of(html): + m = re.search(r'name="_csrf"\s+value="([^"]+)"', html) + return m.group(1) if m else None + + +def captcha(c): + """取一道数学验证码并算出答案,返回 :。""" + j = json.loads(c.get("/api/captcha/generate").body) + pid, q = j["data"]["id"], j["data"]["question"] + m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q) + a, op, b = int(m.group(1)), m.group(2), int(m.group(3)) + ans = {"+": a + b, "-": a - b, "×": a * b}[op] + return "%s:%d" % (pid, ans) + + +def step(desc, ok, extra=""): + RESULTS.append((desc, ok, extra)) + print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else "")) + + +# 停用提示的跨语言特征串(站点默认语言可能是 ja,断言不能只看中文) +DEACTIVATED_HINTS = ("已停用", "has been deactivated", "は停止されています") +INTERNAL_ERROR_HINTS = ("サーバー内部エラー", "Internal Server Error", "服务器内部错误") + + +def is_deactivated_rejected(r): + """停用后的重注册应被明确拒绝:HTTP 200 + 提示「账号已停用」,且不得是 500 兜底。""" + if r.status != 200: + return False + if any(h in r.body for h in INTERNAL_ERROR_HINTS): + return False + return any(h in r.body for h in DEACTIVATED_HINTS) + + +def check_public_pages(base): + """公开页只读巡检:期望全部 200(/user/votes 之类的登录页除外)。""" + paths = ["/", "/explore", "/register", "/login", "/forgot-password", + "/sponsor", "/change-logs", "/about-me", "/robots.txt", + "/sitemap.xml", "/llms.txt", "/llms-full.txt", "/favicon.ico", + "/deactivated", "/api/captcha/generate", "/api/v1/site-stats"] + c = Client(base) + bad = [] + for p in paths: + r = c.get(p, timeout=40) + if r.status != 200: + bad.append("%s=%s" % (p, r.status)) + step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad)) + + +def check_deactivated_i18n(base): + """停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。""" + bad = [] + for lang in ["zh-CN", "zh-TW", "en", "ja"]: + c = Client(base) + c.get("/i18n/%s?to=/deactivated" % lang, timeout=40) # 302,语言写入 session + r = c.get("/deactivated", timeout=40) # 再请求一次才渲染新语言 + title = re.search(r'uk-card-body uk-text-center".*?]*>([^<]*)', + r.body, re.S) + if not title or not title.group(1).strip(): + bad.append(lang) + step("停用成功页四语言渲染", not bad, "缺失: " + ",".join(bad) if bad else "") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", default="https://box-dev-api.337845818.best") + ap.add_argument("--reuse-email", default="", + help="只跑一项:用该既有邮箱重新注册(用于复测停用后能否重注册)") + args = ap.parse_args() + base = args.base.rstrip("/") + pwd = "TbCheck!2026" + + if args.reuse_email: + domain = args.reuse_email.split("@")[0] + c = Client(base) + r = c.get("/register") + r = c.post("/register", { + "email": args.reuse_email, "domain": domain, "name": "TBCheck", + "password": pwd, "repeat_password": pwd, + "g-recaptcha-response": captcha(c), "language": "zh-CN", + "_csrf": csrf_of(r.body), + }) + step("以既有邮箱重新注册(已停用则应被明确拒绝并提示找站长)", is_deactivated_rejected(r), + "HTTP %s %s" % (r.status, r.alert())) + return 0 if all(x[1] for x in RESULTS) else 1 + + ts = time.strftime("%m%d%H%M%S") + email, domain = "tbe2e%s@example.com" % ts, "tbe2e%s" % ts + print("== 新账号:%s / %s ==" % (email, domain)) + + pub = Client(base) + r = pub.get("/register") + reg = pub.post("/register", { + "email": email, "domain": domain, "name": "TB%s" % ts, + "password": pwd, "repeat_password": pwd, + "g-recaptcha-response": captcha(pub), "language": "zh-CN", + "_csrf": csrf_of(r.body), + }) + step("注册", reg.status == 302, "HTTP %s -> %s %s" % (reg.status, reg.loc, reg.alert())) + + owner = Client(base) + r = owner.get("/login") + r = owner.post("/login", { + "email": email, "password": pwd, + "g-recaptcha-response": captcha(owner), "_csrf": csrf_of(r.body), + }) + step("登录", r.status == 302, "HTTP %s -> %s" % (r.status, r.loc)) + + step("登录态 /user/profile", owner.get("/user/profile").status == 200) + step("登录态 /user/questions", owner.get("/user/questions").status == 200) + r = owner.get("/user/votes") + step("/user/votes 302 -> /user/questions#votes", + r.status == 302 and "questions" in r.loc, "HTTP %s -> %s" % (r.status, r.loc)) + step("新建投票页", owner.get("/user/votes/new").status == 200) + + # 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达 + r = pub.get("/_/%s" % domain) + step("打开提问箱", r.status == 200) + r = pub.post("/_/%s" % domain, { + "content": "自动化巡检测试提问(可忽略)", + "g-recaptcha-response": captcha(pub), + "receive_reply_email": "", "private": "", "ask_password": "", + "start_chat_val": "", "_csrf": csrf_of(r.body), + }) + step("提交提问", r.status in (200, 302), "HTTP %s -> %s" % (r.status, r.loc)) + + r = owner.get("/user/profile/deactivate") + step("打开停用页", r.status == 200) + r = owner.post("/user/profile/deactivate", {"_csrf": csrf_of(r.body)}) + step("停用后跳 /deactivated", r.status == 302 and "deactivated" in r.loc, + "HTTP %s -> %s" % (r.status, r.loc)) + r = owner.get("/deactivated") + step("停用成功页渲染", r.status == 200 and "ratio: 3" in r.body, "HTTP %s" % r.status) + + pub2 = Client(base) + r = pub2.get("/register") + r = pub2.post("/register", { + "email": email, "domain": domain, "name": "TB%sb" % ts, + "password": pwd, "repeat_password": pwd, + "g-recaptcha-response": captcha(pub2), "language": "zh-CN", + "_csrf": csrf_of(r.body), + }) + step("停用后同邮箱重注册被明确拒绝并提示找站长(核心回归)", is_deactivated_rejected(r), + "HTTP %s %s" % (r.status, r.alert())) + + check_public_pages(base) + check_deactivated_i18n(base) + + print("\n===== 汇总 =====") + for d, ok, ex in RESULTS: + print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else "")) + n = sum(1 for x in RESULTS if not x[1]) + print("\n共 %d 项,失败 %d 项" % (len(RESULTS), n)) + return 1 if n else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/cmd/restore.go b/internal/cmd/restore.go new file mode 100644 index 0000000..6301c2a --- /dev/null +++ b/internal/cmd/restore.go @@ -0,0 +1,123 @@ +// 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 ( + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" + + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" +) + +// Restore 恢复被自助停用的账号。 +// +// 自助停用(Deactivate)只清空内容、保留用户行作「身份占位」(软删除), +// 因此恢复只需清掉 deleted_at;内容已不可找回。 +// +// 用法:TamaBox restore --email user@example.com +// +// TamaBox restore --domain someone --password 'new-password' +var Restore = &cli.Command{ + Name: "restore", + Usage: "Restore a deactivated account (clears the identity placeholder)", + Action: runRestore, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "email", + Usage: "Email of the deactivated account", + }, + &cli.StringFlag{ + Name: "domain", + Usage: "Custom domain of the deactivated account", + }, + &cli.StringFlag{ + Name: "password", + Usage: "Optional: reset the password to this value (min 8 chars)", + }, + &cli.BoolFlag{ + Name: "yes", + Usage: "Skip confirmation", + }, + }, +} + +func runRestore(ctx *cli.Context) error { + if err := conf.Init(); err != nil { + return errors.Wrap(err, "load configuration") + } + + dbType := conf.Database.Type + if dbType == "postgres" { + conf.Database.DSN = conf.PostgresDsn() + } else { + conf.Database.DSN = conf.MySQLDsn() + } + + database, err := db.Init(dbType, conf.Database.DSN) + if err != nil { + return errors.Wrap(err, "connect to database") + } + + email := ctx.String("email") + domain := ctx.String("domain") + if email == "" && domain == "" { + return errors.New("must provide --email or --domain") + } + newPassword := ctx.String("password") + if newPassword != "" && len(newPassword) < 8 { + return errors.New("password must be at least 8 characters") + } + + // 用 Unscoped 查找:待恢复的账号必然是软删除状态,普通查询看不见 + var user db.User + query := database.WithContext(ctx.Context).Unscoped() + if email != "" { + query = query.Where("email = ?", email) + } else { + query = query.Where("domain = ?", domain) + } + if err := query.First(&user).Error; err != nil { + return errors.Wrap(err, "find user to restore") + } + if !user.DeletedAt.Valid { + return errors.Errorf("账号 %q 未被停用,无需恢复", user.Domain) + } + + logrus.WithFields(logrus.Fields{ + "id": user.ID, + "name": user.Name, + "email": user.Email, + "domain": user.Domain, + }).Warn("准备恢复以下已停用账号(提问/追问/私聊等内容在停用时已被删除,无法找回)") + + 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("已取消") + } + } + + if err := db.Users.Reactivate(ctx.Context, user.ID); err != nil { + return errors.Wrap(err, "reactivate user") + } + + // 密码重置必须在恢复之后:UpdatePassword 走普通作用域,软删除状态下找不到该用户 + if newPassword != "" { + if err := db.Users.UpdatePassword(ctx.Context, user.ID, newPassword); err != nil { + return errors.Wrap(err, "reset password") + } + logrus.WithField("user_id", user.ID).Info("账号已恢复,密码已重置") + return nil + } + + logrus.WithField("user_id", user.ID).Info("账号已恢复(密码未改动,如原主已忘记密码请用 --password 重置)") + return nil +} diff --git a/internal/db/users.go b/internal/db/users.go index 94a655d..a8460fa 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -71,6 +71,8 @@ type UsersStore interface { CountTodayMessagesFromUser(ctx context.Context, ownerID, askerID uint) (int64, error) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int) error BanUserAndCleanup(ctx context.Context, userID uint) error + // Reactivate 恢复被自助停用的账号(内容已清空),供站长核验身份后调用 + Reactivate(ctx context.Context, id uint) error } func NewUsersStore(db *gorm.DB) UsersStore { @@ -226,6 +228,11 @@ var ( ErrBadCredential = errors.New("邮箱或密码错误") ErrDuplicateEmail = errors.New("这个邮箱已经注册过账号了!") ErrDuplicateDomain = errors.New("个性域名重复了,换一个吧~") + + // 已停用(软删除)账号仍占用 email / domain 唯一索引:gorm 的普通查重看不见它, + // 必须 Unscoped 才能识别,否则会退化成「INSERT 撞唯一索引 → 500」。 + ErrDeactivatedEmail = errors.New("该邮箱对应的账号已停用") + ErrDeactivatedDomain = errors.New("该个性域名对应的账号已停用") ) func (db *users) Create(ctx context.Context, opts CreateUserOptions) error { @@ -507,38 +514,94 @@ func (db *users) UpdatePassword(ctx context.Context, id uint, newPassword string return nil } +// Deactivate 自助停用账号:删除该账号的全部内容数据,保留一行「身份占位」(软删除)。 +// +// 为什么保留用户行(软删除)而不是物理删除: +// users 的 email / domain 是唯一索引。若连行一起删掉,这个邮箱/域名就能被任何人立刻重新注册; +// 而停用的语义是「该身份归原主所有」——留占位既是防止他人抢占,也让站长核验后能把账号恢复成空账号。 +// ⚠️ 代价是普通查重看不见这一行,注册侧必须用 Unscoped 识别(见 validate 与 ErrDeactivated*), +// 否则会退化成「查重放行、INSERT 撞唯一索引 → 500」。 func (db *users) Deactivate(ctx context.Context, id uint) error { u, err := db.GetByID(ctx, id) if err != nil { return errors.Wrap(err, "get user by id") } - if err := db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID).Error; err != nil { - return errors.Wrap(err, "delete user") + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := purgeUserContent(ctx, tx, u.ID); err != nil { + return err + } + // 内容已清空,用户行只作身份占位保留 → 软删除(写 deleted_at) + if err := tx.WithContext(ctx).Delete(&User{}, u.ID).Error; err != nil { + return errors.Wrap(err, "deactivate user") + } + return nil + }) +} + +// Reactivate 恢复被自助停用的账号(内容已被清空的空账号),供站长核验身份后调用。 +// 密码不会被重置,若原主已忘记密码,可再用 UpdatePassword 另行处理。 +func (db *users) Reactivate(ctx context.Context, id uint) error { + result := db.WithContext(ctx).Unscoped().Model(&User{}). + Where("id = ? AND deleted_at IS NOT NULL", id). + Update("deleted_at", nil) + if result.Error != nil { + return errors.Wrap(result.Error, "reactivate user") + } + if result.RowsAffected == 0 { + return ErrUserNotExists } return nil } +// validate 检查邮箱与个性域名是否可用。 +// +// 分两轮:先看活跃账号(普通占用),再看「已停用」的软删除占位行。 +// 第二轮必须 Unscoped —— 停用会保留用户行作为身份占位,普通查询看不见它, +// 但 email / domain 的唯一索引仍会拦下 INSERT,于是表现为「查重放行、插入 500」。 func (db *users) validate(ctx context.Context, opts CreateUserOptions) error { - if err := db.WithContext(ctx).Model(&User{}).Where("email = ?", opts.Email).First(&User{}).Error; err != nil { - if err != gorm.ErrRecordNotFound { - return errors.Wrap(err, "validate email") - } - } else { + if taken, err := db.fieldTaken(ctx, "email", opts.Email, false); err != nil { + return errors.Wrap(err, "validate email") + } else if taken { return ErrDuplicateEmail } - if err := db.WithContext(ctx).Model(&User{}).Where("domain = ?", opts.Domain).First(&User{}).Error; err != nil { - if err != gorm.ErrRecordNotFound { - return errors.Wrap(err, "validate name") - } - } else { + if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, false); err != nil { + return errors.Wrap(err, "validate domain") + } else if taken { return ErrDuplicateDomain } + if taken, err := db.fieldTaken(ctx, "email", opts.Email, true); err != nil { + return errors.Wrap(err, "validate deactivated email") + } else if taken { + return ErrDeactivatedEmail + } + + if taken, err := db.fieldTaken(ctx, "domain", opts.Domain, true); err != nil { + return errors.Wrap(err, "validate deactivated domain") + } else if taken { + return ErrDeactivatedDomain + } return nil } +// fieldTaken 判断某个唯一字段是否已被占用。 +// includeDeleted 为 true 时连已停用(软删除)的占位行一起计入。 +// column 只允许由调用方传入字面量(email / domain),不接受外部输入。 +func (db *users) fieldTaken(ctx context.Context, column, value string, includeDeleted bool) (bool, error) { + query := db.WithContext(ctx).Model(&User{}) + if includeDeleted { + query = query.Unscoped() + } + + var count int64 + if err := query.Where(column+" = ?", value).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + type UpdateTagsOptions struct { Tags string ShowInHome bool @@ -902,68 +965,85 @@ func (db *users) UpdateDailyMessageLimit(ctx context.Context, id uint, limit int // BanUserAndCleanup 封禁用户并连带清理其全部数据(提问、追问、私聊、消息、图片关联), // 最后物理删除用户记录。供管理 CLI 命令(tamabox ban)使用。 // +// 与自助停用(Deactivate)的区别只有一处:封禁连用户行一起物理删除, +// 因此该邮箱/域名会被彻底释放、可被任何人重新注册。 +// // 注意:该操作不可恢复,执行前请确认目标账号。 func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error { return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 追问:属于该用户提问下的追问 - if err := tx.WithContext(ctx). - Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). - Delete(&FollowUp{}).Error; err != nil { - return errors.Wrap(err, "delete follow ups of banned user") + if err := purgeUserContent(ctx, tx, userID); err != nil { + return err } - - // 消息:属于该用户作为 owner 或 asker 的会话 - if err := tx.WithContext(ctx). - Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID). - Delete(&Message{}).Error; err != nil { - return errors.Wrap(err, "delete messages of banned user") - } - // 消息图片 - if err := tx.WithContext(ctx). - Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID). - Delete(&MessageImage{}).Error; err != nil { - return errors.Wrap(err, "delete message images of banned user") - } - - // 私聊会话 - if err := tx.WithContext(ctx). - Where("owner_id = ? OR asker_id = ?", userID, userID). - Delete(&Conversation{}).Error; err != nil { - return errors.Wrap(err, "delete conversations of banned user") - } - - // 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留) - if err := tx.WithContext(ctx). - Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). - Delete(&UploadImageQuestion{}).Error; err != nil { - return errors.Wrap(err, "delete upload image links of banned user") - } - - // 提问 - if err := tx.WithContext(ctx). - Where("user_id = ?", userID). - Delete(&Question{}).Error; err != nil { - return errors.Wrap(err, "delete questions of banned user") - } - - // 拉黑关系(该用户拉黑别人,或被别人拉黑) - if err := tx.WithContext(ctx). - Where("user_id = ? OR blocked_id = ?", userID, userID). - Delete(&UserBlock{}).Error; err != nil { - return errors.Wrap(err, "delete user blocks of banned user") - } - - // 功能开关配置(提问 / 私聊 / 投票) - if err := tx.WithContext(ctx).Unscoped(). - Where("user_id = ?", userID). - Delete(&FeatureControl{}).Error; err != nil { - return errors.Wrap(err, "delete feature controls of banned user") - } - - // 物理删除用户 if err := tx.WithContext(ctx).Unscoped().Delete(&User{}, userID).Error; err != nil { - return errors.Wrap(err, "delete banned user") + return errors.Wrap(err, "delete user") } return nil }) } + +// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、 +// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。 +// +// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引 +// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。 +// +// ⚠️ 每条语句都必须经 tx.WithContext(ctx) 取新句柄:事务句柄的 clone 为 0, +// 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups, +// 报「字段 conversation_id 不存在」)。 +func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error { + // 追问:属于该用户提问下的追问 + if err := tx.WithContext(ctx).Unscoped(). + Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). + Delete(&FollowUp{}).Error; err != nil { + return errors.Wrap(err, "delete follow ups of user") + } + + // 消息:属于该用户作为 owner 或 asker 的会话 + if err := tx.WithContext(ctx).Unscoped(). + Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID). + Delete(&Message{}).Error; err != nil { + return errors.Wrap(err, "delete messages of user") + } + // 消息图片 + if err := tx.WithContext(ctx).Unscoped(). + Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID). + Delete(&MessageImage{}).Error; err != nil { + return errors.Wrap(err, "delete message images of user") + } + + // 私聊会话 + if err := tx.WithContext(ctx).Unscoped(). + Where("owner_id = ? OR asker_id = ?", userID, userID). + Delete(&Conversation{}).Error; err != nil { + return errors.Wrap(err, "delete conversations of user") + } + + // 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留) + if err := tx.WithContext(ctx).Unscoped(). + Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID). + Delete(&UploadImageQuestion{}).Error; err != nil { + return errors.Wrap(err, "delete upload image links of user") + } + + // 提问 + if err := tx.WithContext(ctx).Unscoped(). + Where("user_id = ?", userID). + Delete(&Question{}).Error; err != nil { + return errors.Wrap(err, "delete questions of user") + } + + // 拉黑关系(该用户拉黑别人,或被别人拉黑) + if err := tx.WithContext(ctx).Unscoped(). + Where("user_id = ? OR blocked_id = ?", userID, userID). + Delete(&UserBlock{}).Error; err != nil { + return errors.Wrap(err, "delete user blocks of user") + } + + // 功能开关配置(提问 / 私聊 / 投票) + if err := tx.WithContext(ctx).Unscoped(). + Where("user_id = ?", userID). + Delete(&FeatureControl{}).Error; err != nil { + return errors.Wrap(err, "delete feature controls of user") + } + return nil +} diff --git a/internal/db/users_test.go b/internal/db/users_test.go index 4083d55..eae0841 100644 --- a/internal/db/users_test.go +++ b/internal/db/users_test.go @@ -6,6 +6,7 @@ package db import ( "context" + "errors" "testing" "time" @@ -470,3 +471,117 @@ func TestHarassmentSettingNormalize(t *testing.T) { require.Nil(t, err) require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none") } + +// TestDeactivatePurgesUser 覆盖自助停用的删除语义。 +// +// 停用 = 清空内容 + 保留用户行作「身份占位」(软删除),因此: +// - 该账号的提问等内容必须被物理删除; +// - 用户行必须仍在(Unscoped 可见,deleted_at 有效),邮箱/域名继续被占用 → 防止他人抢占; +// - 用同一邮箱/域名重新注册必须得到「账号已停用」的明确错误,而不是底层的唯一键 500; +// - 站长可通过 Reactivate 把账号恢复成空账号。 +func TestDeactivatePurgesUser(t *testing.T) { + db, cleanup := newTestDB(t) + defer func() { _ = cleanup("users", "questions", "feature_controls") }() + ctx := context.Background() + usersStore := NewUsersStore(db) + questionsStore := NewQuestionsStore(db) + + opts := CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "purge@github.red", + Avatar: "avater.png", + Domain: "purge", + Background: "background.png", + Intro: "Be cool, but also be warm.", + } + require.Nil(t, usersStore.Create(ctx, opts)) + + user, err := usersStore.GetByEmail(ctx, opts.Email) + require.Nil(t, err) + + _, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"}) + require.Nil(t, err) + + require.Nil(t, usersStore.Deactivate(ctx, user.ID)) + + var n int64 + // 身份占位必须保留(Unscoped 可见),且处于软删除状态 + var placeholder User + require.Nil(t, db.WithContext(ctx).Unscoped().Where("email = ?", opts.Email).First(&placeholder).Error) + require.True(t, placeholder.DeletedAt.Valid, "停用后用户行应保留为软删除的身份占位") + require.Equal(t, user.ID, placeholder.ID) + + require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error) + require.EqualValues(t, 0, n, "停用后该用户的提问应被物理删除") + + // 普通查询已看不见该占位行 + _, err = usersStore.GetByEmail(ctx, opts.Email) + require.Equal(t, ErrUserNotExists, err) + + // 同一邮箱 / 域名重新注册:给出明确的「已停用」错误,而不是底层唯一键冲突 + err = usersStore.Create(ctx, opts) + require.True(t, errors.Is(err, ErrDeactivatedEmail), "同邮箱重注册应返回 ErrDeactivatedEmail,实际: %v", err) + require.False(t, errors.Is(err, ErrDuplicateEmail), "不应退化成普通重复邮箱错误") + + err = usersStore.Create(ctx, CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "another@github.red", + Avatar: "avater.png", + Domain: opts.Domain, + Background: "background.png", + Intro: "Be cool, but also be warm.", + }) + require.True(t, errors.Is(err, ErrDeactivatedDomain), "同域名重注册应返回 ErrDeactivatedDomain,实际: %v", err) + + // 站长恢复:占位行清掉 deleted_at,账号重新可见 + require.Nil(t, usersStore.Reactivate(ctx, user.ID)) + + restored, err := usersStore.GetByEmail(ctx, opts.Email) + require.Nil(t, err) + require.Equal(t, user.ID, restored.ID) + require.False(t, restored.DeletedAt.Valid) + + // 恢复后该邮箱已被活跃账号占用 → 普通重复错误 + require.True(t, errors.Is(usersStore.Create(ctx, opts), ErrDuplicateEmail)) + + // 未被停用的账号无法重复恢复 + require.Equal(t, ErrUserNotExists, usersStore.Reactivate(ctx, user.ID)) +} + +// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除, +// 该邮箱/域名被彻底释放。 +func TestBanUserAndCleanupRemovesRow(t *testing.T) { + db, cleanup := newTestDB(t) + defer func() { _ = cleanup("users", "questions", "feature_controls") }() + ctx := context.Background() + usersStore := NewUsersStore(db) + questionsStore := NewQuestionsStore(db) + + opts := CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "ban@github.red", + Avatar: "avater.png", + Domain: "ban", + Background: "background.png", + Intro: "Be cool, but also be warm.", + } + require.Nil(t, usersStore.Create(ctx, opts)) + + user, err := usersStore.GetByEmail(ctx, opts.Email) + require.Nil(t, err) + _, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"}) + require.Nil(t, err) + + require.Nil(t, usersStore.BanUserAndCleanup(ctx, user.ID)) + + var n int64 + require.Nil(t, db.WithContext(ctx).Unscoped().Model(&User{}).Where("email = ?", opts.Email).Count(&n).Error) + require.EqualValues(t, 0, n, "封禁后用户行应被物理删除") + require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error) + require.EqualValues(t, 0, n, "封禁后该用户的提问应被物理删除") + + require.Nil(t, usersStore.Create(ctx, opts), "封禁后应能用同一邮箱重新注册") +} diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index bb6c10f..2505ea6 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -418,6 +418,7 @@ var enDict = map[string]string{ "deactivate.desc": "After deactivation your question box will be inaccessible and all data will be deleted. This cannot be undone!", "deactivate.confirm": "I confirm I want to deactivate my account", "deactivate.submit": "Deactivate account", + "deactivate.note": "After deactivation, your email and custom domain are kept as an identity reservation to prevent others from taking them, so you will not be able to register again with the same email. To restore your account, please contact us through the \"Contact admin\" link in the page footer.", // Deactivated page "deactivated.title": "Account deactivated", @@ -685,6 +686,9 @@ var enDict = map[string]string{ "auth.errLoginRequired": "Please log in first", "auth.errDuplicateEmail": "This email is already registered", "auth.errDuplicateDomain": "This custom domain is already taken", + // The deactivated account still holds this email/domain as an identity reservation + "auth.errDeactivatedEmail": "The account for this email has been deactivated. To use it again, please contact us through the \"Contact admin\" link in the page footer, using this email address.", + "auth.errDeactivatedDomain": "The account for this custom domain has been deactivated. To use it again, please contact us through the \"Contact admin\" link in the page footer.", // Question box page: visitor-visible (P0) "question.errWrongPassword": "Wrong password", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index e6dcb40..9385765 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -418,6 +418,7 @@ var jaDict = map[string]string{ "deactivate.desc": "停止後、あなたの質問箱にはアクセスできなくなり、すべてのデータが削除されます。この操作は取り消せません!", "deactivate.confirm": "アカウントを停止することに同意します", "deactivate.submit": "アカウントを停止", + "deactivate.note": "停止後、メールアドレスとカスタムドメインは第三者による取得を防ぐため本人確認用のプレースホルダーとして保持されるため、同じメールアドレスで再登録することはできません。復元をご希望の場合は、ページ下部の「管理者へ」からご連絡ください。", // アカウント停止完了ページ "deactivated.title": "アカウントを停止しました", @@ -685,6 +686,9 @@ var jaDict = map[string]string{ "auth.errLoginRequired": "ログインしてください", "auth.errDuplicateEmail": "このメールアドレスは既に登録されています", "auth.errDuplicateDomain": "このカスタムドメインは既に使用されています", + // 停止済みアカウントがメールアドレス/ドメインを本人確認用に保持している + "auth.errDeactivatedEmail": "このメールアドレスのアカウントは停止されています。再びご利用になるには、ページ下部の「管理者へ」からこのメールアドレスでご連絡ください。", + "auth.errDeactivatedDomain": "このカスタムドメインのアカウントは停止されています。再びご利用になるには、ページ下部の「管理者へ」からご連絡ください。", // 質問箱ページ:訪問者に表示(P0) "question.errWrongPassword": "パスワードが正しくありません", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 5d19411..442d6fe 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -418,6 +418,7 @@ var zhCNDict = map[string]string{ "deactivate.desc": "停用后你的提问箱将无法访问,且所有数据将被删除,此操作不可恢复!", "deactivate.confirm": "我确认要停用账号", "deactivate.submit": "停用账号", + "deactivate.note": "停用后,你的邮箱与个性域名会作为身份占位保留(避免被他人占用),因此无法再用同一邮箱重新注册。如需恢复,请通过页脚的「找站长」入口联系我们。", // 停用成功页 "deactivated.title": "账号已停用", @@ -685,6 +686,9 @@ var zhCNDict = map[string]string{ "auth.errLoginRequired": "请先登录", "auth.errDuplicateEmail": "该邮箱已被注册", "auth.errDuplicateDomain": "该个性域名已被使用", + // 已停用账号仍占用邮箱/域名(身份占位),引导用户走站长核验恢复 + "auth.errDeactivatedEmail": "该邮箱对应的账号已停用。如需重新使用,请通过页脚的「找站长」入口,用该邮箱与我们联系。", + "auth.errDeactivatedDomain": "该个性域名对应的账号已停用。如需重新使用,请通过页脚的「找站长」入口与我们联系。", // 提问箱页:访客可见(P0) "question.errWrongPassword": "密码错误", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 67e5a0e..543e29d 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -418,6 +418,7 @@ var zhTWDict = map[string]string{ "deactivate.desc": "停用後你的提問箱將無法存取,且所有資料將被刪除,此操作不可恢復!", "deactivate.confirm": "我確認要停用帳號", "deactivate.submit": "停用帳號", + "deactivate.note": "停用後,你的郵箱與個性網域會作為身分佔位保留(避免被他人佔用),因此無法再用同一郵箱重新註冊。如需恢復,請透過頁腳的「找站長」入口與我們聯繫。", // 停用成功頁 "deactivated.title": "帳號已停用", @@ -685,6 +686,9 @@ var zhTWDict = map[string]string{ "auth.errLoginRequired": "請先登入", "auth.errDuplicateEmail": "該郵箱已被註冊", "auth.errDuplicateDomain": "該個性網域已被使用", + // 已停用帳號仍佔用郵箱/網域(身分佔位),引導使用者走站長核驗恢復 + "auth.errDeactivatedEmail": "該郵箱對應的帳號已停用。如需重新使用,請透過頁腳的「找站長」入口,用該郵箱與我們聯繫。", + "auth.errDeactivatedDomain": "該個性網域對應的帳號已停用。如需重新使用,請透過頁腳的「找站長」入口與我們聯繫。", // 提問箱頁:訪客可見(P0) "question.errWrongPassword": "密碼錯誤", diff --git a/route/auth/register.go b/route/auth/register.go index 65d1e44..a0bedff 100644 --- a/route/auth/register.go +++ b/route/auth/register.go @@ -70,6 +70,12 @@ func RegisterAction(ctx context.Context, f form.Register, recaptcha recaptcha.Re ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateEmail"))) case errors.Is(err, db.ErrDuplicateDomain): ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDuplicateDomain"))) + // 已停用账号仍保留身份占位(占着邮箱/域名唯一索引),不再静默通过或抛 500, + // 而是明确告知用户该身份已被占用、可由站长核验后恢复。 + case errors.Is(err, db.ErrDeactivatedEmail): + ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedEmail"))) + case errors.Is(err, db.ErrDeactivatedDomain): + ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errDeactivatedDomain"))) case errors.Is(err, db.ErrBadCredential): ctx.SetError(errors.New(i18n.T(ctx.Lang(), "auth.errBadCredential"))) diff --git a/templates/user/deactivate.html b/templates/user/deactivate.html index 3e71042..6e503e9 100644 --- a/templates/user/deactivate.html +++ b/templates/user/deactivate.html @@ -8,6 +8,9 @@ {{t .LangCode "deactivate.desc"}}

+ {{t .LangCode "deactivate.note"}} +
+