207 lines
18 KiB
Markdown
207 lines
18 KiB
Markdown
# TamaBox-v4 项目长期笔记
|
||
|
||
## 协作惯例
|
||
|
||
- 结论分级 P0/P1/P2 + 🔴/🟠;给修复建议前先做根因取证与实测复现,不要只静态推断。
|
||
- **改代码前先出「方案/变更清单」获批准,再分批落地**。用户常授权自主推进(「你先继续,后面我发现问题再说」),但重大问题仍要显式指出。
|
||
- 工作场景用 `@scene#N:"名称"` 标记。
|
||
|
||
## 构建与测试
|
||
|
||
- **本机无 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-<rand>` 并 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 <files>`。
|
||
- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。
|
||
- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。
|
||
要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案:
|
||
**`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace <cgo依赖>=../本地stub` → 编译/测试**。
|
||
副本约 15MB、编译约 2~7 分钟。**别用 `s3stub` overlay 检查 s3.go 本身**——overlay 会把 s3.go
|
||
换成桩,等于根本没检查它(只能检查调用方)。见 2026-10-15 的批次 A 落地记录。
|
||
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
|
||
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
|
||
这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。
|
||
- ⚠️ **本沙箱起不了 `TamaBox web`**:flamego/session 固定走 redis store,本机 **6379 未开**,
|
||
启动即 `panic: session: open database: default addr for network ...`。所以任何需要 HTTP 的
|
||
端到端验证(L4 停用页、分享卡拦截、发布拦截)只能部署到 box-dev 后做,别在本地硬试。
|
||
- ⚠️ **探测端口 200 可能是别的进程**:8899 被本机无关进程占用,服务 panic 后 curl 仍返回 200,
|
||
差点误判成「改动没生效」。起服务后必须看任务日志确认 `Starting web server` 且无 panic,
|
||
再信任 curl 结果;端口探测本身不能证明是自己的服务。
|
||
- ⚠️ **本机 python urllib 不稳定**:`https_proxy=127.0.0.1:54881` 下偶发「响应读超时」
|
||
(同一时刻 curl 完全正常)。曾据此误判「举报接口卡死 25 秒」,实际是客户端问题。
|
||
**验证线上行为优先用 curl**;必须用 python 时加短超时 + 重试,且不要用 `/tmp` 路径
|
||
(Windows python 把 `/tmp/x` 解析成 `C:\tmp\x`,而 MSYS curl 写的是 MSYS 的 `/tmp`,两者不通)。
|
||
|
||
## 图片上传与编码(`internal/storage/s3.go`)
|
||
|
||
- 三档编码规格 `EncodeSpec{MaxSide, Quality}`:头像 512/q78、背景 1920/q80、通用 1600/q80。
|
||
头像依据:模板最大展示 120px(`.tb-frame-avatar`),前端 cropper 输出上限 512。
|
||
⚠️ 档位只作用于**新上传**,线上已有图片不会被重压。
|
||
- 所有转换分支都有 `len(webp) >= len(原图)` 回退——线框图/小图标上有损 WebP 反而更大
|
||
(实测 200x200 线框图 PNG 652B vs WebP 908B),不加兜底会越转越大。
|
||
- **EXIF 方向必须在转码前归一**(`exif.go` 手工解析 APP1,无外部依赖)。
|
||
`image.Decode` 与 Pillow 默认都不读 EXIF;**转成 WebP 后 EXIF 被丢弃、方向永久丢失**。
|
||
顺序铁律:**先归一方向 → 再限边缩放**(归一会交换宽高)。
|
||
⚠️ **比例校验必须用同一套方向**(`aspect.check(cfg, orientation)` + `orientationSwapsDimensions`),
|
||
否则手机竖拍照片(像素横躺 + Orientation=6)直传头像/背景会被误判「比例不符」而拒。
|
||
比例校验只交换宽高、**不解码像素**(大图内存)。前端 cropper 路径不受影响(canvas 已归一)。
|
||
⚠️ **头像 1:1 测不出这个缺口**(对称比例,交换宽高比例不变);**只有背景 16:9 能触发**。
|
||
线上验证脚本是 `dev/devcheck-upload.py`(不是 devcheck-avatar.py),12/12 覆盖
|
||
A 分档 / 背景对照 / EXIF ori6 放行 / **像素级方向比对(vs Pillow exif_transpose 红条包围盒)** / 反向用例(无 EXIF 竖图仍被拒)。
|
||
⚠️ 判定「方向是否归一」别用区域平均亮度:采样带比标记条还宽会稀释颜色,误判成「没归一」。
|
||
按颜色阈值定位像素包围盒才靠谱。**排查先立黄金标准(Pillow exif_transpose),否则会去改本来正确的代码。**
|
||
- **上传管线只完整解码一次**:`image.DecodeConfig` 一次同时拿 format + 宽高,
|
||
供像素上限 / 比例 / WebP 透传三处共用;完整解码只在真正转码时发生。
|
||
`ConvertStaticImageToWebP(data []byte, spec)` 收字节而非 `io.Reader`。
|
||
实测堆峰值:JPEG 4000×3000 单解 18MB / 双解 36MB(正好 2 倍),1C1G 上很关键。
|
||
- GIF 帧数上限 `maxGIFFrames = 200`:`CheckPixelLimit` 只看逻辑屏尺寸,
|
||
几百帧的大 GIF 体积小但能让 `cwebp` 长时间吃 CPU。
|
||
- **method=6 已评估并否决**:实测耗时只增 1.2~1.9x(不是原以为的 5~10x),
|
||
但体积只省 ~3%,1C1G 上不划算。
|
||
- `cwebp` 缺失时的 GIF 静默回退已加 `logrus.Warn`;生产机(裸 Linux)已确认装有 `cwebp`。
|
||
|
||
|
||
## 内容审核
|
||
|
||
- 线上生效通道 = **七牛云**(`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`)
|
||
- 实现与上游/官方示例一致;`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(区分「审核失败」与「真违规」)。
|
||
|
||
## 提示与 Flash(route 层)
|
||
|
||
- ⚠️ **同请求渲染必须用 `ctx.SetError` / `ctx.SetSuccess`,不能用 `*Flash`**:`ctx.Success("...")` 直接出页面时,
|
||
`SetErrorFlash` 写的是 session、要等**下一次请求**才会被读取,提示会静默丢失(表现为「点了没反应」)。
|
||
`*Flash` 只适用于 `Redirect` 之后再渲染的页面。
|
||
2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。
|
||
- 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、
|
||
`question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。
|
||
- ✅ **2026-10-18 已完成全量清理**:扫描 `route/**/*.go` 中 `Set*Flash` / `SetInternalErrorFlash`
|
||
后 8 行内跟 `ctx.Success(...)`(而非 `Redirect`)的位置,共 **9 处**:`follow_up.go` 2 处、
|
||
`page.go` 1 处、`question.go` 1 处、`profile.go` 5 处,全部改为 `SetError(err[, f])` / `SetInternalError()`。
|
||
`Set*Flash + Redirect` 112 处属正常模式,未动。`vote.go:702/704` 是扫描器误报(其后是 Redirect)。
|
||
扫描脚本思路可复用:逐行找 flash 调用 → 向下找第一个 `Success(`/`Redirect(` → 前者即缺陷。
|
||
|
||
## 表单校验
|
||
|
||
- 长度/必填由 `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:` 会让 GORM 在 Create 时把零值替换为默认值(曾致「取消勾选保存后库里仍 true」)。
|
||
「空串有语义」的字段不要加 `default:`。`Updates(map)` 不受影响。
|
||
- 启动期「历史数据归一」惯例:`db.Init` 的 AutoMigrate 之后追加幂等函数(`backfillFeatureControls` 等),
|
||
失败仅告警不阻断启动。
|
||
- ⚠️ **事务里复用同一个 `tx` 句柄会串条件**(事务句柄 clone==0)。每条语句都要重新取句柄:
|
||
`tx.WithContext(ctx).<链式>`。曾生成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → 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`**(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 可见路径。
|
||
|
||
## 会话失效后的落点
|
||
|
||
- **终止性操作(停用/注销)落「独立公开成功页」**,不要落登录页:既有 `/deactivated`
|
||
(`user.DeactivatedProfile` → `templates/user/deactivated.html`)。停用后 `Session.Flush()` 清空会话,
|
||
落 `/login` 会让用户以为没生效;该路由必须注册在**公开区**(`reqUserSignIn` 之外)。
|
||
- **继续可登录的操作(改密码后重登)才用闪信 + `/login`**:顺序**先 `Session.Flush()` 再 `SetSuccessFlash()`**。
|
||
- i18n 键名:`deactivate.*` = 停用表单页;`deactivated.*` = 停用成功页。
|
||
- `templates/embed.go` 整目录 embed,**新增模板自动嵌入**,无需改指令。
|
||
|
||
## 仓库状态
|
||
|
||
- **git 仓库已损坏**,用户明确要求「不要动 git」。备份/回滚用 tar.gz(排除 `.git`、`.workbuddy`),
|
||
放项目同级:`C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。
|
||
|
||
## 测试环境 box-dev-api.337845818.best
|
||
|
||
- **跑的就是本工作区的构建**(实测本地新模板/路由/文案能在线访问),但**部署有延迟** →
|
||
改完代码要提醒用户重新部署,不要假定已生效。
|
||
- 站点默认语言 **ja**(`shared.App.Lang()`),匿名断言别写中文,用语言无关标记。
|
||
- 验证码是**数学题**(`CaptchaType == "math"`):`GET /api/captcha/generate` 取 `{data:{id,question}}`,
|
||
算 `a op b`,提交 `g-recaptcha-response = "<id>:<答案>"`;表单还需页面里的 `_csrf` 隐藏域。
|
||
- 可复用端到端巡检脚本:`dev/devcheck-e2e.py`(28 项)——注册/登录/投票**全链路**(建票发布/公开页/匿名提交/
|
||
导出计数/草稿/表单型)/登录态接口与导出/匿名提问/停用/重注册被拒/公开页/四语言。
|
||
单轮会留下一个已停用的身份占位,清理见「管理 CLI」。
|
||
⚠️ 投票单选导出的 CSV **末行是 `总参与,N`**,与选项票数行同形,解析票数时必须单独匹配该行。
|
||
- 表单字段名易错:提问 `content`;回答 `answer`。`GET /user/votes` **故意** 302 到 `/user/questions#votes`;
|
||
匿名无邮箱提问成功**直接 200 渲染成功页**(不重定向)——都不是 bug。
|
||
|
||
## 管理 CLI(`TamaBox <cmd>`)
|
||
|
||
- ⚠️ **构建入口有两个,必须同步维护**:
|
||
- 根目录 `go build .` → 用根 `tamabox.go` 的 `app.Commands`;
|
||
- `Dockerfile` 里是 `go build ... -o NekoBox ./cmd/` → 用 `cmd/tamabox.go` 的 `app.Commands`。
|
||
两处命令列表必须一致,否则「本机构建能用、Docker 部署没有该命令」(或反之)。`cmd/tamabox.go`
|
||
曾是只注册到 `ban` 的过期副本,连 `restore` 都漏了;2026-10-18 已把两处对齐到 8 个命令。
|
||
新增命令只改其中一处时,实测报 `No help topic for '<cmd>'`。
|
||
- 现有:`web` / `censor` / `uid` / `ban` / `restore` / `reports` / `restrict` / `unrestrict`。
|
||
- `restrict --domain|--email --level L1|L2|L3|L4 --days N --reason "..." [--yes]` → 写 `user_restrictions`,
|
||
**替换语义**(先撤销该用户所有生效中记录再写新的),到期靠 `expires_at` 比较时间自动失效,无 cron。
|
||
- `reports --domain|--email [--days N]` 出证据包(被举报条目 / 公开回答条数 / 占比 / 历史处置);
|
||
`reports --pending` 列全站待处理;`reports --stats --days N` 出全站 Top 被举报箱主 + Top 举报 IP
|
||
(识别「举报被武器化」)。三者只读。
|
||
- `unrestrict --domain|--email` 撤销全部生效中处置。
|
||
- `hide-public --domain|--email [--only-reported] [--since N] [--yes]` → 把该箱主「公开且已回答」的题
|
||
批量置为私密。`--only-reported` 是推荐用法(只收被投诉的,保留正常内容);`--since N` 限最近 N 天。
|
||
⚠️ GORM 链式对象在 Count/Find 后带 Statement 状态,**统计/预览/更新必须各自重新 build query**,
|
||
复用一个实例会条件污染。
|
||
- `ban --email|--domain [--yes]` → 物理删除用户+内容(不可恢复,邮箱释放)。
|
||
- `restore --email|--domain [--password <新密码>] [--yes]` → 恢复已停用账号(清 deleted_at)。
|
||
`--password` 必须在恢复之后执行(`UpdatePassword` 走普通作用域)。
|
||
- 本地冒烟:仓库只有 `conf/app.sample.ini`,用 `NEKOBOX_CONFIG_PATH=<ini>` 指定;`conf.Init` 强制
|
||
`app.external_url`、`server.salt`、`server.xsrf_key` 非空。跑 CLI 会自动 AutoMigrate,可指向临时库验证后 DROP。
|
||
|
||
## 架构约定:刻意不做站方管理后台
|
||
|
||
- 项目只有**箱主自己的** `/user` 面板(`reqUserSignIn`),**没有管理员角色、没有站方后台**,用户明确
|
||
要求后续也不要加。因此站方的一切运营动作只能落在:**CLI 子命令 / 邮件告警 / 改配置后重启**。
|
||
- 判断"要不要新增管理界面"一律先问:能不能用 CLI 表达?能就不用 UI。
|
||
- 站方→用户的告知走**模板渲染**(读库里的状态字段渲条幅/独立页),不给运营者开写入口。
|
||
- `/user/questions` 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。
|