17 KiB
17 KiB
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)。跑法:✅ 2026-09-15 起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/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统一归一化 01(七牛本身即 01;阿里云 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)。