Files
tamabox/.workbuddy/memory/MEMORY.md
T
2026-10-18 22:18:21 +08:00

17 KiB
Raw Blame History

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 结果;端口探测本身不能证明是自己的服务。

图片上传与编码(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.21.9x(不是原以为的 510x), 但体积只省 ~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。
  • TODO(用户 2026-10-18 明确要求后续处理):全量排查 SetErrorFlash + Success(...) 组合并统一改为 SetError,做一轮专项清理。

表单校验

  • 长度/必填由 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 列全站待处理。两者只读。
  • unrestrict --domain|--email 撤销全部生效中处置。
  • 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 在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。