Files
tamabox/.workbuddy/memory/MEMORY.md
T
2026-09-15 13:10:57 +08:00

183 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TamaBox-v4 项目长期笔记
## 审计/失败分级惯例(沿用用户偏好)
- 结论分级:P0/P1/P2,配 🔴(阻断级)/🟠(高)。给修复建议前先做根因取证与并行诊断。
- 改代码前先出「方案/变更清单」获批准,再分批落地;用户倾向方案 A 或明确指定方向。
- 工作场景用 `@scene#N:"名称"` 标记(如 `@scene#15:"日常开发"`)。
- 用户常说「你先继续,后面我发现问题再和你说」→ 可自主推进,但重大问题仍要显式指出。
## 构建与测试(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-<rand>` 并在成功时 DROP)。跑法:
```
DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432 \
go test -overlay=<overlay.json> -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 <pkg>`(纯元数据、不编译)拆开「模块解析」与「编译」
两层;前者 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 <files>`(原 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。
## 内容审核架构(简洁版)
- 线上生效的审核通道是**七牛云**(`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。
⚠️ 推论:**显式选定的通道一旦坏掉(账号未开通/欠费/权限不足),整站发布+私信会全部被拒**,现象隐蔽(只在日志)。
### 阿里云 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)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。
## 表单校验(勿重复造轮子)
- **长度/必填校验已在表单层完成**:字段的 `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 代码。
## 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 <follow_ups 条件> AND <messages 条件>`
→ `字段 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 命令记得同时改这里。
## 文件工具陷阱(重要)
- `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/<user>/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`),放在项目同级目录:
`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 = "<id>:<答案>"`;
表单还需页面里的 `_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>`)
- 注册在 `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 <cmd>` 会自动 `AutoMigrate`,可直接指向一个临时库做端到端验证(用完 DROP)。