16 KiB
16 KiB
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 分钟。别用s3stuboverlay 检查 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.2
1.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(区分「审核失败」与「真违规」)。
表单校验
- 长度/必填由
valid:"required;maxlen:N"标签在Bind()阶段执行(internal/form/form.go:131govalid.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在代码注释里被称作"管理后台",指的是箱主自己的收件箱,不是站方后台。