diff --git a/.workbuddy/memory/2026-09-15.md b/.workbuddy/memory/2026-09-15.md index 3124c22..4585cd3 100644 --- a/.workbuddy/memory/2026-09-15.md +++ b/.workbuddy/memory/2026-09-15.md @@ -380,3 +380,42 @@ SQL 变成 `DELETE FROM follow_ups WHERE AND 在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。 + +--- + +## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿) + +**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」, +**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。 + +**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组: + +1. `check_vote_flow(owner, base)` —— 投票全链路 + - POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes` + - GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID** + - 匿名 GET `/vote/` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID + - 匿名 POST `/vote//submit`(`option_id` + 数学验证码 + `_csrf`)→ 302 + - GET `/user/votes//export` 断言 **`总参与,N` 这行的 N == 1** + - 草稿:`action=save` → 302 到 `/user/votes//edit?saved=1` + - 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302 +2. `check_user_assets(owner)` —— 登录态接口/导出 + - `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、 + `POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言 + +**踩到的坑(都已在脚本里修掉)**: +- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 → + 用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。 + **必须单独匹配 `总参与,(\d+)`**。 +- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发), + 异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底 + ——中断时仍打印已完成项,而不是只留一段 traceback。 +- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时, + 变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**, + 单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。 + +**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。 +10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。 + +**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。 +本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。 +清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index f2f402c..bc268f0 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -1,182 +1,121 @@ # TamaBox-v4 项目长期笔记 -## 审计/失败分级惯例(沿用用户偏好) +## 协作惯例 -- 结论分级:P0/P1/P2,配 🔴(阻断级)/🟠(高)。给修复建议前先做根因取证与并行诊断。 -- 改代码前先出「方案/变更清单」获批准,再分批落地;用户倾向方案 A 或明确指定方向。 -- 工作场景用 `@scene#N:"名称"` 标记(如 `@scene#15:"日常开发"`)。 -- 用户常说「你先继续,后面我发现问题再和你说」→ 可自主推进,但重大问题仍要显式指出。 +- 结论分级 P0/P1/P2 + 🔴/🟠;给修复建议前先做根因取证与实测复现,不要只静态推断。 +- **改代码前先出「方案/变更清单」获批准,再分批落地**。用户常授权自主推进(「你先继续,后面我发现问题再说」),但重大问题仍要显式指出。 +- 工作场景用 `@scene#N:"名称"` 标记。 -## 构建与测试(Windows 沙箱) +## 构建与测试 -- **本机无 libwebp / GCC**,而 `chai2010/webp`(cgo)会挡住 `go build ./...`。 - 必须用 overlay 把 `internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`: - ``` - go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./... - go test -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./... - ``` - (`dev/overlay.json` 里的路径指向 TAMAKY~1 短名,可能失效;用上面 Temp 里的那份。) -- `internal/db` 测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`)。 - **用户本地有测试 PG**:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432` - (DSN 形如 `postgres://user:pwd@host:port?sslmode=disable`,不带库名;`NewTestDB` 会自建 - `tamabox-test-` 并在成功时 DROP)。跑法: - ``` - DB_TYPE=postgres DB_USER=postgres DB_PASSWORD= DB_HOST=127.0.0.1 DB_PORT=5432 \ - go test -overlay= -count=1 ./internal/db/ - ``` - ✅ 2026-09-15 起 `go test ./...`(带 DB)**全绿**。此前该套件的一大堆失败**全是测试自身缺陷** - (bcrypt 直接比哈希、测试数据撞车、顶层 `t.Parallel()` + 共享表 + `TRUNCATE ... RESTART IDENTITY`、 - cleanup 漏清 `upload_image_questions`),已修。 -- ⚠️ **`internal/db` 包级 store 单例(`Users`/`Questions`/`FollowUps`/`UploadImages`/`FeatureControls`…) - 只在 `db.Init` 装配**。若某 store 方法内部直接调这些全局单例,测试不装配 → **nil panic**。 - 2026-09-15 已把 `DeleteByID`、`MarkDeletedByOwner` 改为 `NewXStore(db.DB)`(用自身 store 的 `*gorm.DB`)。 - **新增此类跨 store 调用时务必用 `NewXStore(db.DB)`,不要引用包级全局。** `feature_control.go` 的 - `FeatureStateOf` 是唯一例外(有 nil 守卫,返回默认配置)。 -- **「编译卡死」真因 = `proxy.golang.org` 不可达**:本沙箱 `curl -m10 proxy.golang.org` → `HTTP=000`(连接超时,rc=28), - 而 `goproxy.cn` → `HTTP=200`。任何触及网络的 `go build/test/mod` 会**无限等待代理超时**,表象就是「卡死」。 - `go env` 默认 `GOPROXY=https://proxy.golang.org,direct` → 必卡。 - ✅ 解法(每条 go 命令前都要):`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。 - ⚠️ **「整库编译慢 / 卡死」是伪命题**:只要 GOPROXY 正确,`go build ./...` 实测 **EXIT=0**, - `./route/user/` 只要 **7 秒**(缓存热时全库也是秒级)。此前 20+ 分钟/240s 超时**全是代理挂起**。 - ⚠️ **1C1G 是用户的正式生产环境,不是沙箱** —— 禁止用「沙箱慢/环境限制」解释任何超时(2026-09-15 用户纠正)。 - ⚠️ 方法论:**超时 ≠ 慢**。先用 `go list -deps `(纯元数据、不编译)拆开「模块解析」与「编译」 - 两层;前者 2.3s 说明模块在缓存,问题就必在别处。 - ⚠️ `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`、`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'`。) -- `internal/security/censor/censor.go`、`internal/security/censor/text_test.go` 存在**升级前就有**的 - 空格缩进(`gofmt -l` 会列出)。除非专门整理,否则不要顺手 gofmt,避免污染 diff。 +- **本机无 libwebp/GCC**,`chai2010/webp`(cgo) 会挡住 `go build ./...`;必须用 overlay 把 + `internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`: + `-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`(`dev/overlay.json` 里的短名路径可能失效)。 +- **「编译卡死」真因 = `proxy.golang.org` 不可达**(本沙箱 curl 超时;`goproxy.cn` 正常)。 + 每条 go 命令前必须:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。 + 代理正确时 `go build ./...` EXIT=0、`./route/user/` 约 7s。**超时 ≠ 慢**:先用 `go list -deps` 拆开「模块解析/编译」两层。 + `go env -w` 在本沙箱失败(无 `%AppData%`),无法持久化。**1C1G 是用户正式生产环境,禁止用「沙箱慢」解释超时。** +- `internal/db` 测试需真实 DB。本地 PG:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm + DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名,`NewTestDB` 自建 `tamabox-test-` 并 DROP)。 + 2026-09-15 起「带 DB 的 `go test ./...`」**全绿**;此前的失败**全是测试自身缺陷**,已修。 +- ⚠️ `internal/db` 包级 store 单例只在 `db.Init` 装配;store 方法内**不要引用包级全局**,用 `NewXStore(db.DB)`, + 否则测试下 nil panic。唯一例外:`feature_control.go` 的 `FeatureStateOf`(有 nil 守卫)。 +- **行尾是混合的,不能按规律推断**:`internal/db/*`、`internal/mail/mail.go`、`route/user/profile.go` 是 LF; + `internal/route/route.go`、`internal/db/users.go`、`internal/i18n/dict_*.go` 是 CRLF。 + 编辑前用 Python 数 `b'\r\n'` 确认(`grep -c $'\r'` 不可靠)。`gofmt -l` 会把 CRLF 文件误报为未格式化。 + 格式化统一走 `dev/gofmt_keep_crlf.py `。 +- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。 -## 内容审核架构(简洁版) +## 内容审核 -- 线上生效的审核通道是**七牛云**(`ai.qiniuapi.com/v3/text/censor`,scene=antispam);阿里云通道代码存在但 key 留空、不生效。 -- **通道可选**:`[security] text_censor_source = qiniu | aliyun | auto`(留空同 auto=七牛优先、阿里云兜底,即历史行为)。 - 取值常量在 `conf`(`CensorSource*`),装配在 `censor.censorChain()`,启动校验在 `conf.validateTextCensorSource`。 - 显式选通道却缺凭据会**启动报错**(避免运行期 fail-closed 导致整站不可用)。 -- 关键事实:七牛 `IsPass()` 只硬拦 politics/terrorism/porn,`review` 一律放行; - 七牛文本审核**不支持白名单**、自定义词库需提工单 → 服务端「拦得准不准」不可控。 -- 因此本项目把「是否拦截」从服务商 Pass 判定剥离,收敛到策略层: - `internal/security/censor/policy.go`(档位裁决)+ `internal/security/moderation/moderation.go`(唯一入口 `Check`)。 -- **任何新增的文本审核调用点都必须走 `moderation.Check`**,不要再直接调 `censor.Text`, - 否则会绕过「审核严格度」档位。三场景:提问/回答、投票、私聊。 -- 语义约定:`LabelScore.Score` 统一归一化 0~1(七牛本身即 0~1;阿里云 2.0 无逐标签置信度, - 按 `riskLevel` 折算 high=1 / medium=0.6 / low=0.3,该响应内所有标签共用同一分值)。 -- 审核服务不可用时 **fail-closed**(拒绝提交)—— 这是既有行为,未经批准不要改成 fail-open。 - ⚠️ 推论:**显式选定的通道一旦坏掉(账号未开通/欠费/权限不足),整站发布+私信会全部被拒**,现象隐蔽(只在日志)。 +- 线上生效通道 = **七牛云**(`ai.qiniuapi.com/v3/text/censor`,scene=antispam);阿里云代码在但 key 空。 +- 通道可配:`[security] text_censor_source = qiniu | aliyun | auto`(留空同 auto)。常量在 `conf`, + 装配在 `censor.censorChain()`,启动校验 `conf.validateTextCensorSource`;**显式选通道却缺凭据会启动报错**。 +- 七牛 `IsPass()` 只硬拦 politics/terrorism/porn,`review` 一律放行,且不支持白名单 → + 拦截判定收敛到策略层:`censor/policy.go`(档位裁决)+ `moderation/moderation.go`(唯一入口 `Check`)。 +- **新增文本审核调用点必须走 `moderation.Check`**,不要直接调 `censor.Text`(会绕过审核严格度档位)。 + 三场景:提问/回答、投票、私聊。 +- `LabelScore.Score` 统一归一化 0~1(阿里云 2.0 无逐标签置信度,按 riskLevel 折算 high=1/medium=0.6/low=0.3)。 +- 审核服务不可用时 **fail-closed**,未经批准不要改 fail-open。⚠️ 推论:显式选定的通道一旦坏掉, + 整站发布+私信全被拒且现象隐蔽(只在日志)。 -### 阿里云 green 通道(`/green/text/scan`, 2018-05-09) -- 实现与上游 NekoBox 及阿里云官方 Go 示例**逐字节一致**;`TextScanRequest` 无独立 `Content` 字段, - body 走内嵌 `RoaRequest.Content`。**SDK 的 ROA 默认 Content-Type 是 `application/octet-stream`** - (`sdk/auth/roa_signature_composer.go:88`),现已显式 `SetContentType(requests.Json)` 对齐接口契约。 -- 已知故障:HTTP 200 + `data` 为空 → `"response data is empty"`。**该错误此前会吞掉顶层 code/msg/requestId** - (唯一的根因线索),现已把 code/msg/requestId + 原始响应摘要(≤512 字符)写进错误。 - ✅ 2026-09-10 实证根因:`code=596 "You have not opened Yundun Content Moderation Service"` —— - 阿里云回的是 **HTTP 200 + `success:false` + 无 data**,即「请求被拒也返回 200」。 - ⚠️ **硬门槛:「内容安全 1.0」开通前提是账号完成企业实名认证**(官方 69806),个人认证账号**永远开不了**。 - 开通入口是 `common-buy.aliyun.com/?commodityCode=cdi#/open`,不是产品介绍页 `aliyun.com/product/lvwang`。 - 替代方案:迁「内容安全增强版 2.0」(`green-cip`,个人认证可开,但标签体系完全不同、需改代码), - 或退回 `auto`/`qiniu`。RAM 用户需 `AliyunYundunGreenWebFullAccess`;欠费停服同理。 -- `data[].code != 200`(任务级失败)仍 fail-closed 置 block,但现在会打 Warn —— - 否则「审核失败」与「真的违规」在日志里无法区分。 -- ⚠️ `SetContentType(requests.Json)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。 +### 阿里云 green(`/green/text/scan`) +- 实现与上游/官方示例一致;`SetContentType(requests.Json)` 仅为接口契约对齐,**已证伪与故障无关**。 +- 根因(2026-09-10 实证):`code=596 "You have not opened Yundun Content Moderation Service"`, + 阿里云**请求被拒也回 HTTP 200 + success:false + 无 data**。现已把 code/msg/requestId + 响应摘要写进错误。 +- ⚠️ **硬门槛:「内容安全 1.0」需企业实名认证**(官方 69806),个人认证账号永远开不了。 + 替代:迁 `green-cip`(2.0,标签体系不同、需改代码)或退回 `auto`/`qiniu`。RAM 需 `AliyunYundunGreenWebFullAccess`。 +- `data[].code != 200` 仍 fail-closed 置 block,但现在会打 Warn(区分「审核失败」与「真违规」)。 -## 表单校验(勿重复造轮子) +## 表单校验 -- **长度/必填校验已在表单层完成**:字段的 `valid:"required;maxlen:N"` 标签由 - `internal/form/form.go:131` 的 `govalid.Check()` 在 `Bind()` 时执行,handler 顶部用 - `ctx.HasError()` 承接(见 `route/user/profile.go:76`、`route/user/harassment.go:30`)。 - 报错文案通过标签的 `msg:` 指向 i18n 键(如 `form.profile.name` / `form.profile.linkMax`)。 -- ⚠️ **因此不要再在 handler 里手写一套长度校验**:既会成为永不触发的死代码(超限早在 Bind 阶段就被拒), - 又容易与真实上限冲突(profile 实际为:Name≤20、Intro≤100、各 link≤30,而非想当然的 50/500/500)。 - 2026-09-15 曾据此误判 P2「profile 缺长度校验」并写了一版,已回滚。 -- 判断某字段是否已有校验:**先看 `internal/form/*.go` 的 struct 标签**,不要只看 handler 代码。 +- 长度/必填由 `valid:"required;maxlen:N"` 标签在 `Bind()` 阶段执行(`internal/form/form.go:131` govalid.Check), + handler 顶部用 `ctx.HasError()` 承接,文案经 `msg:` 指向 i18n 键。 +- ⚠️ **不要再在 handler 手写长度校验**:那是永不触发的死代码,且易与真实上限冲突 + (profile 实际 Name≤20 / Intro≤100 / link≤30)。2026-09-15 曾据此误判 P2 并已回滚。 +- 判断字段有无校验:**先看 `internal/form/*.go` 的 struct 标签**。 ## GORM 约定 -- **字符串列默认值必须带引号**:`gorm:"default:'none'"`(裸写 `default:none` 会生成非法 SQL)。 -- `default:` 标签会让 GORM 在 **Create** 时把该字段的**零值替换为默认值** - (`feature_control.go:79` 有 ⚠️ 记录,曾导致「取消勾选保存后库里仍是 true」)。 - 因此「空串有语义」的字段不要加 `default:`(如 `ModerationStrictness` 用空串表示「跟随站点默认」)。 - `Updates(map[string]interface{}{...})` 不受此影响(只写指定键)。 -- **启动期「历史数据归一」的既有惯例**:在 `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 命令记得同时改这里。 +- 字符串列默认值必须带引号:`gorm:"default:'none'"`。 +- `default:` 会让 GORM 在 Create 时把零值替换为默认值(曾致「取消勾选保存后库里仍 true」)。 + 「空串有语义」的字段不要加 `default:`。`Updates(map)` 不受影响。 +- 启动期「历史数据归一」惯例:`db.Init` 的 AutoMigrate 之后追加幂等函数(`backfillFeatureControls` 等), + 失败仅告警不阻断启动。 +- ⚠️ **事务里复用同一个 `tx` 句柄会串条件**(事务句柄 clone==0)。每条语句都要重新取句柄: + `tx.WithContext(ctx).<链式>`。曾生成 `DELETE FROM follow_ups WHERE AND ` → 42703。 +- ⚠️ **软删除 + 唯一索引 = 槽位被永久占用**。User.email/domain、Conversation.token、VoteSubmission 复合唯一索引 + 都不含 deleted_at,而 gorm 查重默认带 `deleted_at IS NULL` → 「查重放行、INSERT 被索引拦下」= 500。两种语义: + - **停用(可恢复)**:`users.Deactivate` = `purgeUserContent()` 物理删内容 + **软删用户行**(身份占位)。 + 邮箱/域名继续占用是**有意为之**;注册侧必须 `Unscoped` 识别并给 `ErrDeactivatedEmail/Domain` + (见 `validate`/`fieldTaken`),**不能**当普通重复邮箱。恢复用 `TamaBox restore`。 + - **封禁(不可恢复)**:`BanUserAndCleanup` = `purgeUserContent()` + **物理删用户行**,邮箱释放。 + - 级联内容删除一律 `Unscoped()`;`voteSubmissions.DeleteByVoteID` 同理。 +- 唯一键冲突兜底 `isDuplicateKeyError(err)`(MySQL 1062 / PG 23505);注册不需要额外兜底。 +- ⚠️ 新增 CLI 命令记得同时注册到 `cmd/tamabox.go` 的 `app.Commands`(`ban.go` 曾写好却没注册 → 命令不可达)。 -## 文件工具陷阱(重要) +## 文件工具陷阱 -- `templates/user/moderation-strictness.html`:**用 Write/Edit 写它会被自动注入 `data-page-node-id` 属性** - (每次一批新 id);**用 shell(sed)改则不会**。注入方是「文件工具触发的后处理钩子」,不是文件监听。 - → **该文件今后只用 shell 修改**。相关断言(`templates/render_test.go`)已改成不依赖属性顺序的正则。 -- 往 gofmt 对齐组**中间**插 map 键,会把「拆分后剩余组」整组重新对齐 → 污染 diff。 - 新键应插在组末尾或独立成组(前后留空行)。i18n 字典已因此返工过一次。 - 同理:**在结构体/字面量对齐组中间插入 `//` 注释行会打断对齐组**,使相邻行被重新对齐; - 要加说明就放在该组**末尾**(下一个注释区之前),或改用行尾注释。 - 验证方法:`tr -d '\r' < f.go > a.go; gofmt a.go > b.go; diff a.go b.go`(无输出=不会重排)。 -- ⚠️ **gofmt 会把「文档注释」里的 `''` 规范化成 `”`**(Go 1.19+ doc comment 重排:`` `` ``→`“`、`''`→`”`), - 于是 `gofmt -l` 反复报未格式化。**注释里不要用 `''`**,改写为「空字符串」等文字。 -- ⚠️ `gofmt` 是 **Windows 原生程序,看不到 MSYS 的 `/tmp` 路径**(报 `GetFileAttributesEx ... cannot find`)。 - 临时比对文件要放到 Windows 可见路径(如 `C:/Users//AppData/Local/Temp/...`)。 +- `templates/user/moderation-strictness.html`:**用 Write/Edit 会被注入 `data-page-node-id`**(shell/sed 不会)。 + → 该文件今后只用 shell 改。 +- 往 gofmt 对齐组**中间**插 map 键/`//` 注释会打断对齐组 → 污染 diff。新键插在**组末尾**或独立成组。 + 验证:`tr -d '\r' < f.go > a.go; gofmt a.go > b.go; diff a.go b.go`(无输出=不重排)。 +- ⚠️ gofmt 会把 doc comment 里的 `''` 规范化为 `”`(Go 1.19+),导致反复报未格式化。**注释里别用 `''`**。 +- ⚠️ gofmt 是 Windows 原生程序,看不到 MSYS `/tmp`;临时比对文件放 Windows 可见路径。 -## 会话失效后的落点(闪信 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 指令。 +- **终止性操作(停用/注销)落「独立公开成功页」**,不要落登录页:既有 `/deactivated` + (`user.DeactivatedProfile` → `templates/user/deactivated.html`)。停用后 `Session.Flush()` 清空会话, + 落 `/login` 会让用户以为没生效;该路由必须注册在**公开区**(`reqUserSignIn` 之外)。 +- **继续可登录的操作(改密码后重登)才用闪信 + `/login`**:顺序**先 `Session.Flush()` 再 `SetSuccessFlash()`**。 +- i18n 键名:`deactivate.*` = 停用表单页;`deactivated.*` = 停用成功页。 +- `templates/embed.go` 整目录 embed,**新增模板自动嵌入**,无需改指令。 ## 仓库状态 -- **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。 - 备份/回滚一律用 tar.gz(排除 `.git`、`.workbuddy`),放在项目同级目录: - `C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。 -## 测试环境(box-dev-api.337845818.best) +- **git 仓库已损坏**,用户明确要求「不要动 git」。备份/回滚用 tar.gz(排除 `.git`、`.workbuddy`), + 放项目同级:`C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。 -- **该域名跑的就是本工作区的构建**(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。 +## 测试环境 box-dev-api.337845818.best + +- **跑的就是本工作区的构建**(实测本地新模板/路由/文案能在线访问),但**部署有延迟** → + 改完代码要提醒用户重新部署,不要假定已生效。 +- 站点默认语言 **ja**(`shared.App.Lang()`),匿名断言别写中文,用语言无关标记。 +- 验证码是**数学题**(`CaptchaType == "math"`):`GET /api/captcha/generate` 取 `{data:{id,question}}`, + 算 `a op b`,提交 `g-recaptcha-response = ":<答案>"`;表单还需页面里的 `_csrf` 隐藏域。 +- 可复用端到端巡检脚本:`dev/devcheck-e2e.py`(28 项)——注册/登录/投票**全链路**(建票发布/公开页/匿名提交/ + 导出计数/草稿/表单型)/登录态接口与导出/匿名提问/停用/重注册被拒/公开页/四语言。 + 单轮会留下一个已停用的身份占位,清理见「管理 CLI」。 + ⚠️ 投票单选导出的 CSV **末行是 `总参与,N`**,与选项票数行同形,解析票数时必须单独匹配该行。 +- 表单字段名易错:提问 `content`;回答 `answer`。`GET /user/votes` **故意** 302 到 `/user/questions#votes`; + 匿名无邮箱提问成功**直接 200 渲染成功页**(不重定向)——都不是 bug。 ## 管理 CLI(`TamaBox `) - 注册在 `cmd/tamabox.go` 的 `app.Commands`。现有:`web` / `censor` / `uid` / `ban` / `restore`。 -- `ban --email|--domain [--yes]` → 物理删除用户 + 内容(不可恢复,邮箱释放)。 +- `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)。 + `--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。 +- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=` 指定;`conf.Init` 强制 + `app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。 diff --git a/dev/__pycache__/devcheck-e2e.cpython-313.pyc b/dev/__pycache__/devcheck-e2e.cpython-313.pyc index 80c777f..4088802 100644 Binary files a/dev/__pycache__/devcheck-e2e.cpython-313.pyc and b/dev/__pycache__/devcheck-e2e.cpython-313.pyc differ diff --git a/dev/devcheck-e2e.py b/dev/devcheck-e2e.py index b74c39f..d7afee3 100644 --- a/dev/devcheck-e2e.py +++ b/dev/devcheck-e2e.py @@ -3,7 +3,8 @@ """TamaBox 部署后冒烟巡检(端到端)。 用途:对已部署环境跑一遍核心链路,用于发版/重新部署后的回归。 -覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 匿名提问 -> 停用 +覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 投票全链路(建票发布/公开页/匿名提交/ + 结果导出计数/草稿/表单型)-> 匿名提问 -> 停用 -> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长** -> 公开页只读巡检 -> /deactivated 四语言渲染。 @@ -74,7 +75,12 @@ class Client: 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")) + # 读取错误响应体本身也可能超时(尤其 302 无体/远端慢),不能让它把巡检整轮打断 + try: + body = e.read().decode("utf-8", "replace") + except Exception: # noqa: BLE001 + body = "" + return Resp(e.code, dict(e.headers), body) except Exception as e: # noqa: BLE001 return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e)) @@ -138,6 +144,100 @@ def check_public_pages(base): step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad)) +def create_vote(c, title, vtype="single", options=None, fields=None, action="publish"): + """提交一次投票创建(POST /user/votes)。 + + 后端对创建动作一律按 draft=true 解析(草稿可随时暂存、跳过审核), + action=publish 时再走 validateForPublish + 内容审核;因此这里两种动作都可直接测。 + """ + page = c.get("/user/votes/new") + data = { + "title": title, "description": "", "visibility": "public", + "voter_scope": "anyone", "show_result": "after_vote", + "type": vtype, "max_choices": "1", "allow_custom": "", + "start_at": "", "end_at": "", "quota_limit": "0", + "options": json.dumps(options or [], ensure_ascii=False), + "form_fields": json.dumps(fields or [], ensure_ascii=False), + "action": action, "_csrf": csrf_of(page.body), + } + return c.post("/user/votes", data) + + +def check_user_assets(owner): + """登录态的接口/导出,以及停用页的「身份占位」说明。 + + 停用页说明用**结构断言**(.uk-text-muted 段落非空),不写死文案 —— 站点语言随会话变化。 + """ + r = owner.get("/api/v1/user") + step("登录态 /api/v1/user 返回 JSON", r.status == 200 and r.body.lstrip().startswith("{"), + "HTTP %s" % r.status) + step("登录态 /explore", owner.get("/explore").status == 200) + + csrf = csrf_of(owner.get("/user/profile").body) + r = owner.post("/user/profile/export-json", {"_csrf": csrf}) + step("导出 JSON", r.status == 200 and r.body.lstrip().startswith(("{", "[")), + "HTTP %s" % r.status) + r = owner.post("/user/profile/export", {"_csrf": csrf}) + step("导出 Excel", r.status == 200, "HTTP %s %s" % (r.status, r.h.get("content-type", ""))) + + r = owner.get("/user/profile/deactivate") + note = re.search(r'class="uk-text-small uk-text-muted">(.*?)', r.body, re.S) + step("停用页含身份占位说明", r.status == 200 and bool(note) and note.group(1).strip() != "") + + +def check_vote_flow(owner, base): + """投票全链路:建票发布 -> 管理列表 -> 公开页 -> 匿名提交 -> 导出计数 -> 草稿 -> 表单型。""" + ts = time.strftime("%m%d%H%M%S") + + title = "巡检单选投票%s" % ts + r = create_vote(owner, title, "single", options=["选项甲", "选项乙", "选项丙"]) + step("创建单选投票并发布", r.status == 302 and "questions" in r.loc, + "HTTP %s -> %s %s" % (r.status, r.loc, r.alert())) + + q = owner.get("/user/questions") + step("投票出现在管理列表", title in q.body) + mt = re.search(r"copyShareURL\('([^']+)'", q.body) + mv = re.search(r"/user/votes/(\d+)/edit", q.body) + token, vid = (mt.group(1) if mt else None), (mv.group(1) if mv else None) + step("取得分享 token / 投票 ID", bool(token and vid), "%s / %s" % (token, vid)) + if not (token and vid): + return + + anon = Client(base) + pv = anon.get("/vote/%s" % token) + step("公开投票页渲染", pv.status == 200 and title in pv.body, "HTTP %s" % pv.status) + mo = re.search(r'name="option_id" value="(\d+)"', pv.body) + step("公开页含可选项", bool(mo)) + if not mo: + return + + sub = anon.post("/vote/%s/submit" % token, { + "option_id": mo.group(1), "g-recaptcha-response": captcha(anon), + "custom_option": "", "_csrf": csrf_of(pv.body), + }) + step("匿名提交投票", sub.status == 302, "HTTP %s -> %s %s" % (sub.status, sub.loc, sub.alert())) + + # 导出仅作只读校验:单选导出的 CSV 每行是 <选项,票数>,末行为 <总参与,N>。 + # 只认「总参与」这一行,避免把各选项票数一起求和导致 2 倍计数。 + exp = owner.get("/user/votes/%s/export" % vid) + mtot = re.search(r"总参与,(\d+)", exp.body) + total = int(mtot.group(1)) if mtot else -1 + step("导出显示总参与 = 1", exp.status == 200 and total == 1, + "HTTP %s 总参与=%s" % (exp.status, total)) + + dr = create_vote(owner, "巡检草稿%s" % ts, "single", options=["甲", "乙"], action="save") + step("保存草稿跳编辑页(?saved=1)", + dr.status == 302 and "/user/votes/" in dr.loc and "saved=1" in dr.loc, + "HTTP %s -> %s" % (dr.status, dr.loc)) + + fr = create_vote(owner, "巡检表单投票%s" % ts, "form", fields=[ + {"field_type": "text", "label": "昵称", "required": True, "max_length": 50}, + {"field_type": "textarea", "label": "建议", "required": False, "max_length": 200}, + ]) + step("创建表单型投票并发布", fr.status == 302 and "questions" in fr.loc, + "HTTP %s -> %s %s" % (fr.status, fr.loc, fr.alert())) + + def check_deactivated_i18n(base): """停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。""" bad = [] @@ -203,6 +303,8 @@ def main(): 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) + check_user_assets(owner) + check_vote_flow(owner, base) # 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达 r = pub.get("/_/%s" % domain) @@ -246,4 +348,13 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except KeyboardInterrupt: + raise + except Exception as e: # noqa: BLE001 + # 远端抖动等意外中断时,仍把已完成项的结论打出来,避免只剩一段 traceback + print("\n巡检意外中断:%s: %s" % (type(e).__name__, e)) + for d, ok, ex in RESULTS: + print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else "")) + sys.exit(2)