Compare commits

..
39 Commits
Author SHA1 Message Date
tamakyi 5fdc3df22d 修复: 评论组件泄露私密 token、cwebp 无超时、选项长度与去重
Waline(安全)
- item.html 的 path 从 window.location.href 改为剔除 ?t= 后的 URL。
  私密题 /_/<domain>/<id>?t=<token> 的钥匙原本会整条发往 Waline 服务端
  并落进其访问日志。只删 t、保留其他参数,公开题的评论线程键不变。

cwebp(可靠性)
- ConvertGifToAnimatedWebP 改用 exec.CommandContext + 30s 上限。
  原来裸 exec.Command 无超时,畸形/超大 GIF 会把子进程和上传 goroutine 一起挂住。
  超时杀进程并记 Warn,调用方按既有逻辑回退为原 GIF,不影响上传成功。

投票选项长度(正确性)
- vote_options.content 由 varchar(200) 放宽到 300,与配置默认值一致。
  此前 201–300 字的选项通过校验后在写库阶段失败。
- 新增 db.MaxVoteOptionLen;voteOptionMaxLen() 取配置与存储上限的较小值,
  校验与前端 maxlength 同源,避免配置调大后再次越界。

多选 checkbox 去重(正确性)
- 抽出 appendUniqueValue,预置值与「其他」文本共用同一 seen 集合。
  此前重复提交同名字段会让同一选项在统计里被计多次。

测试
- vote_dedupe_test.go:去重与顺序。
- vote_option_len_test.go:配置小于/等于/大于存储上限三种情形。
- TestVoteOptionMaxLengthRoundTrip:300 个多字节字符存回不截断。
- TestVoteOptionColumnWidens:把列改回 200 后 AutoMigrate 能扩回 300(老库升级路径)。

验证:WSL2 Debian + PostgreSQL 全量 go test ./... 27 个包全绿;
隔离实例渲染单题页确认新的 path 逻辑。
2026-11-05 17:04:47 +08:00
tamakyi 7ffab24025 安全: 修复页面密码零值绕过,导出不再泄露投票者 IP
页面密码(P0,2026-11-05 复核发现)
- 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer`
  把「session 里没有授权记录」(断言失败得 0)与「新增列默认值 0」判成相等,
  升级瞬间所有存量页面密码静默失效:匿名访客不带 cookie 即可读箱主页。
- 收口为 pagePasswordAuthorized:缺 key 一律视为未授权,
  key 名由 pagePassKey 统一生成,Pager / ListAPI / AskStatusAPI 三处共用。
- 新增迁移 v6 回填 page_password_ver / ask_password_ver(仅仍为 0 且已设密码的行,
  幂等),让模型语义与「改过一次密码」一致,并作废升级前的布尔授权。
- AskStatusAPI 的 L4 查询补日志(保持与 Pager 一致的 fail-open 口径)。

导出(P1)
- JSON 导出的投票提交不再输出 voter_ip。库里照常保留,
  防刷、取证与站方 CLI 处置不受影响,只是不进箱主的自助导出文件。

测试
- route/question/page_password_test.go:8 个用例钉住零值/类型不符/轮换失效等边界。
- internal/db/page_password_migration_test.go:回填只碰该碰的行且幂等。

验证:WSL2 Debian + PostgreSQL 全量测试通过;隔离实例实测
ver=0 匿名访问返回密码页、API 401,输对密码后放行、改密后旧会话立即失效,
删除迁移账本后重启可正确回填。
2026-11-05 16:31:54 +08:00
tamakyi 25aee35030 安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性 2026-11-05 12:17:05 +08:00
tamakyi 7636456d4e 可靠性: PostgreSQL 无 Redis 时 session 不再误用 MySQL 存储 2026-11-05 12:16:46 +08:00
tamakyi 937e688cfa 可靠性: 完善 R2 备份恢复与对象生命周期 2026-11-05 08:41:55 +08:00
tamakyi 3e7719f200 安全: 修复凭据、遥测与账号数据清理风险 2026-11-04 14:38:19 +08:00
tamakyi c633789a92 fix footer 2026-11-02 17:17:06 +08:00
tamakyi ea9ba3e8fc 首页: 修复功能卡下方各卡片之间缺少间距
tb-grid 之后的三张独立卡片(域名迁移/一点提醒/近期更新)彼此相邻
却没有垂直间距,挤成一整块。用 .tb-grid ~ .uk-card 统一加 16px 上边距。
2026-10-30 17:56:21 +08:00
tamakyi 036156b6fb 蜜罐告警: 修复去重口径与空收件人短路
审阅蜜罐邮件提醒功能发现的问题,三项一并修:

1. autoban.alerted 从 map[string]bool(永不过期)改为带时间戳的
   map[string]time.Time,冷却期 24h 与蜜罐侧对齐。cleanup 里清理过期项,
   长期盯防同一来源不再漏掉后续攻击活动(此前一生只报一次)。
2. 蜜罐告警在 security_alert_email 为空时短路:route.go 注入 sendAlert
   前判空,收件人缺失置 nil,避免每次凭据提交白做 SMTP 拨号 + 刷 Warn。
3. 强化 security_alert_email 的配置警示(蜜罐邮件含明文密码,
   必须是个人邮箱、禁群组/分发列表)。
2026-10-30 17:17:25 +08:00
tamakyi 48eea98a27 docs(memory): 记录安全汇总批次的关键教训(ipinfo 维度差异 / 缓存毒化 / 地图合规) 2026-10-30 16:41:29 +08:00
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00
tamakyi 08bbd17751 storage: 对齐图片对象 key 到生产口径
生产真实 key 为 `YYYY/MM/<UnixNano>.<ext>`(无前缀、无 day 层),
与 route/question/page.go:uploadImage 一致;internal/storage 里三个
上传函数仍用旧的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`,从未上线。

- PictureKeyPrefix 置空,统一由 uploadKey 生成 key
- 扩展名跟随最终 ContentType(webp 转成功用 .webp,回退原图用原格式)
- OSS 原图直传通道取原文件名扩展名
- 新增 picture_test.go 锁定 key 格式,防回归
2026-10-29 20:04:31 +08:00
tamakyi c6966138b4 docs(memory): 记录迁移系统/备份脚本/通知的实现与 R2 存储结构 2026-10-29 19:27:29 +08:00
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 /
  新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。
  消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主
  自填 Chat ID,新提问 / 新投票回复点对点推送。

设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错,
绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步
执行,与现有邮件通知同一条路径。

6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。
profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。

测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00
tamakyi 1e9ffc0718 feat(ops): 新增 TamaBox backup 命令,同时备份数据库与 R2 图片
此前没有任何备份手段,而站点的图片全在 Cloudflare R2、数据库只存 key/URL ——
只备份 DB 的话,恢复后 key 指向的图片若已不在 R2(bucket 误删 / 账号丢失 /
图片被清理),页面就全是裂图。

新增 TamaBox backup --output <dir>:
- 数据库:pg_dump 全量导出(schema + data,标准恢复格式),密码经 PGPASSWORD
  环境变量传递避免出现在 ps 进程参数里
- 图片:从 R2 下载 tamabox 站全部图片,保持原 key 路径,并写 images-manifest.json
  (key → size → md5)用于恢复后完整性校验
- --db-only / --images-only 分开关

R2 访问方式的坑:storage.GetS3Client 的 endpoint 带 /<bucket> 后缀,对
GetObject/PutObject 恰好可用,但 ListObjectsV2 会把 URL 拼成 /<bucket>/<bucket>
而 404 NoSuchKey。备份脚本独立构造客户端(剥离 endpoint 的 bucket 后缀 +
UsePathStyle),List prefix="tamabox/" 后用完整 key 下载 —— 这是 R2 上 List
唯一稳定可用的姿势。

实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 下载的文件
大小与 R2 List 返回完全一致,路径保持 tamabox/ 结构。
2026-10-29 19:27:13 +08:00
tamakyi 62edf88029 feat(db): 版本化数据库迁移,改列名/删列不再静默丢数据
GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类
破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」,
也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls /
reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting)
留成了每次启动都全表扫一遍。

新增 schema_migrations 账本表 + runMigrations:
- 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动
- 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过
- AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更
  与一次性回填,两者分工互不替代

测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次
启动建账本 + v1~v5 落库,二次重启账本不变。
2026-10-29 19:27:03 +08:00
tamakyi 693b1102c2 fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
三处小修 + 两个格式清理,均来自全盘复查:

1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
   数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
   改为箱主不存在时也回显 ok:false,四种失败响应完全同形。

2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
   与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
   的巧合侥幸安全,现统一改 Strict。

3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
   到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。

顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00
tamakyi c9b426acbb fix(insights): 数据面板「每日回答数」按 answered_at 分组,而非 created_at
BoxTrend 的回答那条 SQL 复用了 d.day(基于 created_at 的日期表达式)做 GROUP BY,
但 WHERE 过滤用的是 answered_at。于是「40 天前创建、昨天才回答」的提问被排除在
窗口外(created_at < from),昨天的回答数里不出现;「今天创建、明天回答」的又
被记到今天。趋势图里的「每日回答数」根本不是「某天被回答了多少」。

单独构造 answerDay(基于 COALESCE(answered_at, updated_at)),WHERE / GROUP BY /
SELECT 三者口径一致。PG 用 to_char(... AT TIME ZONE INTERVAL ...),MySQL 用
CONVERT_TZ,与 analyticsDialectFor 对 created_at 的处理对称。

新增 box_analytics_test.go:构造「40 天前创建、昨天回答」的提问,断言计入昨天,
并做变异验证(改回 d.day → 测试失败)。
2026-10-29 18:52:29 +08:00
tamakyi 87176516f3 fix(data): 删除提问时追问与图片一并真删,不再软删残留
questions.DeleteByID 对主表用 Unscoped 真删,但级联清理的两个子 store 仍用软删:
FollowUp(内嵌 dbutil.Model)与 UploadImage(内嵌 gorm.Model)都带 DeletedAt,
裸 Delete 是软删。于是提问正文(含 FromIP、邮箱)删干净了,追问文本与图片元数据
(name/md5/key)却带着 deleted_at 永久残留,孤儿行的 question_id 指向已不存在的提问
—— 正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改。

两个 DeleteByQuestionID 改为 Unscoped 真删。关联表 UploadImageQuestion 无 DeletedAt,
本来就真删,无需 Unscoped(已注释)。

新增 question_delete_hard_test.go:用 Unscoped 计数断言删除后追问/图片/关联行物理上=0,
并做变异验证(改回软删 → 测试失败)证明测试真的能抓住这个回归。
2026-10-29 18:52:19 +08:00
tamakyi 7f86db8937 docs(memory): 记录数据面板与图片压缩的实现、验证与踩坑 2026-10-29 18:26:12 +08:00
tamakyi f9aaef17e5 feat(ux): 图片选图即压缩,上传体积从 MB 级降到 KB 级
服务端早已把静态图转 WebP、长边限 1600(storage.GeneralEncodeSpec),但上传这一跳
发生在压缩之前:手机拍的 4~8MB 照片要先原样传完才能被缩小,移动网络 1~2 Mbps 下
要等几十秒,超过 5MB 校验的直接被拒、连写好的正文一起丢。

现在选图后先在浏览器内压缩,再随表单上传:
- canvas → WebP,长边 1600、质量 0.8(与服务端同档),一张照片通常降到 200~400KB
- createImageBitmap + imageOrientation:from-image 摆正 EXIF,老浏览器回退不带选项
- submit capture 兜底:选图立刻提交时先等压缩完成再真正 submit

三条安全边界(缺一不可,否则会「压出比原图更差的结果」):
- GIF/SVG 跳过:canvas 会把 GIF 画成静止首帧、SVG 栅格化
- 压完不到原体积 85% 退回原图:已优化小图重编码反而更大
- 透明 PNG 优先 WebP(支持 alpha),不支持 WebP 编码时退回 PNG 而非 JPEG(否则透明变黑)

实测(WSL headless Chromium + CDP):4.6MB PNG → 11.7KB WebP(0.3%),
小图原样保留、GIF 跳过。

顺带加 dev/sync-wsl.sh:把同步脚本里 rsync --delete 误删目标机 conf/app.ini 的坑
固化掉(该文件含 DB 密码与对象存储密钥,不在源目录里,必须 exclude)。
2026-10-29 18:26:04 +08:00
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00
tamakyi 5a05a61647 docs(api): 说明 ask-status 的 Method 检查是防御而非主路径
路由只注册了 POST(token 不能进 URL),所以 GET 会落到 NotFound 返回 404,
handler 里的 405 分支实际走不到。留着是为了将来路由若改成 f.Any 时仍然安全,
加上说明避免被当成死代码删掉。
2026-10-29 17:19:26 +08:00
tamakyi 4fcaa197cf docs(memory): 第三批修复与三个新踩到的坑
- 2026-10-29.md:第三批 7 项改动与验证数据
- MEMORY.md:三条长期结论 ——
  「用户主动删除 = 真删」(模型全带 DeletedAt,默认 Delete 是软删)、
  「配额必须与内容同事务」(incrementQuotaTx 是唯一实现,且投票口径是票数不是个数)、
  「错误页不套模板」(f.NotFound 不经过组级中间件,注入 template.Data 会 panic)
- topics/quality.md:标注第三批范围

新踩到的三个坑:
1. gofmt 会把注释列表 `// 1) ...` 改写成 `//  1. ...`,被标为未格式化时先排除这个
2. Select(...) 漏列 = 字段静默为空(摘要永远是空串,只有一项测试红)
3. CDP 验证纯前端:chrome 必须作为独立后台进程启动,跟在别的命令后面 `&`
   会随 shell 退出被回收;headless=new 的空标签页 type 常是 other
2026-10-29 17:18:47 +08:00
tamakyi 5def41d0f8 feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
一、错误页

原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。

现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
  并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)

为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。

蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。

二、提问者找回自己的提问

不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:

- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
  点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1

它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。

列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 ![图片](https://...)
这种噪音,一条带图的提问几乎只剩链接。

验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
  响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
  注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00
tamakyi 89e263cbd4 docs(memory): 记录第二批修复与「Python write_text 会改行尾」这个坑
- 2026-10-29.md:第二批 9 项改动与验证数据(含投票结果 2 万条 0.211s→0.023s),
  以及剩余待办清单(级联删除事务、配额漂移、JSON-LD/RSS、箱主数据面板等)
- MEMORY.md:构建测试段补 write_text 行尾陷阱与排查手法(看 git diff --stat / git show),
  其它约定段补 /healthz 与 /readyz 的语义边界(存活探针不查依赖)
- topics/quality.md:标注第二批已完成的范围

附带说明:本轮我自己也踩了 write_text 的坑,把 14 个 LF 文件写成了 CRLF,
靠 git diff --stat 的「整文件重写」发现、再用 git show HEAD:<file> 的行尾比对恢复。
2026-10-29 16:29:02 +08:00
tamakyi 03cb5ddcf0 fix(ui): 提交防重复覆盖验证码路径、waline 按页加载、懒加载、safe-area、硬编码文案接字典
1) 防重复点击原来漏了最主要的一条路径
   footer 的全局 submit 监听会禁用按钮,但**程序化 form.submit() 不触发 submit 事件** ——
   走 reCAPTCHA / Turnstile 的流程是在回调里调 form.submit() 的,而默认配置
   captcha_type = recaptcha,也就是主提问流一直没有防重复。登录、注册、找回密码、
   投票、注销账号同样走这个回调。
   → 把「切提交中状态」抽成 tbBeginSubmit(form, trigger) 挂到 window,
     验证码回调先调它再 submit。7 个 data-callback="onSubmit" 的模板全部受益。
     顺带加了 data-tb-restore 的重复覆盖保护,避免 bfcache 复位后按钮卡在转圈。

2) waline.js / waline.css 从全站改按页加载
   只有 home.html 与 item.html 用评论区,但此前挂在 header 里 —— 登录、后台、投票、
   私聊每页都下载解析 waline.js(2.15 未压缩约 200KB)。实测 /login 已无引用。

3) 图片懒加载:explore 列表头像与题目配图加 loading="lazy"(首屏推荐位不加,避免影响 LCP)。

4) 移动端底栏:body 只预留固定 56px,而底栏 padding 含 safe-area-inset-bottom。
   带 Home Indicator 的 iPhone 上 inset 约 34px、底栏实际约 88px,最后约 32px
   (页脚备案、存活计时)被压在栏下,滚到底也看不到。改成 calc(56px + env(...))。

5) 硬编码中文接字典(en/ja 用户此前直接看到中文):
   history-template 的「查看回答 / 加载更多 / 加载中... / 无更多留言」、
   单题页「@xxx 以前回答过的问题」(字典里本来就有 ask.history)、
   投票页「(最多 N 项)」改为带参数的整句(原先前半走字典、后半手写中文)。
   新增 4 个 key × 4 语言。

实测:箱主语言设为 en 后箱主页显示 View answer ×2 / Load more / No more posts,中文零残留。
2026-10-29 16:28:53 +08:00
tamakyi f2e0c43083 feat(ops): 健康检查端点,并把上传超时与服务端读超时对齐
1) /healthz 与 /readyz
   改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库:
   DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
   监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
   → /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务,
     把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助;
   → /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态,
     绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。
   实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。

2) ReadTimeout 30s → 120s
   站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行,
   而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,
   且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s,
   偏远地区 3G 也能过。
   放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」,
   由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。
2026-10-29 16:28:44 +08:00
tamakyi 0124e392c7 fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。

1) 删除会话重复回退配额
   MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
   都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
   表现为定量收集提前关闭,箱主重置计数也校准不回来。
   → 改条件更新 + RowsAffected 决定是否回退。

2) 双击产生两条重复追问
   「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
   随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
   吃掉 2/3 名额,箱主还收两封邮件。
   → 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。

3) 双击置顶结果与预期相反
   SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
   用户点两次期望「置顶后取消」,实际仍是「置顶」。
   → 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。

4) 私聊首条三步写入无事务
   建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
   箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
   孤儿会话永久占用它。
   → 新增 CreateWithFirstMessage,三步收进一个事务。

5) 验证码失败丢正文
   该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
   验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
   → 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。

6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
   断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。

验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00
tamakyi 0041cc51f8 perf(vote): 结果聚合改 stale-while-revalidate,消除持续投票时的 O(N²)
多选 / 评分 / 排序 / 表单的统计必须在 Go 层逐条 json.Unmarshal 全部提交记录
(ListByVoteID 传 nil 不设 LIMIT),而 Submit 每来一票就 InvalidateResultsCache
删掉缓存 —— 于是第 k 票触发一次 O(k) 重算,持续有人投票时稳态是 O(N²)。
一万票的投票在投票期间每次刷新都要扫一万行 JSON。

现在:
- LoadResults 用 GetWithExpiry 读到「已过期但仍在」的旧值,先返回给调用方,
  再在后台按投票去重重算(sync.Map 占位 + shared.SafeGo,60s 超时)
- 后台只拷贝 ID/Type/AllowCustom 三个字段,不把 handler 正在用的 *db.Vote 带进 goroutine
- Submit 从 InvalidateResultsCache(删缓存)改为 ExpireResultsCache(标记过期保留旧值),
  否则每次提交都会让下一次读退化成同步全量重算
- 选项变更 / 删除投票仍走 InvalidateResultsCache(结构性变化,旧值已无意义)
- TTLCache 新增 GetWithExpiry / Expire:Get 遇到过期项直接删除并返回 false,
  「过期」与「不存在」分不开,正是无法做 SWR 的原因

实测(2 万条多选提交):冷启动 0.211s → 过期首读 0.023s(快 9.2x),
后台刷新后 0.009s。缓存单测 3 项覆盖 GetWithExpiry 不删条目 / Expire 保留值。

顺带把 7 处裸类型断言改成带 ok(其中 results.go 的是新代码)。
2026-10-29 16:28:23 +08:00
tamakyi d4e6da76f1 docs(memory): 记录第二轮复查结论与六项修复的验证方式
- 新增 topics/quality.md:业务正确性 / 运维 / 前端体验四类结论,含
  「私聊取最早 50 条」「Classic() 的 Logger 记 RequestURI」这两个坑的成因,
  以及处置覆盖面、Redis 两种故障模式、产品缺口(无 page view / 提问者找不回自己的提问)
- 2026-10-29.md:本轮六项修复的改动与验证结果,另记两个实现坑
  (limit 兜底不该抬大用户配置;Python bytes 字面量不支持 \uXXXX)
- topics/sec.md / 2026-10-27.md:补上一轮遗漏的字体自动发现与 CSP enforce 结论
- MEMORY.md:索引加 quality,处置覆盖面一条标注投票链路已补齐
2026-10-29 15:53:57 +08:00
tamakyi c971d99bdb fix(vote): 站方处置覆盖投票链路
提问链路上 ActiveLevel 有 7 处调用,投票链路 0 处。于是 L4(整页停用)处置某人后:
/_/domain 已经 302 到 /restricted,但 /vote/{token} 照常渲染、照常收票;被处置者
还能发新投票;探索页的「热门/全部投票」也不做处置过滤 —— 同一张页面里
「箱主消失了、他的投票还在曝光」。这是运营口径不一致,不是越权。

补齐三处,与提问链路对齐:
- vote.Page      L4 → 302 /restricted(同箱主页;查询失败 fail-open,与 Pager 一致)
- vote.Submit    L4 → 拒绝(页面进不去不代表直接构造 POST 也拦得住)
- VotePublish    L2 起拒绝(L2 已是「收窄公开」,不该再新增公开内容)
- 探索页投票列表改用 Restrictions.ActiveLevels 批量过滤(与 GetShowInHome 同口径,
  一次查询避免 N+1),功能开关过滤逻辑保持不变

新增字典 key vote.err.restrictedPublish(四语言)。

验证(本地实例):
- L4 后 /vote/{token} 302 → /restricted;撤销后恢复 200
- 探索页该投票出现次数 0(处置中)→ 2(撤销后)
- 草稿投票在 L2 下发布后状态停留 draft,撤销后同一请求变为 published
2026-10-29 15:53:49 +08:00
tamakyi ced4c1635c fix(question): 图片上传失败时回滚已创建的提问
Questions.Create 在图片处理之前就提交了,失败分支只渲染错误页、不回滚。于是:
箱主收到一条无图提问,提问者只看到「图片过大 / 上传失败」以为没发出去;再想重发
又会被 DuplicateGuard 按「同 IP + 同内容 5 分钟内重复」拦住 —— 既没发成功也重发不了。

现在失败分支先 Questions.DeleteByID 回滚(连带清理追问与图片关联,并回退前面
那次提问配额 +1),再做提示。顺带把不需要的 else 去掉(两个分支都以 return 结束)。

验证:6MB 图提交后提问数增量 0、该行 deleted_at 非空(回滚走的是软删);
无图对照增量 1。两条路径均返回 200,正常流程未受影响。
2026-10-29 15:53:39 +08:00
tamakyi 11d6e701ee fix(ops): 访问日志脱敏、优雅关闭、Redis 可降级并启动即探活
三处都属于「故障时才发现」的运维缺口,一起改。

1) 访问日志泄露凭证
   flamego.Classic() 内部 Use(Logger()),而 Logger 记的是 RequestURI —— 含 query。
   实测日志出现 path=/_/tblocal/4?t=SECRETTOKEN123:?t= 是私密提问钥匙,
   ?code= 是一次性改密凭证,?q= 是搜索词,全都会被明文落盘,凡是能看日志的人
   都能直接读私密提问或重置密码。internal/tracing 早就为同一原因只取 URL.Path
   并留了注释,漏的是框架自带那层。
   → 改 flamego.New() + accessLogMiddleware,只记 Method/Path/Status/Duration/IP;
     静态资源成功请求不记(噪音),4xx/5xx 照记(排查裂图靠它)。

2) 没有优雅关闭
   直接 ListenAndServe,Go 对 SIGTERM 是立即退出,每次部署都会把正在写库的 POST
   就地处死,留下「提问已 INSERT、图片没传完」这类半成品。
   → signal.NotifyContext(SIGTERM/SIGINT) + srv.Shutdown(15s),超时才 Close;
     退出前关连接池。实测 8 个并发分享图请求中发 SIGTERM,8/8 全部成功。

3) Redis 单点
   session 建在 Redis 上,load 失败 flamego/session 会 panic → 每请求 500(含匿名访客);
   而 cache.Cacher 没判空 Addr,go-redis 会回退 localhost:6379,于是「不配 Redis」
   这种被允许的部署里找回密码整体不可用、私信邮件节流失效,且启动零提示。
   → cache 跟随开关(未配则 MemoryIniter);启动 PingRedis fail-fast,显式 2s 超时。
     实测停掉 Redis 后启动立即失败并明确指出是 Redis。

验证:单测 2 项(query 永不入日志 / 静态资源过滤口径);重启日志出现
「收到退出信号」→「HTTP 服务已关闭」;启动打「Redis 连接正常」。
2026-10-29 15:53:32 +08:00
tamakyi 46932b4c53 fix(chat): 会话消息只取最早 50 条,第 51 条起对双方永久不可见
GetByConversationID 原来是 Order("created_at ASC") + Limit(50),而会话上限是
conf.Limits.ChatMaxMessages(默认 200)。唯一调用方 chat_handler 永远传 nil cursor,
于是取到的永远是「最早 50 条」:第 51 条起写库成功、message_count 原子递增、
页面显示 51/200,但列表里没有它 —— 等多久、换设备、重新登录都不会出现。
本地实测 55 条会话只渲染 #1–#50,双方都以为对方没回,直到 200 条被判满自动关闭。

改为:
- Order("id DESC") 取最新 limit 条,再在内存反转成时间正序(气泡从上到下=旧→新)
- limit 取 conf.Limits.ChatMaxMessages,兜底只处理零值(配置未初始化);
  不写成 limit<50→50,那样会把箱主显式配的小上限抬大
- 排序键用 id 而非 created_at:同毫秒写入的 created_at 相同,排序不稳定
- 游标 query 去掉 NULL 风险:显式要求 cursor.Value 非空

验证:55 条实测渲染 55 条、范围 #1–#55;单测 3 项(全量可见 / 超限保留最新 /
游标翻页),变异验证把 ORDER 改回 ASC 时「超限保留最新」立刻变红。
2026-10-29 15:53:22 +08:00
tamakyi 430c6ef6d8 docs(memory): 记录审计结论(CSP 默认 enforce、分享卡字体限制、复查新发现)
- topics/sec.md:两轮修复的结论、全局复查新发现(私聊 POST 越权、页面密码 next)、
  CSP 白名单口径、字体只能用 glyf 轮廓这条硬限制,以及复现脚本的两种「假红」
- MEMORY.md:安全小节补跳转白名单、GET/POST 校验不对称、CSP/umami、注册防枚举
- 2026-10-27.md / topics/build.md:本地 WSL 部署与验收的具体跑法
2026-10-27 12:37:55 +08:00
tamakyi e807dea218 feat(share-card): 字体自动发现 + 启动探测,缺字体不再等到第一次请求才 500
- 系统候选补上 glyf 轮廓的 CJK 字体(文泉驿正黑/微米黑、DroidSansFallback、
  AR PL UMing),并把 Noto Sans CJK 压到最后:它是 CFF/OTTO,freetype 必定解析失败,
  排前面只会白白多读十几 MB
- static/fonts 同时按当前工作目录与可执行文件目录查找,systemd 没配
  WorkingDirectory 时也能找到(原先只按 CWD,文件在却报「没有可用字体」)
- 启动时探一次字体:命中打 Info 带路径,缺失打 WARN 并给出补救命令
  (apt install fonts-wqy-zenhei / 放 static/fonts/ / 配 font_path)
- .ttc 先提取首个字面再用(仅对 glyf 轮廓的集合有效,如文泉驿);
  失败结果缓存 1 分钟,避免公开端点被「每请求重新解析十几 MB」放大

实测:本地实例 apt install fonts-wqy-zenhei 后无需任何配置,启动即打出
font=/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc,/share-card 与单题分享图
从 500 变为 200 image/jpeg,中文与二维码渲染正常。
2026-10-27 12:37:48 +08:00
tamakyi 7802bb2078 fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
179 changed files with 20145 additions and 4254 deletions
+20
View File
@@ -45,3 +45,23 @@ __pycache__/
# 本地凭据与一次性状态(测试账号密码等),只留在本机,不进版本库。
# 目录说明见 .workbuddy/local/test-accounts.md 的抬头注释。
/.workbuddy/local/
/.codeartsdoer/
# 本地运行配置与 WSL 测试环境模板:含数据库密码与对象存储密钥,绝不能进版本库。
# dev/wsl-app.ini 的用法见 dev/sync-wsl.sh;同步时必须排除 conf/app.ini,避免被 rsync --delete 删掉。
/conf/app.ini
/dev/wsl-app.ini
/dev/_*.py
# 含真实测试凭据的历史记录与巡检脚本:保留本地文件,但不再纳入版本控制。
/.workbuddy/memory/MEMORY.md
/.workbuddy/memory/topics/build.md
/.workbuddy/memory/topics/sec.md
/.workbuddy/memory/topics/misc.md
/.workbuddy/memory/2026-09-15.md
/.workbuddy/memory/2026-10-19.md
/.workbuddy/memory/2026-10-22.md
/dev/devcheck-e2e.py
/dev/devcheck-avatar.py
/dev/devcheck-image.py
/dev/devcheck-upload.py
-421
View File
@@ -1,421 +0,0 @@
# 2026-09-15 工作日志
## 落地 P2 推荐批次 #1–3(审核链路一致性 / 文档)
承接 09-11 的全库审阅(无 P0/P1,8 项 P2)。上次征询修复批次未获选择,本次按「继续 +
我标注的建议先做」自主推进**最低风险的一致性/文档类 #1–3**,未动 #4–8(需另行确认)。
### 已改(`internal/security/censor/`)
1. **#1 缓存/送审归一化统一**(censor.go:66-68,76,122,131)
- 原先:缓存按**原文** `text` 查 `GetByText`,实际送审 `RemoveTrustedURL(text)`,落库 `Input:text`。
→ 审计记录与实际送审内容不符;归一化后相同的原文会各占一条缓存、重复调 API。
- 现在:循环外算一次 `normalized := RemoveTrustedURL(text)`,缓存查找 / 送审 / `Input` 三者统一。
- 安全性论证:归一化是纯函数确定性映射;`TrustedHosts` 增减只会让旧条目 miss(重新送审),
不会让「已通过」的旧缓存被错配到新文本上 → 无漏放风险。
2. **#2 阿里云缓存回放分支与七牛对齐**(censor.go:99-110)
- 原先:aliyun 命中缓存**不看 Pass 直接 return**,依赖「aliyun 恒为链尾」这一隐式前提。
- 现在:与 qiniu 一致 —— 仅 `Pass` 才 return,否则 append + continue 交给下一通道复核。
- 等价性:aliyun 现为链尾,continue 后循环结束仍由 `responses[len-1]` 返回**同一份响应**,行为不变;
但消除了顺序耦合,将来若前置 aliyun 也不会短路后续通道。
3. **#3 修正失真注释**(text.go:66)
- 原注释称 `Details`「回放缓存结果时为 nil」**是错的**:
`AliyunTextCensorParser`(aliyun.go:433-461) 与 `QiniuTextCensorParser`(qiniu.go:133-170)
都会从 `RawResponse` 重建 `Details` → 缓存回放同样受严格度策略约束,改档位对历史缓存也生效。
- 已改为准确描述。**结论:审核缓存回放无 fail-open**(09-11 的怀疑已排除)。
### 验证
- `gofmt_keep_crlf.py` 两个文件 exit=0,CRLF 保留(censor 147 / text 141,loneLF=0)。
- `go build ./internal/security/censor/...` **EXIT=0**(定向构建,~秒级;用 goproxy.cn + overlay)。
- `go test ./internal/security/censor/` **ok 2.467s** —— 确认严格度策略与回放行为未被破坏。
### 顺带已完成 #6
- **#6 `User.Email` 改 `json:"-"`**(users.go:89)。
查证:`route/` 与 `internal/` 中**无任何非测试代码读取序列化后的 `.Email`**(模板里的 email 均为表单字段名 /
邮件模板,属 Go template 服务端渲染,不受 json tag 影响;输入走 `CreateUserOptions`/`UpdateUserOptions`,
不经 `User` 模型反序列化)→ 无消费方受损。
验证:gofmt exit=0,`go build ./internal/db/` **EXIT=0**。
### 待确认(P2 #4/#5/#7/#8)
| # | 项 | 我的建议 |
|---|---|---|
| 4 | 投票配额「插入后计数」非原子(route/vote/vote.go:814-822) | 暂缓:需 DB 层条件插入,超额有界且自动结束自愈 |
| 5 | ~~profile Name/Intro/link 无长度校验~~ | **误报,已关闭**:表单层已有校验,见下方更正 |
| 7 | `conf.Database.Schema` 拼 CREATE SEQUENCE(db.go:215-222) | 可选:配置来源非远端输入,纵深防御 |
| 8 | `RemoveTrustedURL` 24 次整串重建(text.go:114-141) | 可跳过:短文本开销可忽略(#1 已顺带把调用从循环内降到 1 次) |
#### ✅ #5 更正:是**误报**,已按「报错式校验」实现后**全部回滚**
- 先按用户选择实现了一版(Name 50 / Intro 500 / link 500 rune,4 语 i18n 词条),随后核对
`internal/form/user.go` 时发现**表单层早已有校验**:
- `Name` → `valid:"required;maxlen:20"`
- `Intro` → `valid:"required;maxlen:100"`
- 各 link → `valid:"maxlen:30"`
- 且确实生效:`internal/form/form.go:16` 引入 `govalid`,`:131` 在 `Bind()` 中 `govalid.Check()`;
handler 顶部 `ctx.HasError()` 承接;报错文案走标签 `msg:` 对应的 i18n 键
(`form.profile.name`="不超过 20 个字符"、`form.profile.intro`=100、`form.profile.linkMax`=30)。
- 结论:我那版是**永不触发的死代码**(超限在 Bind 阶段就被拒),且上限与真实值冲突 → 已完整回滚
(profile.go 的 utf8 导入 / 常量 / 校验块 + 4 份字典词条),`git status` 确认 5 个文件零 diff。
- 教训已写入 MEMORY.md「表单校验(勿重复造轮子)」:查某字段有无校验,**先看 `internal/form/*.go` 的 struct 标签**。
- 附带修正:Postgres 下 `string` 映射为 `text`(无 DB 层长度上限)这一点本身**没错**,
但不构成漏洞 —— 因为表单层已经挡住了。
### 环境
- ~~重包 `./route/user/ ./internal/route/ ./internal/db/` 仍会 30 分钟超时 → 判定为 1C1G 沙箱编译整图慢~~
**❌ 该结论已被推翻,见文末「编译卡死」误判更正。全库 `go build ./...` 实测 EXIT=0。**
---
## 「编译卡死」误判更正(用户指出:1C1G 是正式环境,不是沙箱)
用户明确纠正:**1C1G 是正式生产环境**,我不该拿「沙箱慢」当挡箭牌解释构建超时。重新取证后确认:
**根本没有编译问题,是我把代理挂起误判成了编译慢。**
### 取证过程
| 步骤 | 命令 | 结果 | 结论 |
|---|---|---|---|
| 隔离模块解析 | `go list -deps ./route/user/` | **2.3s EXIT=0** | 模块全在本地缓存,网络不是瓶颈 |
| 编译目标包 | `go build -overlay=... ./route/user/` | **7s EXIT=0** | 之前 240s 超时是伪命题 |
| 关键包逐个 | route/ internal/route/ question/ auth/ security/... db/ mail/ | **全 OK** | |
| 全量 | `go build -overlay=... ./...` | **EXIT=0** | |
| 静态检查 | `go vet -overlay=...` 7 个包 | **EXIT=0** | |
| 测试 | censor/autoban/ipinfo/route/conf/form | **全部 ok**,EXIT=0 | |
**真因**:前几次超时是 `GOPROXY` 未导出 → 卡在 `proxy.golang.org` 连接超时(HTTP=000)。
与机器性能、与 1C1G 都无关。**只要每条 go 命令前 `export GOPROXY=https://goproxy.cn,direct
GOSUMDB=off GOFLAGS=-mod=mod` 就秒级完成。**
⚠️ 方法论教训:**超时 ≠ 慢**。要用 `go list -deps`(纯元数据、不编译)先把「模块解析」和
「编译」两层拆开,否则会把网络挂起误判成性能问题,进而拿「环境」当借口。
---
## 复核:追问邮件 token + 其他严重错误
### 1. 追问邮件确实没带 token(用户怀疑成立)
- `internal/mail/mail.go:58-65` `SendNewFollowUpMail` 链接 `%s/_/%s/%d`,**无 `?t=`**。
- 收件人是**箱主** `pageUser.Email`(follow_up.go:131),箱主靠登录态查看 → **不是权限漏洞**。
- 但有两处真实问题:
1. **不一致**:同样发给箱主的 `SendNewQuestionMail`(mail.go:25)**带** `?t=`。
2. **具体失效场景**:若该提问被箱主设为私密(`IsPrivate=true`),箱主在**未登录设备**点邮件链接
→ `Questioner` 中间件(question.go:61-66)判定 `IsPrivate && !IsLogged && token 不匹配`
→ **302 回首页,看不到追问**。新提问邮件因带 token 则不会。
- 建议:给 `SendNewFollowUpMail` 补 token 参数,与 `SendNewQuestionMail` 对齐。
- 关键的那封(**发给匿名提问者的回答通知**)`question.ReceiveReplyEmail` 是**带 token 的**:
follow_up.go:185、question.go:214 → `SendNewAnswerMail(..., question.Token)` ✓ 无问题。
### 2. 新发现 P2:提问 token 仅 6 位且爆破无限流
- `internal/db/questions.go:95` `Token: randstr.String(6)`;`randstr` 用 `crypto/rand` +
base62 → 62⁶ ≈ 5.68×10¹⁰,**仅 35.7 bit**(随机源没问题,是长度短)。
- `GET /_/{domain}/{questionID}?t=` **无 rateLimitMiddleware**;猜错由 `Questioner` 返回
**302 → /**,而 `autoban` 只统计「敏感路径」与「404」(404 默认关闭)→ **完全不计入封禁**。
- token 仅在「未回答 或 私密」且非箱主时才需要,攻破即=读私密/未公开提问。
- 可行性:单条定向 ~2.8×10¹⁰ 次不可行;但 questionID 可枚举、只需命中**任意一条**,
受保护提问 N 条时期望 5.68×10¹⁰/N 次 → 量大时理论可行。
- 低成本修复:`randstr.String(6)` → `randstr.String(12)`(≈71 bit)。存量 6 位 token 不受影响
(列是 string,无长度校验)。或对该 GET 路径加限流。
### 3. 已复核无问题(勿再重复查)
- **密码**(`internal/db/password.go`):bcrypt cost 10;旧 HMAC-SHA1 用 `subtle.ConstantTimeCompare`
常量时间比较并透明升级;哈希失败**不降级**为弱哈希(返回空串=锁定);bcrypt 72 字节截断 ✓
- **越权**:`Delete/SetPrivate/SetPublic/TogglePin/SetRead/SetUnread` 全部由 `canDelete`
(question.go:72 `ctx.IsLogged && ctx.User.ID == pageUser.ID`)把关;
`AnswerFollowUp`(follow_up.go:143) 与 `PublishAnswer`(question.go:154,161) 双重校验 ✓
(`ctx.Redirect` 会写响应从而终止后续 handler,早退有效)
- **CSRF**:全局挂载,SessionKey=uid 正确,有回归测试 ✓
- **会话**:登录 RegenerateID、pwd_ver 失效、HTTPOnly + SameSite=Lax ✓
- **密码找回**:验证码 24h TTL + 用后即删 ✓
- **SQL 注入**:全参数化 ✓
- **token/邮箱泄露**:`Question.Token` 与 `User.Email` 均为 `json:"-"` ✓
- **审核**:提问/回答/追问/投票/私聊统一走 `moderation.Check` ✓
---
## 落地 A+B(追问邮件补 token + 提问 token 6→12)
按「继续未完成的任务」指示,执行上一条报告的 A+B 两项变更。已用 gofmt_keep_crlf 保 CRLF。
### A. 追问邮件(发给箱主)补 token,与 `SendNewQuestionMail` 对齐
- `internal/mail/mail.go:58` `SendNewFollowUpMail` 签名加 `token string`,链接改为 `%s/_/%s/%d?t=%s`。
- `route/question/follow_up.go:131` 调用处补传 `question.Token`。
- 目的:箱主在**未登录设备**点开**私密**提问的追问邮件时也能凭 token 查看(否则被 Questioner 重定向首页)。
- 仅一个调用方,构建已验证签名一致。
### B. 提问 token 长度 6 → 12
- `internal/db/questions.go:95` `randstr.String(6)` → `randstr.String(12)`(≈35.7→71 bit)。
- 存量 6 位 token 不受影响(列是 string,无长度校验,旧 token 仍有效)。
### 验证
- `go build ./route/question/ ./internal/mail/ ./internal/db/` → EXIT=0
- `go build ./...`(overlay)→ EXIT=0
- `go vet` 三包 → EXIT=0
- `internal/db` 单元测试 FAIL 为已知环境限制(需真实 PostgreSQL/MySQL,报 `Unknown database type: ""`),
与本次改动无关,可跳过;security/autoban/censor/ipinfo、route 测试均 ok。
### 待定(未做,按需)
- CHANGELOG.json 是否补一条 entry(未做:需确认版本号与条目格式)。
---
## 用本地 Postgres 跑通 db 测试并修真实缺陷(用户提供 127.0.0.1:5432)
### 测试库环境变量(`internal/dbutil/testing.go:NewTestDB`)
- `DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=<pwd> DB_HOST=127.0.0.1 DB_PORT=5432`
DSN = `postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT?sslmode=disable`(不带库名,连上后
自动 `CREATE DATABASE tamabox-test-<rand>`,测试结束(未失败时)DROP)。
- 仍需 `GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + webp overlay。
### 修的真实缺陷(生产代码,2 处)
- `internal/db/questions.go:DeleteByID`:级联删追问/上传图 + 回退提问配额时**直接调包级全局单例**
`FollowUps`/`UploadImages`/`FeatureControls`。这些单例只在 `db.Init` 装配 → 测试/部分初始化场景 nil → **panic**。
改为 `NewFollowUpsStore(db.DB)` / `NewUploadImagesStore(db.DB)` / `NewFeatureControlsStore(db.DB)`,
用自身 store 的 `*gorm.DB`。生产行为不变(`Init` 里所有 store 共用同一 `*gorm.DB`),但去掉了隐藏全局依赖。
- `internal/db/conversation.go:MarkDeletedByOwner`:同类问题(`FeatureControls.IncrementQuota`),同样修。
- 同包 `feature_control.go:355` 的 `FeatureControls.Get` **已有** `if FeatureControls == nil` 守卫,故未动。
### 修的测试缺陷(全在 internal/db,非生产 bug)
1. `users_test.go`:5 处 `want.EncodePassword(); require.Equal(t, want, got)` —— bcrypt 随机盐,
哈希**永不相等**。新建 helper `requireUserEqual`:先 `got.Authenticate(明文)` 校验密码,再对齐
`Password` 与 `ChatMaxMessages`(= `conf.Limits.ChatMaxMessages`,默认 200)后比较其余字段。
2. `testUsersDeactivate`:复用已被 `testUsersCreate` 占用的 `[email protected]`/`e99` → 改唯一值。
3. `users_test.go`/`upload_images_test.go` 去掉顶层 `t.Parallel()`:子测试共用一张表、cleanup 会
`TRUNCATE ... RESTART IDENTITY`,并行时多个子测试拿到相同 id/邮箱 → 撞唯一键。
4. `upload_images_test.go`:cleanup 只 truncate `upload_images`,而链接表 `upload_image_questions`
**无 FK 级联**(复合主键 `upload_image_id+question_id`),残留行让下一子测试撞 `_pkey`
→ 补 `cleanup("upload_images", "upload_image_questions")`。
5. `UpdateBlockWords/normal`:期望 `HarassmentSettingNone`("none")但新用户实际 `""`
(模型字段无 `gorm:"default:"` 标签)。`""` 与 `"none"` 生产行为等价(只有 `==register_only` 才限制;
模板 profile.html:851 也只在 `register_only` 时勾选)→ **非生产 bug**。改成先显式
`UpdateHarassmentType(RegisterOnly)` 再断言未被误改(更强的测试,且不把 `""` 奉为期望)。
### 验证
- `go build ./...` **EXIT=0**、`go vet ./...` **EXIT=0**
- `go test ./...`(含 DB)**全绿 EXIT=0**:internal/db 57s、route/vote、internal/route、templates、
i18n、conf、form、cache、imagecheck、mail、shared、template、security/* 均 ok。
- 行尾:`internal/db/*` 与 `internal/mail/mail.go` 原本就是 LF,`route/question/*`、`route/vote/vote.go`、
`internal/route/route.go` 是 CRLF —— **仓库本来就是混合行尾**;本次编辑未改变任何文件原行尾(无 diff 污染)。
### 结论
- 除上述「全局单例 nil 依赖」(健壮性隐患:测试与部分初始化场景必崩)外,**未发现新的生产逻辑 bug**。
- db 测试套件此前的一堆失败**全部是测试自身缺陷**:bcrypt 直接比哈希、测试数据撞车、并行共享表、漏清链接表。
---
## 修复 HarassmentSetting 潜在隐患(schema 默认值 + 启动期归一)
用户确认「影响不大就做」。改动面很小(3 处源码 + 1 处测试期望 + 1 个新回归测试)。
### 线索
- 模型 `HarassmentSetting HarassmentSettingType` **无 `gorm:"default:"` 标签** → 新用户该列为空串;
而规范取值是 none / register_only。全仓库**没有任何代码比较 `== HarassmentSettingNone`**
(production 只有 `== register_only`,模板 profile.html:851 也只看 register_only)→ **运行期无 bug**,
但将来若有人写 `== None` 判断就会漏判。
### 改动
1. `internal/db/users.go` 模型:`HarassmentSetting` 补 `gorm:"default:'none'"`。
⚠️ **字符串默认值必须带单引号**(`default:'none'`),参考既有 `ModerationStrictness` 的 `default:''`;
裸写 `default:none` 会生成非法 SQL。
2. `internal/db/users.go:Create`:显式 `HarassmentSetting: HarassmentSettingNone`
(确定写库,不依赖 DB 默认值回读)。
3. `internal/db/users.go`:新增 `normalizeHarassmentSetting(db)`,把历史空值(''/NULL)归一为 none,幂等。
4. `internal/db/db.go:Init`:在 `normalizeChatQuota` 之后调用,失败仅告警不阻断启动
(与 `backfillFeatureControls`/`reconcileLegacyChatEnabled` 等既有惯例一致)。
### 测试
- `users_test.go` helper `requireUserEqual` 补 `want.HarassmentSetting = HarassmentSettingNone`。
- 新增 `TestHarassmentSettingNormalize`:新建用户即 none + 历史空值经归一后为 none。
### 实测证据
- 临时探针查 `information_schema`:`harassment_setting` 的 `column_default = "'none'::text"` ✅
(AutoMigrate 确实把列默认值改成了 none;探针文件跑完即删)。
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含 DB)**全绿**;gofmt 无 diff;
行尾未变(users.go CRLF、db.go/users_test.go LF)。
### ⚠️ 新踩到的 gofmt 陷阱
在**文档注释**里写 `''` 会被 gofmt 重排成 `”`(Go 1.19+ 的 doc comment 规范化:`` `` ``→`“`、`''`→`”`),
导致 `gofmt -l` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。
---
## 账号停用体验修复:独立成功页(/deactivated)
### 问题
停用账号后 `DeactivateProfileAction` 把用户重定向到 `/login`,只在登录页顶部弹一条 flash。
用户会觉得「被丢回登录页」,下意识以为操作没生效。
诊断结论:flash **确实渲染**(Flush 清 session 数据但保留 session,flash 能带到下一页),
真正的体验错配是「停用后落点仍是登录页」——而账号已删/停,用户根本不可能再登录。
### 方案(用户选「独立成功页(推荐)」)
1. `route/user/profile.go`
- 新增公开 handler `DeactivatedProfile`:`ctx.SetTitle(i18n.T(ctx.Lang(), "deactivated.title"))` + `ctx.Success("user/deactivated")`。
- `DeactivateProfileAction` 末尾 `ctx.Redirect("/login")` → `ctx.Redirect("/deactivated")`,**并移除原来的 `SetSuccessFlash`**(页面自身即提示)。
- 顺手修正 `UpdateProfile` 里一处陈旧注释(原写「顺序与 DeactivateProfileAction 保持一致…随后跳登录页」,停用路径已不再如此)。
2. `internal/route/route.go`:在公开区(`/explore` 旁)加 `f.Get("/deactivated", user.DeactivatedProfile)`。
**必须是公开路由**:停用后会话已 Flush、`reqUserSignIn` 会把人弹回登录页,正是要避免的现象。
3. 新建 `templates/user/deactivated.html`:复用 `question/success.html` 的范式(大 ✓ 图标 + 卡片 + 单按钮返回首页)。
`templates/embed.go` 是整目录 `//go:embed ... user ...`,新模板**自动被嵌入**,无需改 embed 指令。
4. i18n:四份字典都加 `deactivated.title` / `deactivated.desc` / `deactivated.backHome`
(`TestDictionaryComplete` 要求 zh_cn/zh_tw/en/ja **键集合完全一致**,漏一份必挂)。
同时**删除已无引用的 `admin.flash.deactivated`**(4 份字典同步删;删它不影响对齐组,因为该组最长键是 `admin.flash.blockWordsTooMany`)。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**,
其中 `internal/i18n`(键一致性)、`internal/route`、`templates`、`internal/template`(模板解析)均 ok → 新模板可被解析、键齐备。
- gofmt 无 diff;行尾未变(4 份 dict + route.go 保持 CRLF,`route/user/profile.go` 实为 **LF**)。
### 备注
- `route/user/profile.go` 的行尾是 **LF**(与「route/** 皆 CRLF」的旧印象不符,按文件单独确认即可)。
---
## 🔴 P0 修复:账号停用后同一邮箱/域名永远无法重新注册
用户在测试环境 `https://box-dev-api.337845818.best` 注册 `1223383917@qq.com` / `tamakyi` 报 500:
```
SELECT ... WHERE email = '...' AND deleted_at IS NULL -> rows:0
INSERT INTO users ... -> ERROR: duplicate key value violates unique constraint "idx_users_email_unique" (SQLSTATE 23505)
ERRO Failed to create new user error="create user: ERROR: duplicate key ..."
```
### 根因(本地已实测复现,日志形态与线上一致)
`internal/db/users.go` 的 `Deactivate` 用的是 **GORM 默认的软删除**:
`db.WithContext(ctx).Model(&User{}).Delete("id = ?", u.ID)` → 只写 `deleted_at`,**行仍在库里**。
而 `User` 的 `email` / `domain` 是 `uniqueIndex`(**索引不含 deleted_at**),于是:
- 查重 `validate()` 走 gorm 软删除作用域 → 看不见这行 → 放行;
- INSERT 被数据库唯一索引拦下 → `Create` 的 TOCTOU 兜底再查一次仍看不见 → 抛底层 23505 → handler `default` 分支 → 500。
复现证据(临时测试,已删):停用后 `Unscoped` 查该邮箱 → 行仍在且 `deleted_at.Valid=true`;同邮箱重新注册 → 与线上同样的 23505。
**旁证**:`internal/cmd/ban.go:64` 早有注释「用 Unscoped 查找,支持封禁已软删除(自助停用)的账号」——
说明「自助停用会留软删除行」是既有事实,但没人把「唯一索引占位」这层后果接上。
同类先例:`internal/db/vote_submissions.go:234` 已因同一原因改用 Unscoped 物理删除。
顺带发现:`BanUserAndCleanup` 的「连带清理」也只是软删除子表(Question/FollowUp/Conversation/Message… 都嵌 `dbutil.Model`),
数据实际仍在库;也与停用页「所有数据将被删除」的承诺不符。
### 改动(`internal/db/users.go`)
1. `Deactivate` → 调 `db.purgeUser(ctx, u.ID)`;`BanUserAndCleanup` 同样委托给它。
2. 抽私有 `purgeUser`:级联删除**全部改为 `Unscoped()` 物理删除**(追问/消息/消息图片/会话/提问-图片关联/提问/拉黑/功能开关/用户行)。
3. `Create` 兜底加固:唯一键冲突且 `validate` 仍查不到时,用新增的 `duplicateFieldErr()`(Unscoped)判定是 email 还是 domain 被占,
返回 `ErrDuplicateEmail`/`ErrDuplicateDomain` 友好提示,而不是把 23505 抛成 500。(`isDuplicateKeyError` 复用 vote_submissions.go 里的)
4. 回归测试 `TestDeactivatePurgesUser`:停用后 Unscoped 也查不到用户行、提问被清理、同一邮箱可重新注册。
### ⚠️ 过程中自己踩的 GORM 大坑
把级联删除改成「先 `tx = tx.WithContext(ctx).Unscoped()` 再逐条 `tx.Where(...)`」——
**事务句柄 clone==0,复用同一个句柄会让上一条语句的 `Where` 条件串到下一条上**,
SQL 变成 `DELETE FROM follow_ups WHERE <follow_ups条件> AND <messages条件>` → `字段 conversation_id 不存在 (42703)`。
✅ 正确写法:**每条语句都重新取句柄** `tx.WithContext(ctx).Unscoped().Where(...)`(原代码本来就是每条 `tx.WithContext(ctx)`,是我改坏的)。
这条已写进 MEMORY.md。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt clean;行尾未变(users.go CRLF、users_test.go LF)。
- 测试环境实测(脚本 `dev/devcheck-e2e.py`):
- ✅ 注册 / 登录 / `/user/profile` / `/user/questions` / `/user/votes`(302→`/user/questions#votes`) / 提问(返回提问成功页)/ 新建投票页 / 停用页 / **停用后跳 `/deactivated` 成功页** 全部正常;
- ✅ `/deactivated` 四语言标题渲染正确(账号已停用 / 帳號已停用 / Account deactivated / アカウントを停止しました);
- ❌ **停用后同邮箱重新注册仍 500** —— 测试环境跑的是**旧构建**(`/deactivated` 已在,但没有 purgeUser 修复),需重新部署。
- ⚠️ 测试环境 DB 里已留下本次测试产生的孤儿行,需清理:
`tbe2e0915122816@example.com`(仍在线)、`tbe2e0915122934@example.com`、`tbcheck0915122657@example.com`(均已停用=软删除占位);
用户自己的 `tamakyi` / `1223383917@qq.com` 同样是软删除占位(这就是他报错的直接原因)。
清理方式:`tamabox ban --email <邮箱> --yes`(CLI 支持查软删除账号),或直接 SQL 删 `users.deleted_at IS NOT NULL` 的行。
---
## ⚠️ 方案变更(覆盖上一节):停用改为「保留身份占位 + 清空内容」
用户表示**尚未部署**,并提出:「曾经注册过的账户停用后,再次注册时应提示『该邮箱已被占用但账户已停用,如需重新使用请联系站长』」。
经询问确认两点:
1. 停用形态 = **保留身份占位**(用户行软删除)+ **物理删除内容数据**(停用页「所有数据将被删除」的承诺仍然成立);
2. 「联系站长」**复用页脚已有的「找站长」入口**(`site.footer.admin` → `https://box.shiroko.one/_/tamakyi`),不新增站长邮箱配置项。
→ **上面「Deactivate 改成物理删除」的做法已被取代**,但其中「级联内容必须 Unscoped 物理删除」的结论仍然有效。
### 最终实现
- `internal/db/users.go`
- `purgeUserContent(ctx, tx, userID)`(包级函数):把内容级联删除抽出来,全部 `Unscoped()` 物理删除。
- `Deactivate` = `purgeUserContent` + **软删除用户行**(保留 email/domain 占位)。
- `BanUserAndCleanup` = `purgeUserContent` + **物理删除用户行**(唯一差别;彻底释放邮箱/域名)。
- `validate` 重写为两轮:先查活跃账号(ErrDuplicateEmail/Domain),再 **Unscoped** 查已停用占位行
→ 新增 `ErrDeactivatedEmail` / `ErrDeactivatedDomain`;辅助方法 `fieldTaken(ctx, column, value, includeDeleted)`。
- 新增 `Reactivate(ctx, id)`(清 `deleted_at`,未停用时返回 ErrUserNotExists),已加进 `UsersStore` 接口。
- 上一轮加的 `duplicateFieldErr` 已删除:`validate` 现在本身就覆盖了软删除行,无需在 Create 兜底里重复判定。
- `route/auth/register.go`:把两个新错误映射到 `auth.errDeactivatedEmail` / `auth.errDeactivatedDomain`。
- i18n ×4:新增 `auth.errDeactivatedEmail`、`auth.errDeactivatedDomain`、`deactivate.note`(键集合保持一致)。
- `templates/user/deactivate.html`:desc 下方加一行 `deactivate.note`(uk-text-small uk-text-muted),
提前告知「邮箱/域名会作为身份占位保留,无法用同一邮箱重新注册,如需恢复请找站长」。
- **新增 CLI `internal/cmd/restore.go`(`TamaBox restore`)**:`--email|--domain [--password] [--yes]`,
清掉 deleted_at 完成恢复;`--password` 会重置密码(必须先恢复再改密码——`UpdatePassword` 走普通作用域)。
### 🐛 顺带修掉的隐藏问题
**`cmd/tamabox.go` 里根本没注册 `cmd.Ban`** —— `internal/cmd/ban.go` 写了但命令不可达(`TamaBox ban` 不存在)。
现已把 `cmd.Ban` 与 `cmd.Restore` 一起注册进 `app.Commands`。
### 验证
- `go build ./...` / `go vet ./...` EXIT=0;`go test ./...`(含本地 PG)**全绿**;gofmt 全部 clean;行尾未变。
- 新增/改写测试:`TestDeactivatePurgesUser`(占位行保留 + 内容清空 + 同邮箱得 ErrDeactivatedEmail + 同域名得
ErrDeactivatedDomain + Reactivate 后可正常注册 + 重复 Reactivate 报 ErrUserNotExists)、
`TestBanUserAndCleanupRemovesRow`(封禁物理删除且可重新注册)。
- **CLI 端到端实测**(临时库 `tamabox-clitest`,用 `NEKOBOX_CONFIG_PATH` 指向临时 ini,跑完已 DROP + 删除产物):
- `restore --email clitest@example.com --yes` → deleted_at 变 NULL ✅;重复恢复 → 正确拒绝「未被停用,无需恢复」✅
- `restore --password newpass12345` → 密码重哈希 + password_version=1 ✅;密码过短/邮箱不存在 → 明确报错 ✅
- `ban --domain banme --yes` → users 与 questions 计数归零(物理删除)✅
- ⚠️ `conf.Init` 还需要 `server.salt` / `server.xsrf_key` / `app.external_url` 非空,配置路径可用
`NEKOBOX_CONFIG_PATH` 覆盖 —— 这是本地跑 CLI 冒烟的关键(仓库里只有 `conf/app.sample.ini`)。
- 测试环境 `devcheck-e2e.py` 的核心回归项已改为断言「重注册被明确拒绝并出现『已停用』提示、且不是 500 兜底」;
在**未重新部署的旧构建**上会 FAIL 并打印「サーバー内部エラー」,正是需要重新部署的信号。
---
## 部署后巡检扩容:投票全链路 + 登录态资产(28/28 全绿)
**背景**:用户重新部署后要求「所有功能都试一遍」。此前 `devcheck-e2e.py` 只验证了「新建投票页 GET」,
**投票创建(POST)这条主链路从未端到端跑过**,是覆盖盲区。
**改动**:`dev/devcheck-e2e.py` 扩到 **28 项**,新增两组:
1. `check_vote_flow(owner, base)` —— 投票全链路
- POST `/user/votes` 建单选投票并 `action=publish` → 断言 302 → `/user/questions#votes`
- GET `/user/questions` 断言标题可见,并从 `copyShareURL('...')` 取 **LinkToken**、从 `/user/votes/(\d+)/edit` 取 **ID**
- 匿名 GET `/vote/<token>` → 200 且含标题;从 `name="option_id" value="..."` 取选项 ID
- 匿名 POST `/vote/<token>/submit`(`option_id` + 数学验证码 + `_csrf`)→ 302
- GET `/user/votes/<id>/export` 断言 **`总参与,N` 这行的 N == 1**
- 草稿:`action=save` → 302 到 `/user/votes/<id>/edit?saved=1`
- 表单型:`type=form` + `form_fields` JSON(field_type/label/required/max_length)→ 发布 302
2. `check_user_assets(owner)` —— 登录态接口/导出
- `/api/v1/user` 返回 JSON、`/explore` 200、`POST /user/profile/export-json`(JSON)、
`POST /user/profile/export`(xlsx,校验 content-type)、停用页 `deactivate.note` 结构断言
**踩到的坑(都已在脚本里修掉)**:
- 单选投票导出 CSV 的**末行是 `总参与,N`**,与各选项票数行同为 `,数字` 结尾 →
用 `,\d+$` 一把捞会把总参与也累加进去(实测 `[1,0,0,1]` 求和成了 2)。
**必须单独匹配 `总参与,(\d+)`**。
- `urllib` 读 **HTTPError 的响应体本身也会超时**(`c.get("/i18n/xx?...")` 这类 302 最易触发),
异常从 except 块里抛出会把整轮巡检打断。已把 body 读取包在 try/except 里,并在 `__main__` 外层加兜底
——中断时仍打印已完成项,而不是只留一段 traceback。
- ⚠️ 复盘我自己的一个假警报:用 `T=/c/Users/Tamakyi\ Gekka/...` 做 gofmt 比对循环时,
变量未加引号导致路径断裂 → 全部文件假报 DIFF。**脚本里带空格的路径变量一定要整体加引号**,
单文件手跑同一个文件即为 OK,说明是脚本问题而非代码问题。
**结果**:测试环境 `https://box-dev-api.337845818.best` 跑 `python dev/devcheck-e2e.py` → **28 项,失败 0 项**。
10 个改动过的 Go 文件 gofmt 比对全部 OK(无重排),行尾未变。
**遗留**:巡检每轮会在测试环境留下一个**已停用的身份占位**(邮箱/域名被占用)。
本轮新增 `tbe2e*` 若干;用户自己的 `1223383917@qq.com` / `tamakyi` 也仍是占位状态。
清理:`TamaBox ban --email <邮箱> --yes`(连行物理删,彻底释放)或 `TamaBox restore --email <邮箱> --yes`(恢复)。
-614
View File
@@ -1,614 +0,0 @@
# 2026-10-19
## 部署后复测(强制严格档那批)
- 规则页返回首页已居中:线上 `<div class="uk-margin-large-top uk-text-center">` ✔
- 验证码弹窗按钮已缩短:线上 `>送信<` ✔(旧的「認証して送信」已消失)
- 箱主面板 `/user/questions`:限制期条幅 + 「查看社区规则」链接都在 ✔
- 严格度锁定(admin6 当前 L2):
- `/user/profile` 出现「处置期间审核严格度已锁定为最高档,解除后自动恢复你的原设置」✔
- `select[name=strictness]` 与「保存审核严格度」按钮均带 `disabled` ✔
- 直接 POST `/user/moderation-strictness/update?tab=security` + `strictness=lax` → 302 回 profile,
flash 仍是「已锁定」,且选中档仍为 `strict`(说明未写入)✔
- 踩坑:表单 action 是 `/user/moderation-strictness/update`(**没有** `/profile` 段);用错路径返回 302 到 `/`,
容易误判成「被中间件拦」。另外每次 POST 都要重新取 `_csrf`,用上一页(已跳首页)的 token 会 400。
## 记忆维护
MEMORY.md 一度 19KB 被注入截断,已重写压缩到约 4.9k 字符(8.1KB),保留构建/行尾/embed/审核/处置/CLI 等硬约束,
删去已在日志里的过程细节。
## CLI 文档 + 全量代码审阅(同日)
新增 `docs/cli.md`:命令总览、reports 三种用法与口径、restrict 级别语义/副作用、unrestrict、
hide-public、ban/restore、典型处置流程、排错表、明确不做的事。
审阅中发现并修复(全部本地临时库实测):
1. **`hide-public` 举报闭环静默失效**(真 bug):`build()` 带 `is_private=false`,在 Update 之后拿它当子查询恒为空集
→ 隐藏成功但 `content_reports` 仍 pending。改为 Update 前 `Pluck("id")` 快照,再按 ID 更新/标记。
实测:3 条 pending → accepted,未波及其他箱主。
2. **整箱分享卡未拦截**:`ShareCard` 直接走缓存,L1–L3 仍能生成整箱图。已在缓存查询前加拦截(fail-closed)。
3. **L2 通知邮件误导**:强制私密后仍给提问者发「你的提问有回答」,链接点开 302。改为 `!forcedPrivate` 才发。
4. **L2 写入顺序**:`SetPrivate` + `AnswerByID` 两条语句有「公开+已作答」窗口,第二条失败还会留下「私密+无回答」。
新增 `Questions.AnswerByIDPrivate`(单条 UPDATE 同时写 answer/is_read/is_private),并补测试 `TestQuestions/AnswerByIDPrivate`。
5. **`ListAPI` 绕过 Pager**:L4 停用与页面访问密码只挡 HTML,`/api/v1/user/{domain}/questions` 仍能整页拉。
已补两道校验(均返回「用户不存在」,不暴露原因)。**页面密码可被 API 绕过是升级前就存在的问题。**
6. **L1 曝光残留**:`GetShowInHome` 的 Count 未过滤(分页总数虚高、末页空白)、热门标签查询未过滤。已补同一子查询。
7. **性能**:`ActiveLevel` 由两次查询改一次(取最高 rank);新增 `moderation.EffectiveStrictnessFor(level, raw)`,
发布路径上不再重复查库(原先一次发布最多 4 次处置查询)。
8. **证据包口径**:分母是「累计公开回答」不是窗口产出 → 文案加「累计」;新增 `待处理 N` / `已处理` 标记,
避免把已闭合的举报误读成还挂着;补 `q.deleted_at IS NULL`;`--stats` 说明最长回溯 30 天;CLI 对 `active` 做 nil 守卫。
验证:`go build ./...` EXIT=0;db/i18n/templates 测试全绿;CLI 全流程(restrict L3 → 证据包 → unrestrict)实测通过;
临时库已 DROP。
遗留(未改,已向用户说明):L4 不覆盖 `/vote/{token}`;`restrict` 没有 `--days` 上限;处置不发邮件给箱主(只有登录后条幅);
`abuseAlertCooldowns` 进程内 map 无清理(重启即失效)。
## 第二轮:处置通知邮件 + 超长确认 + 冷却表清理
- 新增 `templates/mail/restriction-notice.html` + `mail.SendRestrictionNoticeMail(RestrictionNotice)`:
文案由调用方按**箱主自己的语言**(`users.language`,空则 `conf.App.DefaultLang`)预翻译后传入,
模板本身单语言。**不含 `--reason` 原文**,只有级别名/到期/规则链接/申诉渠道。
i18n 新增 10 个键 ×4 语言:`mail.restriction.*`(6)+ `restriction.level.L1..L4`。
- `restrict`:新增 `--force`(`--days > 90` 必填,否则直接报错)与 `--no-notify`;交互模式下超长处置
还要再输一次级别名(`--force --yes` 视为两次确认)。发信失败只 warn + 终端提示,**不回滚处置**;
发信加了 20s 超时(gomail 无 Timeout 字段,裸 Dial 会等系统 TCP 超时)。
- `abuseAlertCooldowns` 加惰性清理(>1024 条时删除已过 24h 的项)。
- 顺带:`/user/questions` 限制期条幅改用 `restriction.level.*` 的名称(「收窄公开」)而不是裸 `L2`。
验证:真实 SMTP sink 实收两封(ja 与 zh-CN),标题/级别名/到期/规则链接均正确、`--reason` 未出现、
链接未被 `ZgotmplZ` 转义;`--days 120` 无 `--force` 被拒;黑洞 SMTP 下 23s 内返回(未阻塞两分钟)。
`go build ./...`、`go vet`、db/i18n/templates 测试、checktpl 全绿。
遗留:`unrestrict` 不发通知(只解禁);`restrict` 对 `--days` 无上限(设计如此)。
## 第三轮:解除处置也发通知
- 复用同一封模板 `mail/restriction-notice.html`:`appeal` 段改为 `{{if .appeal}}` 可选,
解除通知不传该字段就整段省略。
- 新增 i18n 键 ×4:`mail.unrestrict.heading/body/note`(body 带域与级别名;
note 明确写「处置记录保留用于审计、私密内容不会自动恢复」)。
- `unrestrict` 新增 `--no-notify`;撤销前先 `ActiveLevel` 取级别(撤销后读不到),
撤销后按箱主语言发解除通知。`affected==0` 直接返回、不发信。
- 抽出公共件:`sendNotice`(20s 超时)、`reportNoticeResult`(统一成功/失败输出)。
- 文档补充:`unrestrict` 的 `--no-notify`、解除通知内容、以及**到期自动解除不会发信**(无调度器)。
实测(本地 SMTP sink,2 封):处置信含级别名/到期/申诉段;解除信含「已解除」+ 私密内容提示、
**无申诉段**;两封都不含 `--reason`。`--no-notify` 两处都不发信;无生效处置时不发信。
`go build ./...`、db/i18n/templates 测试、checktpl 全绿。
## 上线前最终验收(部署后实测)
- 公开页:`/` `/rules` `/restricted` `/deactivated` 全部 200;`/rules` 四语言标题正确。
- 箱主面板(admin6,L2):条幅显示「收窄公开」+ 规则链接;严格度面板锁定提示 1 处、下拉 disabled。
- **L2 写入路径实测**(关键,因为改成了 `AnswerByIDPrivate` 单条 UPDATE):
对未回答题 q95 作答 → 302 回题目页 → flash「已保存为私密:当前处于平台处置期,回答不会公开」→ 匿名访问 302。
- 公开列表 API `/api/v1/user/{domain}/questions` 正常返回 `{code,data:{data,next_cursor,has_more}}`(新增的 L4/密码校验没有打断正常路径)。
- 整箱分享卡:受处置箱主 302(拦截生效);未处置箱主 500,原因是**测试站未配置字体**(`分享图生成失败:字体文件未配置或无法加载`),与本次改动无关。
这一点反而证明拦截位置在生成之前。
- 未能自测:CLI 发信(restrict/unrestrict 通知)需要在服务器上跑命令,只能由用户执行。
注意:admin6 现有测试题 73/88/89/90/94/95;其中 95 已被我回答并存为私密(内容带 `restrict self-check`)。
admin6 的 L2 于 2026-10-19 21:29 自动到期。
## 邮件模板统一(用户反馈:页脚与其它邮件不一致)
- 原因:`restriction-notice.html` 是我新写的,页脚用了自造的 `© 2026 TamaBox`,与项目其它 11 封邮件的
「此邮件由系统自动发送,请勿直接回复。<br/>{{ .year }} 狼的提问箱」不一致。
- 处理:整封模板按 `question-reported.html` 的结构重写(24px 标题 + `{{.time}}` 副行、同样的卡片/页脚/外间距),
`time` 由 `mail.SendRestrictionNoticeMail` 统一注入(调用方不用各自拼时间)。
- 实测(本地 SMTP sink):页脚文本与 question-reported 完全一致,`© ... TamaBox` 已消失,头部时间行存在。
- 注意:站点名在所有邮件模板里都是硬编码「狼的提问箱」,不是配置项;改站名要一起改 12 个模板。
## 页脚精简(移动端一行放不下)
- 去掉「盘」链接(`https://share.shiroko.one`,zh 文案就叫「盘」),并删除已无引用的
`site.footer.drive` 键(四语言同步删,保证 `TestDictionaryComplete` 词条数一致)。
- `site.footer.rules`:zh-CN「社区规则」→「规则」,zh-TW「社群規則」→「規則」(en/ja 本就短)。
- 现在页脚一行:规则 | 找站长 | 公告 | 聊天 | 状态 | 主页。
- 注意:`templates/base/footer.html` 用 `|` 分隔,行首行尾各带一个,删整行时不会留下悬空分隔符(已核对)。
## 页脚改两行(用户改主意:盘恢复)
- 恢复 `https://share.shiroko.one`「盘」链接与 `site.footer.drive` 键(zh-CN 盘 / zh-TW 盤 / en Drive / ja ドライブ)。
- `site.footer.rules` 文案恢复:社区规则 / 社群規則(en/ja 不变)。
- 布局两行(靠 `<br>` 断行,不用额外 CSS):
第一行 `社区规则 | 找站长`,第二行 `盘 | 公告板 | 聊天室 | 监控板 | 导航页`。
- 键插在 `site.footer.rules` 之后(组末),空格数按同长度键复制,gofmt 对齐不变;dict 仍 CRLF。
- 验证:build / checktpl / i18n / templates 测试全绿。
## 页脚改为「从少到多」四行
版权行与存活计时移到最后两行(原来在最上面),整块按文字量递增:
`社区规则 | 找站长` → `盘 | 公告板 | 聊天室 | 监控板 | 导航页` → `Powered by NekoBox | ICP` → `提问箱已成功存活:…`。
实现只调整了 `<br>` 的位置,仍共 4 个 `<br>`,所以块高与顶部空行跟原来一致(未新增占位)。
注意:`box_runtime` 的脚本按 id 取元素,移动位置不影响;但 `<br>` 数量别随意增删,否则页脚高度会变。
## 前端样式体检(只出结论,未改代码)
量化事实(脚本统计 templates/**/*.html):
- 全站 **0 个 .css 文件**,样式全部内联:`base/header.html` 内含 **17.5KB CSS + 10.6KB JS**(模板总 42.5KB),
footer 3.0KB JS;全模板共 **24 个 `<style>` 块**,`user/profile.html` 自身还有 11.2KB CSS + 22.8KB JS(总 62KB)。
- `--tb-*` 设计变量 **只在 `html[data-theme="dark"]` 里定义**,light 主题没有 `:root` 一套;
调用处全靠 fallback(`#e9e9e9` / `#e5e5e5` / `#fafafa` / `#333` / `#666` 混用),explore 等页面还写死色值。
- **深色模式 FOUC**:`applyTheme(getTheme())` 在 body 内、`<nav>` 之后执行(header.html:812),
深色用户刷新时顶栏会先白闪一下。修法:把 theme 初始化内联脚本提到 `<head>`。
- 结构:`uk-container-xsmall`(750px)由 header 统一输出,仅 explore/vote/chat 页面自套 small;
`question/list.html` 仍用 `<center>`、内联 `style="min-height:260px"`,用户名/简介颜色由用户自选压在背景图上,无 scrim → 可读性无保障。
- 首页 `home.html` 是纯 UIkit 卡片 + `<hr>` + `style="color: red"` + `<pre>` 统计 + 首页挂 Waline(250 行 locale)。
建议分档(未实施):P0 = light/dark 双套 token + 圆角/阴影/中文字体栈 + 首页重排 + 背景图 scrim;
P1 = header CSS 抽成 `static/css/*.css`(注意 `embed.go`/`static/fs.go` 都是显式清单)+ FOUC 修复;
P2 = 图标统一(UIkit icons 取代 emoji/位图 png)、标签减量、viewpoint/meta 去重。
## P0 前端美化(已实施)
用户拍板:卡片**结构不变,只加圆角和柔阴影**;先做 P0,不抽 CSS 文件。
改动文件:
- `templates/base/header.html`
- head 顶部新增**主题初始化内联脚本**(深色用户不再白闪;原 `applyTheme` 在 body 里、nav 之后执行)。
顺手删掉 body 脚本里的 `applyTheme(getTheme())`,只保留切换逻辑。
- 新增**设计令牌块**:`:root` 亮色 + `html[data-theme=dark]` 暗色,同名 14 个(bg/surface/surface-2/surface-hover/
border/text/text-secondary/text-muted/primary/primary-weak/radius/radius-sm/radius-xs/shadow/shadow-hover/font)。
原暗色块里的 7 个变量定义已删除(现在统一在令牌块)。
- 新增**基础质感层**:html+body 底色与字体栈(system-ui + 苹方/冬青黑/雅黑)、卡片 12px 圆角 + 双层柔投影、
按钮/输入 8px、标签 6px、alert 10px、modal 14px、导航栏白底、`:focus-visible` 描边、`tabular-nums`、
`prefers-reduced-motion`。
- `.tb-cover`:封面遮罩;默认浅色(配 `.uk-light` 的黑字兜底),`.tb-cover-dark` 为深色。
- `.tb-img-uploader` 上收到 header(原来 item / new-question 各写一份**内联 style**,写死 #f8f8f8 → 深色下露白)。
- `templates/home.html`:整页重排为 hero(标题+一句话+探索/规则按钮)+ 3 张功能卡 + 域名迁移公告
+ 提醒列表 + `<details>` 折叠更新日志 + 3 个数字统计卡(`.SiteStats` 数值字段,30 分钟 JS 刷新改为按字段更新)。
全部原有信息保留;页内 `style=` 归零。
- `templates/question/list.html`:封面加 `.tb-cover`(仅当有背景图),新增 12 行 JS 按用户名颜色亮度
决定遮罩方向;`min-height:260px` 内联改为 `.tb-profile-cover`。
- 6 个页面样式块里的硬编码色值收敛到令牌(explore/question-list/profile/list/item/vote-page,共 ~84 行),
CRLF 文件用字节级替换(未改动行尾)。
验证:`go test ./templates/... ./internal/i18n/...` 全绿;`go build`(overlay)通过。
新增 `templates/zz_preview_test.go`:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 产出
`outputs/preview-{home,box,explore}-{light,dark}.html`,用无头 Chrome 截图核对过亮暗两套。
⚠️ 本机 **Chrome 新版 headless 会挂住**,必须 `--headless=old`,且页面外部资源要本地化
(`outputs/preview-assets/`,从 cdnjs.snrat.com 取 UIkit 3.3.3;cloudflare/jsdelivr 在本机不通)。
## 封面遮罩 P0 缺陷修复(重要)
**症状**:`.tb-cover::after` 遮罩不渲染,且**完全不报错**——截图对比像素只差 ±1(只看着像"没效果")。
**根因**:UIkit 3.3.3 自带
`.uk-flex::after,.uk-flex::before{display:none}`(优先级 0,2,0),
而封面元素是 `uk-flex`,我写的 `.tb-cover::after`(0,1,1)优先级不够 → 被压成 display:none。
定位方法:`getComputedStyle(el,'::after').display` 得到 `none`;再逐个 `classList.remove` 试 →
去掉 `uk-flex` 立刻变 `block`。最后在 uikit.min.css 里 grep 到那条规则。
**修法**:选择器写成 `.tb-cover::after, .tb-cover.uk-flex::after` 并显式 `display:block`。
**实测效果**(无头 Chrome 截图 + 像素测量,非目测):
- 深色渐变背景 + 白字:封面底部亮度 0.229→0.102,白字对比度 **3.77→6.89**(过 WCAG AA 4.5)
- 线上默认背景(`box_background.png` 2048×1152,接近全白)+ 黑字:0.912→0.971,对比度 19.25→20.42,
即**对默认背景几乎没有可见影响**
- 亮度判定 JS 已验证:`#fff` 会加 `tb-cover-dark`,`#000` 不加。
**教训**:CSS 改动不能只看"规则写上了/类加上了",必须量像素。伪元素 + UIkit 工具类组合要在计算样式层验证。
## 账号背景图的真实链路(回答用户提问时查证)
- 注册时把 `[upload] default_background` **一次性复制**进 `users.background`(`route/auth/register.go:51`),
不是每次渲染兜底 → 之后改配置**不影响存量用户**。
- 页面只用 `.PageUser.Background` 渲染 `data-src`;为空则无背景图、无遮罩。
- 分享卡是另一条路:`route/question/share.go:345` 用 `user.Background`,为空时回退 `conf.App.ShareBackground`。
- 线上 `box.shiroko.one/_/tamakyi` 实际值 = `https://r2.337845818.best/tamabox/box_background.png`。
- 预览里那张"渐变图"是 `zz_preview_test.go` 造的测试数据(data: URI 渐变),**不是产品行为**;
顺带发现 Go 模板会把 `data:` URI 在属性上下文里替换成 `#ZgotmplZ`(真实 http URL 不受影响)。
## 设置页「防护设置」缺白底(用户报)
根因:其它面板都包在 `<section class="tb-pcard">`,只有内容防护面板(profile.html:842)是裸 `div.profile-panel`,
直接露出页面灰底 `--tb-bg`。修法:包一层 `section.tb-pcard`,并把 `.tb-pcard` 的 `background:#fff` 改成
`var(--tb-surface)`(删掉随之冗余的 dark 覆盖)。
实测(计算样式):account 与 security 面板 bg 亮色均 `rgb(255,255,255)`、暗色均 `rgb(36,40,48)`,圆角 10px,完全一致。
顺手把 `security` 加入 `render_test.go` 的面板渲染测试列表(原来只测 account/question/chat/vote)。
⚠️ 教训:用脚本批量缩进/hash 插入时先 dry-run。这次第一版脚本把「空行」也当成需要写 `<section>` 的行,
一次性插了 5 个多余标签;靠立刻 `sed` 复查发现并回滚。
## P1 第一步:CSS 抽离(已完成)
- 新增 `static/css/base.css`(23KB,含注释;gzip 后约 6KB),内容 = 原 header 里的
设计令牌 / UIkit 补丁 / 深色覆盖 / 质感层 / 顶栏响应式 / 标签 / Markdown / 进度条 / 移动端底栏。
- `header.html`:内联 `<style>` 从 17.5KB 降到只剩 1 个(算数验证码,跟着 `{{ if CaptchaType }}` 条件渲染);
改为 `<link href="/static/css/base.css?v=20261019a">`,位置在 UIkit 样式之后(保证覆盖顺序)。
单页 HTML 42.5KB → 30.9KB。
- `static/fs.go` 的 `//go:embed` 显式清单加 `css/base.css`(漏了就是运行期 404 + 全站无样式,编译期不报错)。
- 静态缓存沿用既有 `CacheControl: public, max-age=86400`;靠 `?v=` 版本号破缓存。
**验证方式**:改动前后各截一次同样尺寸的截图做像素比对。
home-light / home-dark / explore-light 三页差异均 **0.024%**,bbox 只有 (11,56)-(29,74) 一个 18×18 方块
= 导航栏随机动物 emoji(每次加载都变),不是样式回归。go build + 模板/i18n 测试全绿。
## 测试环境复测(P1 CSS 抽离 + 防护面板白底,已上线)
- `GET /static/css/base.css?v=20261019a` → 200 / `text/css` / **23088 字节,与本地 `static/css/base.css` 逐字节一致** /
`Cache-Control: public, max-age=86400` / gzip 后 7109 字节。
- 首页 32747 字节(改动前 52747),gzip 10534(改动前 13642);页面内联 `<style>` 只剩验证码 + 首页自己的样式块。
- 设置页(登录 admin6):`data-profile-panel="security"` 下已有 `<section class="tb-pcard">`;
用线上 HTML + 本地 CSS 复算样式 → account / security 两块 `bg=rgb(255,255,255)`、`border-radius:10px`,页面底 `rgb(247,248,250)`。
- `/_/admin6` 公开页:`class="... uk-light tb-cover"`,`:after` 计算值 **display=block, width=689px**
→ 遮罩在真实部署上确实渲染了(UIkit `.uk-flex::after{display:none}` 的坑已解决)。
该用户用户名为 `rgb(0,0,0)`,走浅色遮罩(符合预期)。
- 各页 200:`/`、`/explore`、`/rules`、`/user/questions`;`/user/vote-form` 无参 302 属正常。
- 首页首次访问总字节略增(10.5+7.1 vs 13.6KB gzip),但 CSS 跨页缓存后每页少 3.1KB,且服务端少渲染 17.5KB 模板。
## 专属测试账号(用户指定)
- `https://box-dev-api.337845818.best/_/admin6`,邮箱 `[email protected]`,密码 `adminadmin`。
- 登录需算数验证码,curl 流程:GET /login 取 `_csrf`(同时存 cookie)→ `GET /api/captcha/generate` 拿 `{id, question}`
→ 算答案 → `POST /api/captcha/verify`(头 `X-CSRF-Token`,体 `{id, answer}`)→
`POST /login`(email/password/_csrf/`g-recaptcha-response=id:answer`)→ 302 到 `/`,cookie 已带登录态。
## P2 一致性收尾(已实施,未部署)
1. **导航图标**:🌐🌙☀️ emoji → 内联单色 SVG(`stroke: currentColor`,跟随主题/悬停色)。
⚠️ 原计划用 UIkit icons,实测 **UIkit 3.3.3 的图标集里没有 moon / sun**(只有 world),
照写会渲染成空白按钮——所以三个图标全部改成内联 SVG,也不依赖 JS。
2. **标签**:投票类型 20 处 `uk-label uk-label-primary`(实心蓝)→ `.tb-chip` 轻量胶囊;
探索页热门标签原来的 5 色轮换(success/warning/danger/primary/secondary)也统一成 `.tb-chip`。
箱主面板 `question-list.html` 另 6 处字节级替换(CRLF 保留)。
保留:置顶/私密/被举报/已读 这些**语义状态**仍是彩色 uk-label(有含义,不该降级)。
3. **遗留写法**:`<center>` ×2 → `div.uk-text-center`;占比条从
`div.uk-progress > div.uk-progressbar(style:width)` 改成 UIkit 3 原生
`<progress class="uk-progress" value=".." max="100">`,**删掉 25 行 UIkit2 兼容补丁**,
只保留 10px 高/6px 圆角的外观收窄。测试 `percent_check_test.go` 的断言同步改成 `value="66" max="100"`。
4. **meta**:删掉重复的 `msvalidate.01`(保留 head 顶部一份)、viewport 补 `initial-scale=1`。
**踩坑**:暗色主题图标原来写 `display:inline`,span 里放块级 `<svg>` 会让导航栏高度变化 → 整页下移 36px。
用行亮度曲线做互相关定位到 dy=36,改成 `inline-flex` 后复测差异回到 0.059%(只剩随机 emoji)。
教训:**改图标尺寸/显示模式一定要量整页位移**,不能只看局部截图。
## P2 线上复测(2026-10-19 12:2x)
- `GET /static/css/base.css?v=20261019c` → 200 / text/css / **23918 字节,与本地逐字节一致**,页面已引用该版本。
- meta:viewport 已带 `initial-scale=1`;`msvalidate.01` 只剩 1 份。
- 图标:首页 emoji 🌐🌙☀️ 归零,`<svg ... stroke="currentColor">` ×5(语言 1 + 桌面主题 2 + 移动主题 2);
计算样式暗色下 `#theme-toggle .tb-icon-dark` = `display:flex`、亮色图标 `none`;
**导航高度亮/暗均 80px** → 上次 36px 位移问题确认修好。
- 探索页:`.tb-chip` ×25(全部有底色 `rgb(244,246,248)`、文字 `rgb(92,99,112)`、圆角 6px、字号 11.52px);
残留彩色 `uk-label` 4 个,均为置顶/私密等语义状态。
- 结果页:`/user/votes/{id}/results` 原生 `<progress class="uk-progress" value max>`,计算高度 **10px**、圆角 6px;
遍历 10 个结果页 `uk-progressbar` 全部为 0。
- 巡检 `/ /explore /rules /login /register /_/admin6 /_/admin3` 均 200。
## P2 补漏(已改,待部署)
复测时发现 **结果页与参与页的题型标签仍是实心彩色** —— 那两处的 class 由 Go 模板函数
`fieldTypeLabelClass` 生成,我上一轮只按字面 `class="uk-label uk-label-primary"` 搜索,漏了它们。
- `templates/user/vote-results.html`(2 处)、`templates/vote/page.html`(2 处):序号 + 题型 → `.tb-chip`;
状态标签(未开始/已结束/进行中/已参与)保留彩色,属语义。
- 删除 `internal/template/template.go` 的 `fieldTypeLabelClass`(19 行,已无引用)。
顺带查明:该函数 `text/textarea` 走 `return ""`,注释说"默认灰",**实际渲染成 UIkit 默认蓝**,
所以文本框标签一直是蓝的——注释与实现不符。
- `.uk-progress::-webkit-progress-value` 圆角改成两端都圆(`-moz-progress-bar` 同步)。
⚠️ 起因是误判:我先前测"右端方角"时像素扫描循环上界写成 `range(110,830)`,恰好等于条宽,
于是右端永远"恒定不变"→ 假象。放宽到整幅宽度后两版端点曲线完全一致(830/832/833/834/833/832/830),
即**没有视觉 bug**。保留改动只为 Firefox 的 `-moz-progress-bar` 保险。
- `base.css?v=` → `20261019d`。`go build` + `./templates ./internal/i18n ./internal/template` 全绿。
**教训(重要)**:像素测量脚本的扫描范围必须覆盖整幅图,循环上界不能与被测对象边界重合,
否则会把"边界截断"读成"图形特征"。之前 3.77→6.89 那组对比不知是否也受此影响,需复查时再确认。
## P2 补漏部署后复测(12:48)
- `base.css?v=20261019d` 上线,24023 字节与本地逐字节一致。
- 删掉 `fieldTypeLabelClass` 后**无运行期报错**:公开投票页 3 个(200,tb-chip 8/16/4,uk-label 各 1 = 状态)、
结果页 4 个(200,tb-chip 8/2/0/16,彩色 label 全 0,progress 7/0/10/19)、
草稿预览页 2 个(200,tb-chip 2/6)。
- 巡检 `/ /explore /rules /user/questions /user/profile?tab=security /_/admin6 /_/admin3` 全 200 无模板错误。
- `/user/votes/{id}/preview` 对**已发布**投票 302 到 `/vote/{token}` 是设计如此(vote.go:1093),不是回归。
- 截图核对:结果页「1 単選 / 2 多選」已为浅色胶囊,进度条 10px 圆角 6px;参与页字段头同样统一。
## "移动端语言/主题按钮不见了"排查(结论:非缺陷)
实测(线上测试环境,`--force-device-scale-factor=1`):
- 宽 360/390/414/640 → 顶栏 `.tb-nav-utils` = `display:none`,底栏 `#tb-mobile-utils` = `flex`,
rect `0,639,W,46`,语言 `block`、主题按钮 `block`(48×30,SVG 16×16),命中测试落在 `mobile-lang-select`。
- 宽 700/768/980 → 底栏 `none`,顶栏 `flex`,右上角地球 + 月亮都可见(截图 `outputs/t-nav-right.png`)。
- 页面级差异:`/_/admin6` 底栏**只有月亮没有语言下拉** —— `IsUserPage=true` 让语言块整体不渲染(桌面同样没有)。
- 生产 `box.shiroko.one` 仍是旧构建(页面无 `base.css` 引用,底栏是 emoji 月亮),移动端同样正常。
用户确认是忘了「移动端移到固定底栏」这个设计,不是 bug。
留下的真实缺口:**箱主页(最常见的外部入口)完全没有语言切换**,见 MEMORY.md 前端样式条目。
## 修正:箱主页无语言切换是设计,不是缺口
上一节把它记成"真实缺口"是错的。用户明确:**箱主页属箱主个性化,以箱主配置为主**,
语言入口不出现是有意为之(`IsUserPage` 门控)。
MEMORY.md 对应条目已改为"有意设计、别当 bug 修、要动先问"。
## 备份(用户要求)
`../TamaBox-v4-mainline-20261019-1445.tar.gz`(940K,302 条目,排除 .git/.workbuddy/outputs)。
P1 剩余两项(首屏跟随系统深色、首页 Waline 懒加载)用户明确**先不做**。
## 主题系统方案(只出设计,未实现)
用户诉求:箱主在后台选主题、仅对箱主相关页面生效;站点级默认主题在配置里;要一份可复用的主题底稿。
已确认的现状事实(设计依据):
- 设计令牌只有 16 个,集中在 `static/css/base.css` 的 `:root`(亮) + `html[data-theme="dark"]`(暗)。
- `<html lang>` 在 `templates/base/header.html:2`,服务端渲染;主题属性可放在这里(无 FOUC)。
- `question.Pager`(`route/question/page.go:45`)是 `/_/{domain}` 与 `/_/{domain}/{id}` 的共同中间件
→ 打主题标记的唯一合适位置。
- `/user/*` 与 `/_/*` 共用 `IsUserPage`(context.go:241 按 URL 前缀判定),所以「箱主相关页面」
≠ IsUserPage,必须单独区分,否则设置页会被主题化。
- `static/fs.go` 与 `templates/embed.go` 都是显式 embed 清单(新增主题 css 必须手加)。
- 用户模型已有大量个性化字段(usernamecolor/introcolor/background…),主题作为 `users.theme` 同族最自然。
- 分享卡是服务端绘制图片(`route/question/share.go`),不经过 CSS → 主题不覆盖它。
关键结论(写进方案的核心):令牌覆盖必须用
`html[data-theme-style="x"]`(0,1,1,压过 `:root` 的 0,1,0)+
`html[data-theme="dark"][data-theme-style="x"]`(0,2,1,压过 base 的 dark)。
只写浅色块会让深色模式被浅色令牌污染(同特异性 + 主题文件后加载)。
## 主题系统初版(已实现,未部署)
用户拍板:①作用范围跟随箱主配置(即 /_/ 箱主相关页面)②主题只做配色/图案/整页背景,**不涉及布局**
③主题名可本地化 ④分享卡不参与。
实现:
- `internal/theme`:注册表 + 三级回退。`[theme] default/available` → 白名单(slug 正则 + css 是否存在),
`Resolve(用户值)`:`default`→空串(不加载)/命中→该 slug/其余→站点默认。"" 与 "default" 语义不同:
""=跟随站点默认,"default"=箱主明确选基础外观。
设置页色块从主题 css 浅色块里抓 `--tb-bg`/`--tb-primary` 拼渐变(值必须过色值正则,防注入)。
- `users.theme varchar(32) default ''`(AutoMigrate 加列)+ `UpdateTheme` 单列更新。
- `question.Pager` 注入 `Data["ThemeSlug"]`(`/_/{domain}`、单题、密码页、分享卡路由共用这一个中间件);
header.html 在 `<html>` 输出 `data-theme-style` 并条件加载 `themes/<slug>.css`。
- 设置页账号面板末尾新增「主题外观」卡片(独立表单 `POST /user/profile/theme`,不塞进 UpdateProfile,
避免与该面板其它字段互相覆盖);`{{ if .Themes }}` 包裹,未配置主题时不显示。
- 静态版本号统一 `conf.StaticVersion = "20261019e"`(base.css 与主题 css 共用,改样式只改一处)。
- `static/fs.go` 改嵌目录 `css/themes`(以后加主题文件不用再改 embed 清单)。
- 主题文件:`_scaffold.css`(底稿,含 19 令牌说明 + 素材规格 + 特异性规范)、`default.css`(恒等,仅色块预览)、
`sakura.css`(示例,纯 CSS 网点图案 + 粉调)。
- base.css:body 接 `--tb-page-image` / `--tb-page-pattern` / `--tb-page-pattern-size` 两层背景;
**UIkit 主色是编译期常量 #1e87f0**,新增映射块把 a/uk-link、uk-button-primary、checkbox/radio、input:focus、
uk-tab active 接到 `--tb-primary`(基础主题值相同,默认外观零变化)。
实测(无头 Chrome,box 页 data-theme-style=sakura):
- 浅色 primary `#e5678f`/bg `#fff6f8`/pattern 16px 网点命中;深色 `#ff8ab0`/`#241a1f`,卡片表面各自跟随。
- 未选主题的 box 页:无 data 属性、无 themes link、主按钮仍 `rgb(30,135,240)` → 零回归。
踩坑:
1. 第一版 sakura 页「发送提问」还是蓝色 —— UIkit 组件色写死,令牌覆盖不到,必须按组件映射。
2. 预览里 `{{ .StaticVersion }}` 为空(预览夹具没这个键),导致按固定串替换主题 css 失败;改用正则。
3. `writePreview` 用全文搜 `data-theme="dark"` 判断是否已加属性 → 命中内联主题 css 里的选择器,深色预览没生效;
改为只看 `<html ...>` 开标签。
4. 脚本改 form/user.go 时把插入块写在结构体中间,拼出 `} Language ...`;靠 Read 复查发现并重写。
## 主题 follow-up(部署后反馈两处)
1. **/user/* 不生效主题**:根因是 `ctx.Data["ThemeSlug"]` 只在 `question.Pager`(`/_/{domain}`)里注入,
`/user/*` 完全没走这条链路。修法:`internal/context/context.go` 的 Contexter 里按
`isThemeScope(path) && c.IsLogged` 注入 `theme.Resolve(c.User.Theme)`;
`isThemeScope` 只认 `/user` 与 `/user/`(不能用裸 `HasPrefix("/user")`,会误吞 `/username`)。
新增 `internal/context/context_test.go` 守住路径白名单;page.go 的注释同步改。
公开页(`/`、`/explore`、`/vote/{token}`)**仍不跟随**登录用户主题,保持站点默认。
2. **sakura 显示奇怪**(用户选择:顶栏白与粉底割裂 / 卡片纯白太突 / 底色分布不均):
- base.css 新增令牌 `--tb-navbar`(`:root` 里 `var(--tb-surface)`,亮暗两条 navbar 规则都改成
`var(--tb-navbar, var(--tb-surface))`),主题可单独给顶栏上色。
- sakura v2:底色改**纯色** `#fceef3`(不用渐变——渐变会被读成"不均匀"),
顶栏 `#fff6f9`、卡片 `#fffafc`(暖白,不再是纯白硬切),网点从 16px/0.14 放宽到 24px/0.05。
- 静态版本 `20261019f`;`_scaffold.css` 令牌说明 19→20。
- `profile.theme.hint` 四语言文案改成「提问箱页面 + 本人后台生效,公开页保持默认」。
- `zz_preview_test.go` 抽出 `inlineThemeCSS()`,并新增 `preview-profile-sakura-*`(后台页面也要在主题下检查)。
验证:`go build ./...`(须带 webp overlay)、i18n/theme/context/templates 四个包测试全绿。
渲染法:抓线上 `/_/admin6` HTML → 把 base.css 指到本地新文件、主题 css 内联 → 无头 Chrome `--headless=old` 截图。
## 投票页 + 标签主色(部署复核后第二轮)
- `/vote/{token}` 主题:`route/vote/vote.go` 的 `Page` 里本来就会 `db.Users.GetByID(vote.UserID)` 取 owner,
直接在同一分支补 `ctx.Data["ThemeSlug"] = theme.Resolve(owner.Theme)`;box 页 / /user 后台 / 投票页三处
全部由发布者配置决定。GetByID 失败(箱主已删)保持站点默认外观。
- **`uk-label` 默认色就是 UIkit 主色 #1e87f0**:主题下提问管理的「未回答 / 未读」两枚胶囊还是蓝的。
base.css 追加
`.uk-label:not(.uk-label-danger):not(.uk-label-warning):not(.uk-label-success):not(.uk-label-secondary)`
→ `--tb-primary`。**必须用 :not() 排除语义色**:同特异性且 base.css 在 UIkit 之后,不排除会把
danger/warning/success 一起刷成主色。默认主题实测仍是 (30,135,240),零回归。
- StaticVersion → `20261019g`。
- 线上复核(admin6 专属账号,curl 走完整登录:GET /login 取 _csrf → /api/captcha/generate →
/api/captcha/verify(**answer 必须是数字,传字符串会 400 请求格式错误**)→ POST /login):
`/user/profile`、`/user/questions` 均已输出 `data-theme-style="sakura"` 与 themes link(v=20261019f)。
投票页在 f 版本仍无主题 → 需再部署一次。
## 投票页语言跟随发布者(第三轮)
- 抽出 `applyOwnerLocale(data map[string]interface{}, owner *db.User)`(route/vote/vote.go 顶部):
同时写 `Owner` / `Lang` / `LangCode` / `ThemeSlug`。
签名故意收 Data 而不是 `context.Context`:`Context` 内部字段不可在包外构造,收 map 才能单测
(`route/vote/locale_test.go`,纯内存,不碰 DB)。
- `Page`:owner 查询提到 `FeatureStateOf` **之前**——否则「投票功能已关闭」这类提示先按访客语言生成。
- `Submit`:同样先取 owner 再取 `lang`,保证提交后回跳页的 flash 与页面语言一致;
顺带删掉尾部重复的 `GetByID(owner)`(表单类型通知邮件复用同一个 owner),少一次查询。
- 四语言 `profile.theme.hint` 与 changelog 范围说明补上「你发布的投票页」。
- 验证:vote/i18n/context/theme/templates 全绿;线上 `lang="ja"` + 中文内容的不一致待部署后复测。
### 部署后复测(v=20261019g)
- `/vote/7kcP5kR8YnJC`(admin6):`lang="zh-CN" data-theme-style="sakura"`,base/theme css 均 v=g,
日文串(投票する / 匿名での参加 / ログイン)全部消失 → 语言与主题同步跟随发布者。
- `/ _/admin6`、`/user/profile`:仍为 `zh-CN` + sakura,未回归。
- 隔离性:另取三个 token 抽查 —— 4G3i79OCYa2E / 7l0MwF7cLz4T 为 `lang="ja"`(无语言偏好、无主题),
9J1zgXwQ05Ai 为 `lang="zh-CN"` 且**无** data-theme-style → 主题不会跨箱主泄漏。
- 遗留观感问题(未改,等用户决定):投票页的「允许匿名参与」提示条是 `uk-alert-primary`,
仍是 UIkit 浅蓝,在粉底上偏冷。改它需要「浅底 + 深主色文字」的对比度,现有 `--tb-primary-weak`
叠白后对比度只有 ~2.6:1,得先加一个 primary-strong 令牌,故未动。
### 顶栏配色再修(v=20261019h)
用户反馈「探索/注册链接底色粉色,但整条顶栏是灰色,很突兀」。实测部署版:顶栏 `#fff6f9`、
页面 `#fceef3` —— 近白横带压在饱和粉底上,肉眼就读成「白色/灰色的一条」。无头 Chrome 截图 +
像素采样确认不是 UIkit 默认 #f8f8f8 回退(那说明变量没生效,现象会连带整页失效)。
- sakura 浅色 `--tb-navbar` 直接取页面底色 `#fceef3`(深色同样 `#241a1f`),横带消失;
- 顶栏链接 `#999` 在粉底上偏淡 → 主题内微调为 `--tb-text-secondary`,hover 用 `--tb-primary`
(深色被 base.css 的 `!important` 规则接管,不受影响);
- 顺带 `uk-alert-primary`(匿名参与提示/空列表)的 UIkit 浅蓝是页面上唯一的冷色块,
改为 `--tb-primary-weak` 底 + `#a63a5e` 文字(≈5.3:1)。
- 以上三条都写在 sakura.css 的「组件微调」段,只对 sakura 生效,默认主题零影响。
## 主题「砂狼白子 / shiroko」(v=20261019i)
- `static/css/themes/shiroko.css`:灰蓝银白令牌(浅 #edf2f8 / 主色 #4a7fc1),
组件微调只有两条 —— `.uk-card-default` 右下角水印立绘(`background-size: auto 176px`,纯背景层不改布局)、
顶栏链接色提到 `--tb-text-secondary`;深色不铺立绘(浅色构图在深色下会糊)。
- 素材 `static/images/themes/shiroko/`(68KB):`bg-desktop.webp`(2400×1350) / `bg-mobile.webp`(900×1600) /
`hero-fade.webp`(275×620,16% alpha) / `pattern.svg` / `pattern-dark.svg`。
- **`static/fs.go` 的 embed 清单加了 `images/themes`**(目录项,子目录自动覆盖)——
这是「新增顶层静态目录必须手工登记」的又一次踩点。
- 背景是 PIL 合成的:1280 行的竖向渐变 + 白色径向光晕 + 立绘 30% 贴右下 + 狼 10% / 光环 20% / 纸飞机 18%。
实测 mean≈244、std≈9;右侧留白区 std 13(立绘可见),中间 std 1.9(正文区干净)。
- ⚠️ **`cover` 会按视口裁剪**:竖屏/超宽屏会把贴在图片最右的立绘裁掉,所以立绘不能太靠边 ——
位置要留在图片横向 24%~76% 之外就得配 `@media` 换图(本次桌面 16:9 + 移动 9:16 两张)。
- 生成素材(ImageGen)8 张里只有 3 张可用:线稿图案(狼/光环/纸飞机)抠白底干净;
2 张人像抠底失败(背景不是纯白,`background: transparent` 实际没生效),2 张场景背景几乎没有内容(std<5)。
教训:**没有视觉能力时,生成图要用 `mean/std/edge` 先验内容量**,别直接拿去合成。
- 素材来源与版权写在 `docs/theme-shiroko.md`(角色版权 NEXON / Yostar,非商用同人)。
- 启用需要在 `conf/app.ini` 的 `[theme] available` 里加 `shiroko`(示例配置已更新)。
## shiroko 插画重做:动物线稿 → Q 版人物(v=20261019k)
用户反馈「简笔画不是画动物,是人物形象」,插画层整体重做。
- 新增 `dev/gen_shiroko_chibi.py`:手写 SVG 生成 6 个 Q 版白子(wave/cheer/think/run/doze/peek),
3.8KB/张,造型逐项对齐用户立绘(灰发蘑菇头 + 圆钝狼耳 + 倾斜光环 + 青围巾 + 深蓝外套 + 灰裙 + 青黑球鞋)。
**生成模型这条路走不通**:`background: transparent` 不生效、无视觉反馈时无法判断好坏;
代码画路径反而可控、体积小、任意尺寸不糊。
- ⚠️ **cover 必然裁掉贴边插画**:1280×1400 视口下,2400×1350 的背景图只露出中部 1234px,
右下立绘被裁成 38px 一条。→ base.css 新增装饰层令牌 `--tb-deco / --tb-deco-size / --tb-deco-position`
(固定像素 + 四角定位 + fixed,不随视口比例变化)。
- ⚠️ **多背景层的值个数必须恒定**:deco 先写成 4 个值,background-image 变 6 层而 size/position 只有 3 个,
CSS 循环取值 → 整页重复小人。改成固定 6 个槽位(1 图案 + 6 装饰 + 1 背景 = 8 层),
主题必须写满 6 个(缺的填 none),这条写进了 `_scaffold.css`。
- 删掉 `bg-desktop.webp` / `bg-mobile.webp` / `hero.webp` / `dev/gen_shiroko_bg.py`;
整页底色改成纯 CSS 渐变(不用图片,也就没有 cover 缩放差异)。
- 深色关掉 deco(浅色线稿在深色底上糊成灰块);pattern 里的爪印换成 Q 版头像,
opacity 0.42→0.2、tile 240→320(原来铺得太密,像壁纸)。
- 验证:默认主题背景仍 `#f7f8fa`、顶栏纯白、卡片纯白(零回归);箱主页与 `/user` 后台四角装饰都不压内容;
移动端只留立绘 + peek。
## shiroko 插画二次返工:手绘 SVG → 参考立绘图生图(v=20261019l)
用户反馈上次的简笔画"太抽象"。根因判断:用户的立绘是**平涂无描边色块风**,
我用手绘线稿去配它,风格本身就冲突,而且没有真人手感,形准也做不到。
- 新方案:ImageGen **图生图**(`image1` = `static/images/shiroko-cutout.png`,`input_fidelity: high`),
提示词统一为「same character, same art style... flat vector illustration, no outlines」+ 换动作句,
生成 4 个姿态(wave / cheer / think / doze),1024×1024、纯白背景。
**四张角色特征与画风一致**,这一步是手绘做不到的。
- 抠图:四角 + 四边中点 8 点 floodfill(容差 16)+ alpha 1px MinFilter 收边,裁 bbox 后统一高 620、
WebP q90。实测无白边,光环中间的孔也正确透出。
- 删除 `dev/gen_shiroko_chibi.py` 与 6 个手绘 SVG;`chibi/*.webp` 4 张共 106KB。
图案里的手绘小头像也删了(和位图人物并排显得最抽象),改成雪花 + 光环 + 圆环 + 点簇。
- ⚠️ **deco 的 `center` 定位在矮视口会撞车**:右下立绘 520px 高,1366×768 下从 y=248 一直到底,
托腮放 `right center` 会直接站在立绘头顶。百分比是「(视口-图高)×pct」,改用 `top 46%` 并挪到左侧中部。
验证视口:1366×768 / 1440×900 / 1280×1400 三档,人物互不重叠。
- docs 补了完整提示词骨架、动作句与抠图参数,说明"必须平涂无描边"这条经验。
## shiroko 布局重排 + 新坐姿(v=20261019m)
用户反馈:左右不协调、最上边的头被顶栏挡住。
- 布局改为**左右各两个**(左上 wave / 左下 sit,右上 cheer / 右下立绘),
`≥920px` 真实视口才补中间两个(think / doze),矮屏不放(否则压到立绘)。
- 顶部定位从百分比改成**固定 `top 96px`**:顶栏实际高 60px(不是 80),
百分比是「(视口-图高)×pct」,小视口下算出来的值会落进顶栏里。
`bottom 0` 也改成 `bottom 6px`,避免脚贴页底。
- 新增坐姿(盘腿),ImageGen 图生图第 2 次才可用:第 1 张脚被画到画布底边(底部 78 像素贴边),
提示词补 `centered composition, whole body fully visible with generous empty margin` 后正常。
- ⚠️ **Chrome headless 的 `--window-size` ≠ CSS 视口**:实测 1080 → innerHeight 985、768 → 673
(差约 95px 的窗口边框)。所以 `@media (min-height: 1050px)` 在 1080p 下永远不触发。
判断媒体查询门槛要按「真实视口」写,截图验证必须加 `--force-device-scale-factor=1`,
否则系统 DPI 缩放会让截图尺寸和 window-size 也对不上(曾出现 1920 请求出 1088 宽)。
- git:仓库可用(origin = https://git.shiroko.one/tamakyi/tamabox,main),
已本地提交 `0c39aff`,但 `credential.helper=store` 里的凭据过期 → `Failed to authenticate user`。
打包 `../TamaBox-v4-mainline-20261019-2037.tar.gz`(1.5M,含图片素材)供直接部署。
### git push 补推
上次的 `Failed to authenticate user` 是临时态 —— 用户本机 Git for Windows 认证过之后 store 凭据恢复,
本轮 `git push origin main` 直接成功(`38cd41a..0c39aff`),`git rev-list --left-right --count origin/main...HEAD` = 0 0。
远端 8 个 shiroko 素材文件齐全。教训:**不要用一次 push 失败就断言无凭据**,先看一眼提交是否已存在、再试一次。
### 部署后「设置页找不到 shiroko」
线上实测:`/static/css/themes/shiroko.css?v=20261019m` 200 / 5038B 且与本地逐字节一致,
说明二进制确实带了新主题;但 `/user/profile` 渲染出来的主题单选只有 `""`、`default`、`sakura`
→ **根因是 `[theme] available` 白名单没登记 shiroko**(列表在 `theme.load()` 里按配置构建,启动时读一次)。
- `internal/theme/theme.go` 新增 `reportRegistry()` / `discover()`:启动打 info「主题注册表已加载 enabled=...」,
并把「css 在、但没登记」的主题以 warning 打出建议的 available 值。
没有这段时,这个故障在日志里完全没有痕迹,只能靠猜。
`discover()` 用 `fs.Glob(static.FS, "css/themes/*.css")`,用 slugRe 过滤掉 `_scaffold.css`(下划线开头)。
- `conf/app.sample.ini`:`available = default,sakura,shiroko` + 补充「没登记就不显示」的注释。
- `docs/theme-shiroko.md` 补「部署后看不到主题」两段排查(先 curl css 看 200/404,再看 available)。
- 排查口径:**先分「文件没发布」与「没登记」两类**,别一上来怀疑缓存;`StaticVersion` 只影响缓存,不影响列表。
### shiroko 装饰返工:散射 → 左下角一组(v=20261019n)
用户反馈「主题正常了,但展示方式怪怪的」。实测线上 1512×982 截图确认三处:
1. 四角各一个人 = 贴纸墙纸(人物之间、人物与内容都没有关系);
2. 半透明立绘(16%)叠在实心人物旁边 → 虚实混搭,像重影;
3. 右下角人物和右对齐的页脚文案(社区规则 / 备案号 / 存活时长)挤在一起。
改法:只留左下角一组(sit 330 + wave 190,`left 0/bottom 0`),删掉 hero-fade,
纹样减到雪花 + 光环 + 两点(opacity .16、tile 340),卡片补 1px 冷调描边(box-shadow 不占布局)。
990px 以下缩小左移,900px 以下(卡片占满宽)不出人物。
⚠️ **`background-position` 的四值语法必须以边关键字开头**:写成 `max(8px, calc(50% - 555px)) bottom 0`
整条声明作废并回落到左上角(表现为人物跑到顶上、背景出现错位色带)。
要贴卡片边只能 `left max(8px, calc(50% - 555px)) bottom 0`。
### 纹样横向条纹(v=20261019o)
图案元素原先全在 tile 上半部(雪花 y=52 / 光环 62 / 点 150、176),铺开后在整页形成
**横向条纹带**(左栏 60px 带宽均值 245→237→249,跳变 12),这是"看着怪"的第二个来源。
改成 3 行均布(52 / 132 / 152-212)后收敛为单调 245→236。
⚠️ 图片不走 `StaticVersion` 缓存键(`Cache-Control: max-age=86400`),换图案要自己给 url 加 `?v=`。
### 定稿:半隐在卡片后的单人(v=20261019p)
用户从 A/B/C/D 四版里选了 D(半隐在卡片后),并追加两条:人物离开底边一点、纹样再淡一点。
- `sit` 单人,`left max(6px, calc(50% - 515px)) bottom 26px`;宽度 177px,右缘压进卡片 40px。
- **离底边后必须配接触阴影**:一层 `radial-gradient(ellipse, rgba(63,96,138,.22), transparent 72%)`
128×15px,垫在脚下一格(deco 第 2 个槽位,绘制顺序在人物之下)。没有它,抬高 26px 会读成悬空。
- 纹样 opacity 0.13→0.085(深色 0.09→0.055);`?v=20261019p` 同步改。
- 剩余素材(wave/cheer/think/doze)保留但不上页面,换姿态只改 `--tb-deco` 的 url。
### shiroko 按二次元官网重做:整页场景 + 悬浮磨砂 UI(v=20261019q)
用户反馈「还是很怪,去网上学一下布局」。实际去看站:
- 蔚蓝档案 JP 首页(bluearchive.jp,截图存 outputs/ref/bluearchive_jp_.png):**整屏一张场景插画,
logo/按钮/横幅浮在画面上**,没有"白卡片"这层东西;
- marshmallow-qa / peing 这类真正的提问箱服务被 Cloudflare 拦,拿不到参照。
结论:前几版都卡在"白底 + 留白摆人物"这个结构上,人物没有环境,怎么调位置都像贴纸。
改法:生成教室场景(ImageGen,1536×1024)→ 裁掉底部 160px 水印 → 混白 38% → 模糊 1.4px →
WebP 1920×1080(22KB)/ 720×1003 竖版(6KB);卡片与顶栏改 `rgba(255,255,255,.86)` +
`backdrop-filter: blur(16px)`;人物 340px 站在左侧地面、离底 36px + 椭圆接触阴影;浅色不再叠纹样。
深色仍用渐变 + pattern-dark(白天教室压在深底上会脏)。
⚠️ **`ruleBody` 必须剥 CSS 注释**:令牌前写一行注释时,`tokenValue` 按 ";" 切完再按 ":" 取键,
会拿到 `/* ... */ --tb-surface`,色块预览静默变空、`TestThemeCSSComplete` 报"缺少令牌"。
已加 `commentRe` + `TestRuleBodyIgnoresComments`。
⚠️ ImageGen 出的图右下角有 `AI生成 WORKBUDDY` 水印,用作整页背景必须裁掉。
## 23:40 人像换成「窗边背影」
- 用户否掉了盘腿坐的正面人像("和背景没那么突兀的")。根因:正面直视 + 无环境 + 饱和度高,
在压白 38% 的空教室里就是一块贴纸。
- 新素材 `chibi/window.webp`(135×460, 13KB):文生图出侧后方 3/4 视角(双手背在身后)→ 再
image-to-image 转"纯色块无描边 + 去窗框"→ 抠白底 → **RGB 压白 16%** → 高 460 / q86。
第一步文生图会把窗框画进背景,抠不干净,必须靠第二步重绘。
- CSS:`--tb-deco` 首槽换 window.webp,高 380→400px,接触阴影 96×13 复位到脚底;`StaticVersion` → `20261019r`
(图片另带 `?v=20261019r`)。删除 `sit.webp`;`docs/theme-shiroko.md` 补两步做法与摆位说明。
- 提交 `e95671f` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
-427
View File
@@ -1,427 +0,0 @@
# 2026-10-22
## new-sakura 主题:纯 CSS 樱花
用户要求「用编辑器生成 new-sakura,CSS 做樱花、每页位置不同、上传到已配好的 S3」。
- **结论:生成器做不了这个** —— 它的装饰层只支持图片素材(导出打 zip / 传对象存储),
没有内联 SVG 图层。所以改成手写主题 CSS(结构完全遵循生成器输出:令牌 + 10 槽 deco +
scope 覆盖),生成器只用来做视觉验证。
- 实现:`dev/theme-tools/gen_new_sakura.py` 生成 `static/css/themes/new-sakura.css`。
花/瓣/枝/底纹都是内联 SVG 的 data URI(`quote(svq, safe="")` 全编码,避免 URI 里的
逗号被 `TestDecoSlotCount` 当成分层符)。CSS 50KB(gzip ≈12KB)。
- 六个 scope 各一套 `--tb-deco` 位置;窄屏(≤640px)装饰全关、底纹缩到 200px;
深色换亮粉 + 关底纹(浅粉压深底=噪点)。
- 调参踩到的:①花枝 `auto 390px` 放右上角时可见部分只剩一团花 → 缩到 300px 并内移;
②枝上 5 朵 scale≈1 的花是一串糖葫芦 → 改成 8 朵 0.4~0.62;
③底纹 260px 双花双瓣读成「包装纸」→ 改成 300px 一朵花 + 两片瓣、各自错位。
- 对比度:primary 候选 `#c14a72` 只有 4.39:1,压深到 `#b8436a`(4.87);muted `#77626a`(5.27);
深色 `#ff93b4`(8.17)/`#ab9099`(5.82)。
- i18n 四份词典加 `theme.name.new-sakura`(插在 shiroko 之后,gofmt_keep_crlf 后每份只 +1 行);
`conf/app.sample.ini` 的 available 登记。
- ⚠️ 本机 Chrome/Edge 的 localStorage 里**没有**生成器的 S3 配置记录,项目也没有
`conf/app.ini` → 「已预先配置好的 S3」在本机取不到,上传这一步需要用户给凭据。
纯 CSS 主题本身零素材,严格来说 S3 无事可做(已向用户确认)。
**R2 自定义域可用 + 素材包一键上传(接上一条)**
- `r2.337845818.best` 实测公开可读(7 个对象全部 200、Content-Type 正确);桶的 S3 endpoint 仍 403。
- 樱花位图从 `gen_new_sakura.py` 的同一份 SVG 导出(`export_assets.py` 直接 import 它,不另画),
`--upload` 一次传 CSS + 5 张 webp + 生成的 INSTALL.txt(域名/体积写实数)。
- `serve.upload_files` 支持逐文件 `cacheControl`:原来硬编码 immutable,而
`INSTALL.txt` / 镜像 CSS 的键不带版本 → 改了看不到。现在位图 1 天、CSS 5 分钟、README 60 秒。
- ⚠️ **CF 边缘缓存不会因为重传而失效**:第一版以 immutable 传过,`cf-cache-status: HIT`、Age>800,
重传后拿到的仍是旧副本(带 `?cb=` 才是新内容)。清缓存只能去控制台按 URL 清。
- ⚠️ **gzip 纠错**:服务端 `internal/route/gzip.go` 会压 CSS,内联版 51KB 实际传输 **3.9KB**,
位图版 2.7KB + 5 张图 48.9KB ≈ 51.6KB。所以"换位图省 CSS 体积"是错的 ——
位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。
- `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。
## 线上 521 排障(box.shiroko.one 打不开)
- 现象:`https://box.shiroko.one` 全路径 **521**,同机 IP+端口正常。
- 实测链路:`box.shiroko.one` → CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 接入/优选,
上游 `source.cfnode.eu.org`),**不是** `<UUID>.cfargotunnel.com`。DNS/TLS 均正常(tls=0),
响应体 `error code: 521`,5 次请求全 LAX,0.6s 快速失败(RST 特征,非超时)。
- 对照实验有价值:`box.tama.guru` 走**同一个** CNAME 目标却正常 301 → 中转 zone 边缘健康;
它不回源,而 box.shiroko.one 要回源才 521 ⇒ 断点在「CF → 源站」这一段。
- `shiroko.one` / `www` / `tama.guru` apex 都 CNAME `navi-ew2.pages.dev`(CF Pages 导航站),与本站无关,
别拿它们的 200 当证据。
- 候选根因(待服务器侧确认):① cloudflared 断开(严格说 tunnel 无 connector 多为 502/1033);
② `scripts/edgeone-origin-whitelist.sh` 的 `REJECT tcp-reset` 拦掉 CF 直连回源——脚本注释里
「521」本就是白名单漏放的既有症状;③ 回源 IP 过期(服务器 IP 变动 / 第三方面板未更新)。
- 注意矛盾点:用户本地能连 IP+端口 ⇒ 白名单大概率没生效或不覆盖该端口 ⇒ 更偏向 ③/tunnel 侧。
**根因已确认(服务器实测)**
- `systemctl is-active cloudflared` = **inactive**,`pgrep` 无进程 → Tunnel 断。
journald 最后一条 2026-10-21T21:42:22Z(CST 10-22 05:42),此后无日志 ⇒ **停了约 7 小时**。
- 死前日志模式:`accept stream listener encountered a failure while serving` +
`failed to accept QUIC stream: timeout: no recent network activity` + `datagram handler
error="context canceled"` + `Retrying connection in up to 1s`,connIndex 2/3 反复 →
UDP 7844 QUIC 长时间不稳(美西 23.94.211.138)。**inactive 而非 failed ⇒ 是被正常 stop 的,
不是崩溃**,与用户「昨晚升级 nginx 到 1.30.x」的时间点吻合(升级流程连带停服)。
- 应用侧全好:`*:5877` 监听(tamabox pid),loopback 200。
- `iptables -S INPUT` 只有 `-i eth0 --dport 8118/5877 -j DROP`,**EO_IN / ipset EO_ORIGIN 都不存在**
⇒ EdgeOne 白名单脚本当前未生效。且 DROP 限定 `-i eth0`,网卡名若不叫 eth0 则规则空转。
- 关键推论:CF 一定走 tunnel 而非直连 5877(否则 DROP 空转应 200 而非 521)⇒ 521 完全由 tunnel 断解释。
**续:start 之后仍 521**
- 用户确认:**5877 只允许 Tailscale(100.84.113.84:5877)访问**,公网靠 iptables DROP 封掉。
⇒ 排除「CF 直连公网 5877」这条路径,CF 只能经 tunnel 回源。
- `systemctl start cloudflared` → `active`,`sleep 5` 后 curl 域名仍 **521**。
- 待分辨:① connector 尚未完成边缘注册(sleep 5 偏短);② 本机 tunnel 与 CF/SaaS 侧
`box.shiroko.one` 绑定的 origin(应为 `<uuid>.cfargotunnel.com`)不是同一个;
③ 该 hostname 的 Public Hostname / ingress 未指向 `http://127.0.0.1:5877`。
- 决定性隔离实验:临时 quick tunnel(`cloudflared tunnel --url http://127.0.0.1:5877`)
拿 trycloudflare 域名直测 —— 200 则应用+出站正常,问题在 CF 侧绑定。
**journald 取证(关键)**
- `protocol=quic`,`location=la01`。从 10-21 13:53:30 起就是死循环:
`INF Registered tunnel connection` → 约 3s 后 `ERR failed to accept QUIC stream:
timeout: no recent network activity` + `datagram handler error="context canceled"` +
`WRN failed to serve tunnel connection error="accept stream listener encountered a failure
while serving"` → `INF Retrying connection in up to 1s` → 再注册。connIndex 0/1/2 全部如此,
每 ~3 分钟一轮,一直持续到 10-21 21:42:40。
- ⇒ **UDP 7844 的 QUIC 在这台机器上基本不可用**(RackNerd 美西 → CF la01),这是历史顽疾,
不是本次新故障。根治手段 = `TUNNEL_TRANSPORT_PROTOCOL=http2`(走 TCP 443)。
- 🔴 但 12:56 `systemctl start` 之后 journal 里**一条新日志都没有**(最后仍是 10-21 21:42:40,
PID 626)⇒ 新进程疑似启动即退出/token 无效,`is-active=active` 可能是重启窗口里的瞬时值。
需 `systemctl status` 看 Restart 计数与 ExecMainStatus。
- 旁证:EdgeOne 那条备用通道同样不通(18080 无监听、EO_IN/ipset 均不存在)。
**结论定案**
- 重大纠正:cloudflared **从未死过**。`systemctl status` 显示 `active (running) since 2026-10-20 05:32:44`,
Main PID 626 存活 2 天+。前面「21:42 后无日志」不是进程退出,而是 QUIC 连接最后注册成功后没再报错
(只有 24h 一次的版本提醒)。所以「tunnel 断导致 521」这个假设是错的。
- 改 `TUNNEL_TRANSPORT_PROTOCOL=http2` 后:4 个 connector 全部 `Registered tunnel connection
... protocol=http2`,`SUMMARY: Environment is healthy. Cloudflared will use 'http2' as primary
protocol`,零错误 ⇒ **tunnel 侧完全健康**。QUIC 抖动问题也顺手解决了(该 drop-in 值得长期保留)。
- 但站点仍 521 ⇒ **问题 100% 在 CF 侧 origin/路由,与本机 tunnel 无关**。
- 公网实测 `23.94.211.138`:5877 / 18080 / 80 / 443 **全部 timeout**(不止 5877)。
⇒ 若第三方 SaaS 的 origin 填的是「公网 IP:5877」,CF 同样连不上 → 521。
这解释了「突然」:用户近期把 5877 收敛为仅 Tailscale(100.84.113.84)可达。
- 本机 tunnel 身份(从 token 解出,注意 token 已明文暴露需轮换):
tunnel id `ba34e35d-e517-498a-b2cd-7275c22e354e`,account tag `0c99c40b475a65b393abaca3ca91731b`。
- 修复方向:①(推荐)第三方面板把 origin 改成 `ba34e35d-...cfargotunnel.com`,并在
Zero Trust → Tunnels → 该 tunnel 的 Public Hostnames 补 `box.shiroko.one → http://127.0.0.1:5877`;
②(不推荐)放行 CF 回源网段到 5877,等于重新暴露源站。
- ⚠️ 安全 P0:tunnel token 在对话截图里明文出现,等同凭据泄露 → 必须 Refresh token 后重装服务。
**真凶:SaaS origin 指向被封的公网**
- SaaS 自定义主机名 `box.shiroko.one` 的「源服务器」= `box-direct.337845818.best`。
- 实测该 origin 域名 **解析到 Cloudflare 自己的 IP**(104.21.47.52 / 172.67.144.141,
`Server: cloudflare`,CF-RAY)⇒ 它是**橙云(proxied)记录**,不是灰云 A 记录。
`http://` 301(Always Use HTTPS)、`https://` **521** —— 它自己也回源失败。
- 源站 `23.94.211.138`(colocrossing,RackNerd 上游):22 / 80 / 443 / 5877 / 18080
**全部 timeout**。不只应用端口,连 SSH 都不通 ⇒ 该机对公网几乎无入站(仅白名单/Tailscale)。
- 结论链:CF 回源 `box-direct` → 该记录橙云 → 真实源站端口被封 → **521**。
所有共用 `-direct` origin 的站点同时 521 ⇒ 用户说的「cloudflared 构建的网站全部 521」
与 cloudflared 无关,是**公网入站收敛**这一个动作把整批站点打掉的。nginx 升级纯属时间巧合。
- 修复(保持「只允许组网」的安全目标):① Zero Trust → 该 tunnel → Public Hostnames 加
`box.shiroko.one → http://127.0.0.1:5877`;② SaaS 面板把每个自定义主机名的「源服务器」
从 `xxx-direct.337845818.best` 改为 `ba34e35d-e517-498a-b2cd-7275c22e354e.cfargotunnel.com`。
注意不能把 `-direct` 改成「灰云 CNAME 到 cfargotunnel」——tunnel 路由要求橙云,SaaS origin 又
要求非橙云,两者冲突,必须直接在 origin 字段填 cfargotunnel。
**⚠️ 纠正(官方文档)**
- 上一条的后半段判断错了。CF for SaaS 文档明确:custom origin **必须**是该账号下
**proxied(橙云)的 A / AAAA / CNAME** 记录,不能用 IP。
⇒ `box-direct.337845818.best` 是橙云,**格式上完全正确**,不是配置错误。
用 tunnel 的正确形态就是「橙云 CNAME → `<uuid>.cfargotunnel.com`」,所以那条路径依然成立,
只是理由不是「橙云错了」。
- 真正断点仍是:`23.94.211.138` 的 **443 从公网不可达**(实测 timeout)。
但用户贴的 `iptables -S INPUT` 里**只有 8118 / 5877 两条 DROP,没有 443**
⇒ 对 443 的封锁来自 iptables 之外(云厂商安全组 / 上游 / 其他链),或者 nginx 压根没监听 443。
- 我上一轮的探测命令 `grep ':(80|5877|18080)'` **漏了 443**,这是取证缺口,需要补
`ss -tlnp | grep -E ':(443|80|5877)'` + `systemctl is-active nginx`。
- 注意:SaaS 回源默认 Host = 原始自定义主机名(box.shiroko.one),而 tunnel 的 Public Hostname
只能从账号内 zone 选(shiroko.one 不在 CF)⇒ 走 tunnel 时必须用 Origin Rule / 源站主机头覆盖,
否则 tunnel 会 404。
**最终根因(用户自查确认):nginx 停了 11 小时**
- 时间线:`10-22 00:12:43 Started`(升级后)→ `02:31:10 Stopping...` →
`02:31:15 Stopping timed out. Terminating.` → `Failed with result 'timeout'` → `Stopped`
→ **中间 11 小时无任何记录** → `13:34:42` 用户手动 `systemctl start` 才起来。
- ⇒ 全站 521 的直接原因就是 nginx 不在 443 监听;CF 回源 `box-direct`(橙云 A → 23.94.211.138:443)
拿不到东西。cloudflared/tunnel 全程健康且**从未参与这条链路**。
- 重启后实测:`box.shiroko.one` 连续 3 次 200,`box-direct` 200,源站 443 OPEN。
- 教训(我的误判链,值得记住):排障时先确认「443 上跑的是什么、进程在不在」(`ss -tlnp` +
`systemctl is-active nginx`),再往 DNS/CDN/tunnel 方向挖。这次因为 CNAME 指向第三方 SaaS
+ cloudflared 恰好健康,被带了很远。
- 遗留技术债:
① `listen 443 ssl http2` 在 1.25.1+ 已废弃 → 改 `listen 443 ssl;` + `http2 on;`;
② `Failed to parse PID from file /run/nginx.pid: Invalid argument` —— 大概率是上次 SIGKILL
残留的 pid 文件,配合 `TimeoutStopSec` 过短(5s 就 Terminating)导致 stop 卡死;
③ 全站无可用性监控(11 小时空窗没人知道)→ 建议外部 uptime + cron watchdog
`*/5 * * * * systemctl is-active --quiet nginx || systemctl start nginx`。
**6) 窄屏覆盖被作用域块压死(真 bug,已修)**
- 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。
- 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]`
是 (0,2,1),媒体里写的 `html[data-theme-style]` 只有 (0,1,1) → 压不住;深色作用域块 (0,3,1) 更难。
- 修法:媒体查询里按「作用域通配 `[data-page-scope]`(0,2,1)」+「深色基础(0,2,1)」+
「深色+作用域(0,3,1)」各写一条,靠文件更晚取胜。shiroko 的 900px 块只对浅色有效,
但它的深色块本来就是 none,所以不算坏。
- 守门:新增 `internal/theme/narrow_test.go::TestNarrowMediaOverridesScopedDeco`
——外层每处非 none 的 `--tb-deco`,媒体查询里必须有「同属性名、属性数不少于它」的选择器;
变异验证过(删掉深色基础那条立刻 FAIL)。
- 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin
页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。
- 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。
## 站内公告功能(配置驱动)
- 新增 `[announcement]` 段(content/start/end),改完重启生效;判定全在服务端按 time.Now()。
- 改动:conf/announcement.go(解析+Active+单测)、conf.Init MapTo、keynorm sectionKeySets 登记(漏了会误报未知段)、
template.FuncMap 新增 `announcement`(返回安全 HTML,空串 falsy 配 `{{ with }}`)、
internal/template/announcement.go(切段转义→URL 包链接,不能「先转义再正则」否则 &#34; 被吞进 URL)、
header.html 导航下方插 `uk-container-xsmall` 容器包 `.tb-announce`、base.css 样式(StaticVersion → 20261022a)。
- 时间格式:`2006-01-02 15:04` / 带秒 / 纯日期 / RFC3339;裸时间按服务器本地时区(容器是 UTC),启动 INFO 打印解析结果。
- 时间写错只 WARN 关公告不阻断启动(与未知键口径一致);窗口左闭右开。
- 预览验证:TEST_PREVIEW=1 + 临时 test 设 conf.Announcement 渲染 question/list,chrome --headless=old 截图 OK(亮/暗都看过)。
- 公告间距修复:`.tb-announce` 自身的 margin-bottom 会被 UIkit 的
`.uk-container > :last-child { margin-bottom: 0 }` 清零 → 间距必须挂在容器类
`.tb-announce-wrap { margin-bottom: 20px }` 上。这是容器内末元素贴边的通用坑,
以后在 `.uk-container` 里加末位区块都绕这条。
- 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块,
而 GOSUMDB=off 会卡住校验 → 下载工具链那次要放开 GOSUMDB(goproxy.cn 会代理 sumdb)。
**7) 顶栏:实色 → 半透明磨砂(用户:不能全透明,以后别再来一次)**
- 线上 `https://box.shiroko.one/_/tamakyi` 实测:`--tb-navbar=#fdf6f8` 实色,顶栏带(y=20..80)
把右上花枝齐刷刷切一段;`--tb-navbar` 默认是 `--tb-surface`,彩色主题不覆盖就成一条近白横带。
- 第一版改成 `transparent` 被用户否掉:花枝完整了但顶栏"消失",导航没依托、文字压在花纹上。
- 最终:`rgba(253,246,248,0.72)` / `rgba(34,26,30,0.72)` + `backdrop-filter: blur(12px)`
(模糊只能写在规则里,令牌只是颜色)。0.72 有依据:文字 `#6d555d` 纯底 6.34:1,
最坏(深花瓣压顶栏后面)压平后 5.08:1。
- 花枝 `top` 从 -16px 下移到 26px:顶栏透明后 700~760px 宽度下枝头会压到「登录」(截图确认)。
- 守门:`internal/theme/navbar_test.go::TestNavbarNotFullyTransparent` 拦
`transparent` / `rgba(...,0)` / `#rrggbb00`,变异验证过。约定同步写进
`static/css/themes/_scaffold.css` 的令牌注释与 `topics/theme.md`。
- 手机底栏 `#tb-mobile-utils` 在 base.css 写死 `rgba(255,255,255,.92)`,彩色主题要自己调(本例改同色系 0.92)。
- ⚠️ `StaticVersion` → `20261022c`:主题 CSS 改了必须提版本号,否则浏览器拿着旧 css,
现象就是「改了但线上没变」。
- ⚠️ 跑 go 命令时**别加 `GOSUMDB=off`**:本机 go 1.25.5,go.mod 要 1.27.3,
`GOTOOLCHAIN=auto` 要校验 toolchain 模块,关掉 sumdb 会直接报 "verifying module"。
只设 `GOPROXY=https://goproxy.cn,direct GOFLAGS=-mod=mod` 即可(toolchain 已缓存)。
**8) 生成器新增「顶栏」面板:实色 / 半透明(磨砂)/ 全透明**
- 状态 `S.navbar = {mode, alpha, blur}`(默认 glass 0.72 / 12px),基色仍取「配色 → 顶栏」,
`navbarValue(p)` 统一算:solid → `rgb()`(先 flatten 压平旧工程里的 rgba)、glass → `rgba()`、
clear → `transparent`。模糊单独写 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则,
一条同时管深浅(选择器不带 data-theme)。
- **全透明只给预览**:`validate()` 直接拦(导出报错),与 `TestNavbarNotFullyTransparent` 同一条规矩。
面板上写明原因 —— 不然下一轮又会有人问「为什么不能导出」。
- 对比度读数按「压平后的顶栏」算(半透明时是顶栏压在底色上的结果),低于 4.5 变橙。
- 旧工程迁移:配色里塞过 `rgba(...,0.52)` 的,按 alpha 反推档位(1→solid,0→clear,其余→glass)。
- 自检 `?dump=navbar` 10 项;Playwright 端到端 17 项(含滑杆跟手、刷新后保持、迁移三例)。
- ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。
- ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。
- ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。
## 单题页「下一条未读」(箱主收件箱式导航)
- 新增 `db.Questions.NextUnread(ctx, userID, excludeID)`:未读 = `is_read=false AND answer=''`,
`created_at ASC` 取第一条,无则返回 `(nil, nil)`。正序=先来先处理,反复取即可清空积压。
- `POST /_/{domain}/{qid}/next-unread`(`question.NextUnread`):**先 SetRead 再跳**。
只跳不标记会「打转」—— 当前这条仍是未读,下一次取最早未读又把它选回来(2 条未读时最明显)。
- `PublishAnswer` 支持同表单第二个提交按钮 `name="next" value="1"`(「回答并下一条未读」)。
- 按钮位置:回答行 `[回答][回答并下一条未读]`(箱主+有下一条时);页脚与「返回提问箱」同一行
放 `下一条未读 ›`(POST 表单 + `uk-button-link`,因为要改状态)。
- `adminBackQuery(ctx)` 抽出(原 BackTo 逻辑),`NextQuery` 进模板 → 答案表单/next-unread 表单的
action 都带上 `?from=admin&sort=...`,跳转后续「返回」仍回带筛选的后台列表。
flamego `ctx.Query` 只读 URL query,所以 POST 必须把参数写在 action 里(不是隐藏域)。
- 预览:`templates/zz_preview_test.go` 新增 `writeQuestionOwnerPreviews`(未回答/已回答 × 深浅)。
⚠️ 预览 HTML 的 `base.css` 是 `/static/...` 绝对路径,file:// 打开**不含主题变量**(暗色看着像亮色);
要验暗色必须 `python -m http.server` 起本地服务走 http。移动端预览本身有横向溢出(waline 表情行所致),非本次改动。
- 移动端排版实测(关键数据):390px 视口下 `.tb-ans-row` 只有 **300px**(360px 视口 270px),
而「回答 90 + 回答并下一条未读 174 + 标记为已读 132 + 间距 20」= 416px,**收窄内边距也不够**(320px)。
→ 手机上一行放不下三个按钮,改成:第 1 行保留原「回答 … 标记为已读」,第 2 行「回答并下一条未读」整行宽。
实现:`@media(max-width:640px){ .tb-ans-actions{display:contents} .tb-ans-state{order:2;margin-left:auto}
.tb-ans-actions .uk-button{order:1} .tb-ans-actions .uk-button.tb-ans-next{order:3;flex:1 1 100%} }`
⚠️ `.tb-ans-actions .uk-button`(0,2,0)特异性高于 `.tb-ans-next`(0,1,0),必须再写一条同级选择器盖 order。
- 页脚两个次级操作之间加 `.tb-foot-sep`(`·`,浅色 #b0b6bd / 暗色 #5c6674),原来两个蓝色链接几乎连成一片。
- Chrome headless 在 Windows 上 `--window-size` < 500 无效(innerWidth 恒 500)——要测真·移动端布局,
用「父页面 + 宽 390 的 iframe」再取 `contentDocument` 量尺寸/截图,媒体查询才会按 iframe 宽度生效。
- 文案定为「回答并进入下一条未读」(en: Answer & open next unread / ja: 回答して次の未読を開く)。
实测宽度:该按钮 202px;661px 视口(桌面态最窄)行宽 526 ≥ 90+202+132+20=444,仍单行;
640px 断点两侧切换干净,无需再调。
## 近期新增功能审阅(10-18~10-22)
- 编译(s3 overlay 桩)+ conf/template/theme/route/security/templates 全测试通过,无 P0。
- 🔴 **P1 实测复现**:`NextQuery` 不含 `from=admin`,而 `adminBackQuery` 首行要求它
→ 「回答并下一条未读」/「下一条未读」POST 后筛选上下文全丢,「返回」掉回箱主页。
实测:`POST /answer?sort=new&status=unread` → `from="" backQuery=""`;
带 `?from=admin` 才返回 `sort=..`。修法:`adminBackQuery` 里 `backQuery.Set("from","admin")`,
并把 `PublishAnswer` 普通分支(question.go:279)也改用 `questionPath`。
- P2:公告单语言(四语站点);裸时间按 time.Local(容器 UTC)易偏 8h;
NextUnread 无 `(user_id,is_read,created_at)` 复合索引且忽略后台筛选;公告出现在 pixel 画板页
(xsmall 容器 vs 全宽画布);new-sakura 磨砂顶栏 backdrop-filter 低端机掉帧风险;
`conf/app.sample.ini:296` 注释行尾残留 `,shiroko`。
- 蜜罐仅做代码审阅(用户要求不进用户说明):无正常用户误封路径,前提是 `trust_proxy` 已开。
- 交付:`outputs/2026-10-新增功能审阅报告.md`、`outputs/2026-10-功能说明.md`(面向一般用户)。
## 修 P1:后台上下文丢失(已落地)
- `adminBackQuery` 改为 `url.Values{"from": {"admin"}}` 起手:返回串同时用于模板 action
与 `questionPath`,POST 回来才能重新认出来源(Flamego `ctx.Query` 只读 URL query)。
- `PublishAnswer` 末尾裸重定向 → `questionPath`,让「回答」这条也保留筛选。
- 新增 `route/question/admin_backquery_test.go` 两条用例;build + route/templates/conf/template 测试全绿。
- 副作用(可接受):`BackTo` 拼成 `/user/questions?from=admin&sort=..`,列表页不读 from。
- `question.go` 是 CRLF:改完跑 `python dev/gofmt_keep_crlf.py`,diff 只有 6 增 2 删。
## 修 P2:公告分语言 / 东八区 / 未读索引(已落地)
- **公告分语言**:新增 `content_zh_cn/_zh_tw/_en/_ja`,`content` 降级为默认回退;
`hasContent()` 改成「任一语言非空即开启」(只看 content 会把「只写英文」误判成未配置)。
模板函数签名变为 `announcement .LangCode`(header.html 同步),判定仍在服务端。
- **时区**:新增 `[announcement] timezone`,默认 `Asia/Shanghai`;`import _ "time/tzdata"`
保证容器无 zoneinfo 也能 LoadLocation。RFC3339 仍以自带偏移为准。
启动日志扩成 `zone/languages/start/end/active_now`,解析结果直接带 `+08:00`。
- **索引**:`idx_questions_user_unread_created (user_id, is_read, created_at)` 加进
`ensureFrequentlyUsedIndexes`;本机测试库 psql 实跑两次验证语法 + 幂等。
- 测试:conf 侧 7 组(语言回退/归一化/东八区/时区覆盖/无效时区)、
templates 侧 2 条渲染断言(zh-CN 与 en 只出现各自文案、窗口外不渲染 `tb-announce`)。
- 未改:`NextUnread` 仍不看后台筛选(产品取舍);画板页公告问题按用户确认忽略(画板已下线)。
## 测试站部署验证(box-dev-api)
- 结论:部署的确实是最新代码。判据:`base.css?v=20261022c`、CSS 含 `.tb-announce-wrap`、
`--tb-primary:#0e6fd0`、`themes/new-sakura.css` 含 `backdrop-filter`。
- **公告分语言在线上生效**:同一页面切 `/i18n/{zh-CN,zh-TW,en,ja}` 拿到四套不同文案,
链接渲染成 `<a target="_blank" rel="noopener noreferrer nofollow">`。
- 移动端(CDP `Emulation.setDeviceMetricsOverride` 390×844, mobile:true):
`scrollWidth == innerWidth == 390`,无横向溢出,公告块 right=390 贴容器边 —— 正常。
⚠️ 纯 `chrome --headless --screenshot --window-size=390` 的截图会让人误判「被切」,
真要量布局用 CDP + mobile:true,别只看图。
- 外部验证不到的三项:时区解析(看启动日志 `zone=Asia/Shanghai` + `+08:00`)、
复合索引(`SELECT indexname FROM pg_indexes WHERE tablename='questions'`)、
「下一条未读」(需箱主登录,且不能靠 POST 探测 —— 路由缺失会落到蜜罐返回 200 假登录页)。
- 偶发一次 CF 520(同 URL 复测 3 次均 200),未复现,非部署问题。
- 本机 curl 走 `http_proxy=127.0.0.1:58589`,node 直连本地调试端口要 `NO_PROXY=127.0.0.1`。
## 登录态实测(自建测试账号)→ 又抓到一个 P1 残留
- 账号:`tbunread<ts>@example.com` / TbUnread!2026(脚本 `Temp/tb_unread_check.py`,注册→发提问→登录→走流程→删题)。
- 🔴 **模板 URL 上下文会把 `?{{ .NextQuery }}` 整段 percent-encode**:
实测渲染出 `action=".../answer?from%3dadmin%26read%3dunread%26sort%3dnew"`,
`ctx.Query("from")` 永远为空 ⇒ 浏览器提交时 P1 修复失效。之前只测了 handler 层,没测渲染结果。
本地 html/template 实验确认:`action="{{ .A }}"`(值是完整 URL)→ 只把 `&` 转 `&amp;`;
`action="...?{{ .B }}"`(值是查询片段)→ `%3d`/`%26`。**以后 query 一律在 handler 拼完整 URL。**
- 修法:Questioner 里生成 `AnswerAction` / `NextUnreadAction`(完整 URL),模板直接引用;
守门 `templates/question_action_test.go`(断言无 `%3d`/`%26` 且有 `&amp;sort=new`)。
- 顺带确认(线上):单题页「回答并进入下一条未读」跳转、页脚「下一条未读」跳转、
全部读完的「没有未读提问了」提示 —— 三条行为都正确,`Location` 带 `from=admin&read=unread&sort=new`。
- 断言坑:页面上有 `/user/questions`(导航)和 `/user/questions?from=admin&amp;…`(页脚返回)两个链接,
用 `re.search` 取第一个会误判「返回丢参数」。要按 href 全量匹配。
## 部署后复验:14/14 通过(含「日志重复」排障)
- 复验结果:`answer?from=admin&amp;read=unread&amp;sort=new`(未被编码)、
回答并下一条 → 次早、页脚下一条 → 再下一条、读完后「没有未读提问了」、
下一条页「返回」= `/user/questions?from=admin&read=unread&sort=new`。数据已删净(0 条)。
- 🔴 **日志「一直重复同一个操作」的真相:是自检脚本,不是站点。**
两个原因叠加:① 补数据循环 `while len(ids) < 3` 没有上限、不校验 POST 结果;
② 每轮发的**内容完全相同**,被 `shared.DuplicateGuard`(同 IP + 相同内容防刷屏,
`route/question/page.go:587`)拦下 —— 返回 200 + 错误提示但不落库,脚本没读提示于是无限重发。
→ 泛化教训:跑端到端脚本时,注入的文本必须逐条不同;循环必须有 attempts 上限 + 「未增长即停」。
- `urllib` 偶发 `TimeoutError` 的根因是代理复用 stale keep-alive 连接:请求头加
`Connection: close` 后不再出现,比加重试更有效。
- 测试账号 `tbunread1022181904`(自建,密码不入库)。提问已全部删除,账号可留作后续自检。
## 测试账号存档(凭据不入库)
- 用户要求「记住别再忘」→ 凭据写在 `.workbuddy/local/test-accounts.md`,
`.gitignore` 已加 `/.workbuddy/local/`(明文密码不随仓库走)。
- 内容:`tbunread1022181904@example.com` / 域名 `tbunread1022181904` / 密码 + 登录四步
(`/login` 取 csrf → `/api/captcha/generate` 算数学题 → POST `g-recaptcha-response="<id>:<答案>"`)。
- 以后要跑登录态自检**先读这个文件复用账号**,不要再注册新的;`admin6` 仍只有域名、无密码。
# 2026-10-22
## shiroko 平涂极简肖像(参考用户给的图)
用户给了一张参考图:平涂零描边、纯黑圆角矩形眼、无嘴、腮红、头占满画面被裁边,
要求做同风格的白子,位置他自己放。
- 产出 3 张透明 WebP:`static/images/themes/shiroko/portraits/portrait-{front,tilt,close}.webp`
(907–1024 × 921,共 197KB),未接进 `--tb-deco`,留给用户自己摆。
- ⚠️ **ImageGen 的 `background: "transparent"` 依然不生效**:返回的是 RGB 无 alpha 通道(实测 mode=RGB)。
- ⚠️ **不能按白底抠**:白衬衫会一起被吃掉,闭合光环内部的白色 floodfill 也进不去(留下白斑)。
解法:提示词里把背景写成一个**具体纯色** `#E6EEF8`,抠图时按该色值算 alpha:
`alpha = clip((maxChannelDiff - 6) / 12)`,再 `MinFilter(3)` 收 1px。实测头发 alpha 全 255,
中间过渡只占 2.5–6%(全是边缘),不会被误抠。
- 水印在右下角("AI生成 WORKBUDDY"),统一裁掉底部 10%,切口平直,正好可以贴页面下沿。
- 提示词关键:no outlines / no gradients、眼睛写死 `solid black vertically elongated rounded rectangles`
(否则出白眼球+高光+睫毛)、无嘴要重复强调。3/4 侧脸那版还是画出了嘴唇,弃用。
## 分享卡「二维码下链接不对齐 + 长链接乱掉」(已定位,待批准落地)
- 根因在 `route/question/share.go` 的**字宽估算**:`charPixelWidth` 把 `unicode.Punct/Symbol`
一律按 1.0em 计,而 URL 里的 `: / . _ -` 在 Noto Sans SC 里只有 0.25~0.35em。
- 实测(NotoSansSC + 本机原型 `dev/tmpcard`):`https://box.shiroko.one/_/tamakyi` 15px 真实
227.9px、估算 352.5px(+55%);卡片可用宽仅 225px → 触发缩字号到 **9.57px**,实际只画 145px 宽,
且起点按估算算 → 整段左偏 **40px**。dev 域名 7.24px / 偏 39px;长域名 5.26px / 偏 34px。
- 纯中文不受影响(CJK 就是 1em,偏差 0);底部水印 `Powered by 狼的提问箱` 偏 9.8px。
- 正确测法:`Font.HMetric(scale).AdvanceWidth` + `Font.Kern` 累加,scale=size*64(DPI 72)。
实测与 `freetype.Context.DrawString` 返回笔位完全一致(227.88 vs 227.88)—— 以后居中文字都用它,
别再靠字符宽度系数猜。
- 只修测量还不够:等比缩字号没有下限也没有断行/省略兜底,长链接必然掉到 5px 以下。
- 另记:QR 用 `qr.Image(256)` 再 NearestNeighbor 到 165,41 模块 → 每模块 4.02px,模块宽窄不一;
`qr.Image(-k)` 按模块整数倍直接出图更锐(41*4=164)。
- 交付:`outputs/sharecard-link-compare-2026-10-22.png`(现状/修复后对照)。
**已落地(用户选方案 A:单行优先,放不下才拆 host/path)**
- `measureTextWidth` 改为 `HMetric.AdvanceWidth + Kern` 累加,字体不可用时才回退估算;
所有 `drawTextCentered` 自动吃到修正(链接、水印、提示语)。
- 新增 `displayURL`(去 `https://`)/ `layoutLink`(单行 15→13 → host+path 两行 13→11 →
host 中间省略 @11)/ `ellipsizeMiddle`;`LinkMaxWidth = QRCardW-40 = 230`。
- 卡片内容高度改为按行数算,两行时 startY 自动下移(281 ≤ 315,顶距 17)。
- 守门测试 `route/question/share_layout_test.go`:测量=笔位(0.01px 级)、限宽、
居中(按着墨边界 ±3px)、`TEST_PREVIEW=1` 出图。**变异验证过**:把 measure 换回估算,
三条用例全 FAIL(偏移 39/59px,且文字真的溢出卡片 615 < 660)。
- 删掉了临时原型 `dev/tmpcard`(结论已进测试与 outputs 图)。
- P1 未做(二维码模块整数倍、间距 4px 栅格、水印相对左侧内容区居中)—— 等用户看完效果再定。
- 已推送 main:`0054e50`(修复 + 测试 + go.mod 依赖转正)、`80e042d`(记忆/忽略规则),等测试环境部署复验。
**dev 部署后复验(box-dev-api)**
- `/_/tbunread1022181904/share-card` → **500「分享图生成失败:字体文件未配置或无法加载」**。
- 定位:不是代码回退。`static/fonts/` **从未入库**(`git log -- static/fonts` 空、`git ls-files` 无、
也没被 ignore),`static/fs.go` 的 embed 清单里也没有字体;`/static/fonts/*.otf` 走 HTTP 是 302 回首页。
生产能出图(200 jpeg)纯粹因为老环境手工放过字体。
- 代码侧补强(`d51629e`):`getFont` 去掉 `sync.Once`(失败不再永久缓存,补文件即生效)、
错误信息带「已尝试路径」、候选路径 = font_path → `static/fonts/*.{otf,ttf,ttc}` → 系统 CJK 字体;
`app.sample.ini` 注明必须自备。
- 顺带做了 P1 第一项:`generateQR` 按模块整数倍出图(41 模块 @165 → 每模块 4px / 符号 164px 居中),
头像内嵌改为按目标尺寸等比 + 2px 白边。新增 `TestGenerateQRIsModuleAligned`(扫描线游程必须是
模块宽度整数倍,变异验证过)与 `TestGenerateQRWithLogo`(中心头像、四角静默区纯白)。
- 待用户:在 dev 部署目录放字体(生产上有同一份)后我复验;Dockerfile 仍是旧模板
(module path 写成 NekoWheel/NekoBox、没 COPY static、golang:1.20 低于 go.mod 要求的 1.27.3),
需确认 dev 是否走容器部署。
**用户放好字体后复验(200 OK)**
- 实测着墨居中偏差:链接两行 −0.5 / 0.0px、提示语 −1.0px、底部水印 480.0(原偏 9.8px)——字宽测量修复确认生效。
- 单题卡要「已回答 + 非私密」才出图:提交的提问必须先回答,否则 403/302。
探针脚本 `.workbuddy/local/qcard_probe.py`(提问 → 登录 → 回答 → 拉图 → 删除,不入库)。
- **抓到上一版自己引入的回归**:`generateQR` 把 4 模块静默区画进了图里,49 模块挤进 165px
→ 符号只有 111px(3px/模块),比改造前的 135px 还小。960 卡缩到 480 宽时模块 1.5px,
gozxing 直接解不出。
修法(`b35c2a8`):`qr.DisableBorder = true`,图像只出符号,静默区交给白卡;
调用方按实际符号尺寸居中排版;单题卡 `QQRSize` 128 → 150(37 模块拿到 4px);卡内间距收到
24/20/20/16。profile 与单题卡二维码均 111 → 148px,gozxing 在 960 原图与 75% 缩放图上都能解出正确 URL。
- 结论性数字:**960×540 下 4px/模块是当前上限(profile 卡 37 模块 @148px,用满 90%)**;
缩到 480 宽仍解不出 → 真要抗微信二次压缩只能上 2x 渲染(1920×1080),1C1G 需实测 CPU。
- `dev/overlay.json` 原来指向 /tmp 的桩文件(早已失效),改成指向仓库内 `dev/s3stub/s3_stub.go`。
**第二轮复验(用户重新部署后)+ 换 2x 的替代方案**
- 部署确认:箱子卡 200,二维码 148px、左右留白 61/61、三处居中偏差 −0.5/0.0/−0.5px。
- 延迟别拿 HTTP 测:这台机器 **静态 CSS 的 TTFB 就 2.7s**(TLS+代理),分享卡 TTFB 2.4s
比静态资源还快,测不出服务端渲染耗时。要测就在本地跑基准。
- 本地基准(1C1G 上大约 ×3~5):1x 像素工作 ≈ 40ms(JPEG 12 + 背景缩放 25 + 画布 3),
2x ≈ 103ms;**二维码生成只有 0.8ms**,与尺寸无关 → 放大二维码不花 CPU,2x 贵在编码与缩放。
- 实验(37 模块 / 真实 55 字符链接 / 含头像,gozxing 逐宽度解码):4px/模块 480 宽就挂,
5px 能撑到 320。降纠错 Q→M 只省 11% 模块且容错 25%→15%,不划算;PNG 救不了缩放。
- 落地 `72f08cd`:`QRCodeSize` 165→190(→185px / 5px 模块)、`QRCardH` 315→335,
新增 `TestGenerateQRModuleSizeAtLeast5`(改回 165 立刻 FAIL)。单题卡仍是 150(4px),未动。
- 比 2x 更值得做的:进程内缓存只有 16 条 + TTL 1h,箱子多时冷渲染频繁 → 结果落盘/R2。
+77
View File
@@ -47,3 +47,80 @@
- 顺手修预览测试:`GrowthMoM` 原来写死 12.3,与 `monthly` 造的数据对不上,
而新句会同时印出两个月的条数 → 截图会显示「5620 比 4890 多了 12.3%」这种假证据。
改成从 `monthly[10]/monthly[9]` 算。
## 安全评估(AI 挖洞背景下的一次全量自检)
报告:`outputs/security-audit-2026-10-25.md`,脚本在 `outputs/sec-audit-2026-10-25/`。
结论:无 getshell / SQLi / 未授权读数据;但 2 个 P1 直击「匿名」承诺。
### P1-1 匿名提问可植入远程图片(去匿名化)
- `markdown.go` 的 `safeURL` 只校验协议,`<img src=https://任意域>` 能原样输出。
- 唯一挡住它的是审核「广告_引流类」维度,而该维度**只在 strict 档生效**
(`censor/policy.go`:standard 只有 Abuse/Contraband,lax 为空)。
- 实测:账号切 standard → `<img src="https://tb871870.example.org/standard.png">` 直接入库并渲染;
切回 strict 才被拒。→ 修法必须在 `markdown.go` 层限制 img src 为站内/白名单,不能依赖档位。
- 同时确认 8 种混淆(实体编码、`java\tscript:`、协议相对 `//`、反斜杠)都被审核识别。
### P1-2 访问密码可爆破 + 提问密码校验早于验证码
- `UpdatePagePassword`/`UpdateAskPassword` 只有 `maxlen:30`,**无 minlen**,可设 1 位。
- `page.go` 的 `New()` 第一件事就比对 AskPassword,验证码要到后面才校验 →
实测「错误提问密码 + `deadbeef:999999`」返回「提问密码错误」而不是验证码错误 →
**猜密码完全不消耗验证码**。限额 30/min(ask)、10/min(page)。
### 其余P2/P3
- 登录开放重定向:`to=/\evil.example` → `Location: /\evil.example`,浏览器按 WHATWG
把 `\` 当 `/` → `https://evil.example/`。`path.Clean` 不处理反斜杠。
- 注册接口枚举邮箱(返回「已注册」),与 forgot-password 的防枚举口径不一致。
- 无 CSP;`X-XSS-Protection: 1` 过时;`/user` 无 `Cache-Control: no-store`。
- 限流/自动封禁是进程内存态(多副本叠加、重启清零)。
- 匿名投票身份 = `vote_fp` cookie,清 cookie 即换身份,仅 200/min/IP 兜底。
- 登录 `to` 兜底是死代码:`path.Clean("/"+to)` 恒非空 → 登录后永远落 `/` 而非箱主页。
- JSON 响应无 `Content-Type`(实测 text/plain);429 无 `Retry-After`。
### 无法从外网确认的一项
`trust_proxy` 真实值。三种伪造头(XFF / X-Real-IP / EO-Client-IP)各 22 次都不被采信
(好的方向:无法伪造绕限流),但这既可能是 `trust_proxy=false`(→ 按 CF 边缘 IP 聚合,
限流与封禁会误伤整片区域),也可能是 true 且在 CF 侧删了 EO-Client-IP。需登服务器看 app.ini。
### 已验证没事(别重复怀疑)
CSRF 绑 uid(无 token POST → 400);18 条 XSS 载荷全灭;跨箱题目/投票越权全 302;
页面密码对主页/整箱分享图/单题分享图/API 四个出口都生效;pixel/service/ShareCard 无 SSRF;
`exec cwebp` 不接用户输入;SMTP 默认校验证书;`RegenerateID` + `pwd_ver`;TLS1.0/1.1 拒绝;
无 CORS 头;测试站蜜罐已开(`/.git/HEAD`、`/.env` 返回假后台)。
### 测试卫生
- 测试站账号 `tbunread1022181904`,脚本已把严格度恢复 strict、清空 page/ask 密码、
删除本轮 15 条测试提问(115–129)。下次复用前先确认这三项是干净的。
- 隔离 venv 里装了 `requests`(`~/.workbuddy/binaries/python/envs/default`),
比 urllib 顺手;`/api/captcha/generate` 限流 20/min,批量提交脚本必须带 429 重试。
## 安全评估(续):trust_proxy=true 引出的 P1-3
用户确认两套环境 `trust_proxy = true`。据此重做了 IP 采信那一块,发现比原来的推断严重:
### P1-3 双 CDN 下权威头优先次序反了
- `internal/shared/ip.go` 的 `ResolveClientIP` **无条件**先读 `CF-Connecting-IP`,再读 `EO-Client-IP`。
- 前提「权威头只能由 CDN 写入」只对 Cloudflare 成立:
- CF:强制覆盖 `CF-Connecting-IP`,客户端伪造直接 **403**(生产/测试站实测一致)。
- **EdgeOne:不剥离客户端传入的 `CF-Connecting-IP`**,`share.shiroko.one` 实测带该头返回 200
(响应头 `Server: edgeone makers` / `EO-LOG-UUID`)。
- 代码优先读 CF 那个头 → 走 EO 线路时客户端 IP 完全可控。后果:限流/IP 黑名单全失效,
且 `autoban.Record` 可被用来**栽赃任意 IP**(填别人的 IP 连打 10 次敏感路径 → 对方被封)。
- **今天打不通**:`box.shiroko.one` 境内递归(AliDNS DoH)只解析到 `cloudflare.182682.xyz` → 104.18.x/172.64.x,
文档里写的境内 `eo.dnse4.com` 线路已不在;`box.tama.guru` 301 跳新域名也走 CF。差一条 DNS 记录。
- 修法:权威头也套 `isTrustedProxy(remote)`(回环可用 cloudflared 本机场景),fail-closed;
EO 侧删 `CF-Connecting-IP`;日志同时记 `remote_addr` 与解析后 IP。
### 源站定位(外网已尽力,未找到)
- `box.shiroko.one` CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 优选,zone 不在我们手里,
其 Transform/WAF 规则无法自查)。
- certspotter 枚举 `*.shiroko.one` 11 个、`*.337845818.best` 19 个子域,逐个解析全部落
Cloudflare / 腾讯 EdgeOne / Vercel(216.198.79.x、64.29.17.x),**无一条直连记录**。
- `status.shiroko.one`、`share.shiroko.one` → 43.174.246.43/247.43,用 box 的 SNI 访问回 418 Teapot
→ 是 EO 边缘不是源站。
- 结论:源站隐藏目前成功,但「找不到」≠「不可达」,仍需服务器侧 `security-selfcheck.sh local` 确认
`listen = 127.0.0.1:5877`。
### 顺带修正
上一版报告曾把「三种伪造头都不生效」推断为 `trust_proxy` 可能为 false —— 那是错的解读:
无论 true/false,限流键都落在我自己的真实 IP 上,外部不可分辨。已在报告第四节改正。
+67
View File
@@ -0,0 +1,67 @@
# 2026-10-26
## 修复 P1-3:CDN 权威客户端头未校验来源
用户确认:生产/测试 `trust_proxy = true`,回源是 **cloudflared Tunnel 回本机**(对端 127.0.0.1);
境内 **EdgeOne 已取消**(实测期国内速度不理想),将来可能重新启用。
### 改动
- `internal/shared/ip.go`
- 新增 `headerSourceTrusted(remote)`:对端是回环 或 命中 `trusted_proxies` 才允许采信权威头。
- `ResolveClientIP` 把 `CF-Connecting-IP` / `EO-Client-IP` 读取整体收进该判断;公网对端忽略。
- 公网对端带权威头 → `sync.Once` 一条 WARN(`remote_addr` + `header_ip`),覆盖「伪造」与
「CDN 直连回源忘登记网段」两种情形。
- XFF / X-Real-IP 信任范围未变(仍只认 `trusted_proxies`,回环不隐含信任)。
- `internal/shared/ip_test.go`:原 4 个用例按新契约调整;新增
`RejectsForgedHeaderFromPublicPeer`(4 种伪造组合)`TrustedProxyHonorsCDNHeader`
`IPv6LoopbackHeader` `LoopbackDoesNotTrustXFF`;加 `setTrustedProxies` 助手(还原包级状态)。
- `conf/app.sample.ini`(CRLF,用脚本按行块改,未破坏行尾)+ `internal/conf/static.go` 注释同步。
### 验证
- 变异验证:把 `headerSourceTrusted(remote)` 临时改成 `true`(旧逻辑)→ 回归用例 3 个子项失败;
还原后全绿。确认测试咬得住。
- `go build -overlay dev/overlay.json ./...` 通过;排除 `internal/storage`(overlay 桩缺
`fitsWithin`/`aspect` 等辅助函数,属既有问题)后全包测试通过。
### 环境坑(值得记)
- 本机 `go` 是 1.25.5,`go.mod` 要 1.27.3;`GOSUMDB=off` 会让工具链校验失败(
"verifying module: checksum database disabled by GOSUMDB=off"),`GOTOOLCHAIN=local` 又降级到 1.25.5。
解法:直接调缓存里的工具链
`~/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,配 `GOTOOLCHAIN=local`。
- Python 读 CRLF 文件要显式 `newline=''`,否则 universal newline 把 `\r\n` 归一成 `\n`,
`split('\r\n')` 只会得到 1 行(踩过一次,误判文件被写坏)。
### 待办
P1-1(img src 白名单)、P1-2(密码 minlen + 验证码顺序)、P2-3(`to` 开放重定向)均未动。
## 修复 P1-1 / P1-2
### P1-1 外链图片(`internal/template/markdown.go`)
- 新增 `safeImageSrc()`:`<img src>` 只允许站内相对路径(无协议部分)或主机命中自有图片出口
(`image_bucket_cdn_host` / `aliyun_bucket_cdn_host` / `<bucket>.<endpoint>` / `external_url` 主机)。
- 含反斜杠一律拒(`\` 在浏览器等价 `/`);`//` 先于「以 / 开头=站内」判断;
`url.Parse` + `Hostname()` 比对 → 后缀 / userinfo 把戏失效。
- `img` 无合法 src 时**整段丢弃**(只删 src 会留 `<img alt>` 变成破图)。
- `a[href]` 仍走 `safeURL`(外链需点击,产品上要允许)。
- 测试:`TestSafeImageSrc`(12 条拒绝)+ `TestMarkdownToHTML_ImageSource`(端到端);
变异验证:`safeImageSrc` 直接 return true → 7 条拒绝用例失败。
### P1-2 密码(5 个文件)
- `minlen:6` + `form.PasswordMinLen`;handler 里 `rejectTooShortPassword()` 二次校验(rune 计数);
前端 `minlength=6 maxlength=30` + 提示文案「6–30 位,留空则关闭」;i18n 新增
`form.profile.passwordLen` / `question.errPasswordLocked`(4 语言)。
- 校验顺序:抽 `askPasswordDenied()`,从 `New()` 首行挪到**两个分支验证码之后**(私聊 + 普通提问各一次)。
没把验证码提到最前——会让「箱已关闭」「需登录」变成验证码错误。
- 失败锁定:新增 `shared.AttemptLimiter`(无后台 goroutine,惰性清理 + 键数上限);
`pagePasswordGuard(5, 10min, 10min)`,键 = 箱主ID + LimitKey(IP)。锁定期内不比对密码。
### 端到端验收脚本
`outputs/sec-audit-2026-10-25/verify_p1_fixes.py`(`--only p1-1,p1-2a,p1-2b,p1-2c`)。
**在未部署的旧版本上四项全 FAIL,已实测** → 证明检测有效;部署后应全 PASS。
踩到的两个判定坑(写脚本时记下来):
- 提问成功页是 **200**(不是 302),不能用状态码判断提交成功。
- 受保护的箱是**在 `/_/<domain>` 内联渲染密码表单(200)**,不是跳转 `/password`;
判断「有没有密码闸门」只能看页面里有没有 `action="/_/<domain>/password"`。
- 箱主页与 `/api/v1/user/<domain>/questions` 都**不暴露题目 id**(无后台),
要定位刚建的提问只能扫 `/_/<domain>/<id>`(脚本里 `question_ids_with_marker`)。
+92
View File
@@ -0,0 +1,92 @@
# 2026-10-27
## 在 WSL Debian 上把 TamaBox 跑起来,做 P1 修复的端到端复验
用户新搭了 WSL2 Debian,给了 `data/data.txt`(测试环境的 PG / Redis / OSS 凭据),要求「用它部署测试一下」。
**环境**(详细踩坑见 `topics/build.md` 的「本地部署」小节):
- `wsl.exe -d Debian -u root` 可免密进 root;apt 装了 `redis-server / python3 / python3-requests /
gcc / libwebp-dev`(有 libwebp 后 `s3.go` 正常编译,不用 s3stub overlay)。
- Go:`/opt/go` 装 1.27.3,但 `~/.bashrc` 里 `GOROOT=/usr/local/go`(1.26.3)→ 必须 `env -u GOROOT`。
- PG:新建 role `tamabox`(CREATEDB)+ 库 `tamabox`、`nekobox`(schema `nekobox`)。
- 服务:`/etc/systemd/system/tamabox-test.service`,源码在 `~/tb/src`,二进制 `~/tb/tamabox`,
日志 `~/tb/server.log`,端口 5877。配置 `~/tb/src/conf/app.ini`(`production=true`、
`external_url=http://127.0.0.1:5877`、`captcha_type=math`、`enable_text_censor=false`、`trust_proxy=true`)。
**验收结果(全 PASS)**:
- P1-1 外链图片剥离 / 白名单图片保留 / 实体与反斜杠变体;
- P1-2a 4 位密码被拒、6 位生效;P1-2b 验证码先于密码;P1-2c 6 次失败后正确密码仍被锁;
- P1-3 A/B:回环对端 + `CF-Connecting-IP: 1.2.3.4` → 命中黑名单 302;Windows 侧(非回环)同样头 → 忽略并 WARN;
- 回归:18 条 XSS 载荷无可执行/无外联、CSRF 无 token 400、越权全 302;
- 单测:`internal/{template,shared,form,conf,db,route}` + `route/{question,vote}` 全绿。
**这次揪出的新问题(已修)**:P1-1 的白名单把配置串整串当主机名,而真实配置是
`image_bucket_cdn_host = "r2.337845818.best/tamabox"`(host/path)→ 自家 R2 图片被一起剥掉。
修法:`normalizeHostLike()` 归一化(补协议 → url.Parse → Hostname),endpoint 主机自身也入白名单
(R2 path-style 链接是 `<endpoint>/<bucket>/…`);新增 `TestSafeImageSrc_RealConfiguredHosts`,
变异验证(退回旧逻辑 → 4 条放行用例变红)。**教训:白名单类单测必须照抄生产配置的写法,不能自己编一个好写的形态。**
**P2-3 从推断升级为实测**:`/login?to=/\evil.example` 登录后 `Location: /\evil.example`,
按 WHATWG 解析 = `http://evil.example/`;`\\evil.example` 同样外跳(`//`、`///` 已被规整)。
同一载荷在正文渲染里是安全的(`<a href="/\example.org">` 被剥成 `<a>`)。修复未做,等用户指示。
**产物**:
- 报告新增「六、本地部署实测」章节(`outputs/security-audit-2026-10-25.md`);
- 脚本新增 `verify_p1_allow.py`(正向放行)、`local_seed.py`(本地注册审计账号),
`audit.py` 改为 `TB_BASE/TB_EMAIL/TB_PWD/TB_DOMAIN` 可覆盖 + 验证码 429 退避。
**待办**:改动仍未 commit / push;P2-3、P2-4、P2-5、P2-6、P2-7 未动。
---
## 第二轮:修完 P2/P3 + 全局复查
用户:「把你发现的问题修了,然后全局检查一遍有无其他问题」。
**已修(P2-3~P2-8、P3-9~P3-15)**:都写进 `topics/sec.md` 的「第二轮修复」。要点:
跳转白名单收敛到 `shared.SafeRedirectTarget`(`url.Parse` 会解 `%0d%0a`,必须逐 rune 查 `u.Path`);
注册三条失败分支共用 `registerSucceeded`;CSP 三档 + umami 的 `mami.shiroko.one` 必须入 `script-src`;
`no-store`;`securityHeaders` 前移;封禁落库 `ban_records`;常量时间比较;页码上限;429 统一出口。
**全局复查新发现 3 个**(详见 `topics/sec.md`):
1. 🔴 `chat_handler.SendMessage` 的 POST 缺 `conv.OwnerID != pageUser.ID` —— 被拉黑者换 domain 即可继续骚扰
+ 触发邮件 + 绕过屏蔽词/日限额。已修,3/3 PASS,并做变异验证(去掉校验后对应用例变红)。
2. 🟡 页面密码 `next` 的反斜杠外跳(与 P2-3 同源)。
3. 🟡 分享卡无字体时每请求烧数秒 CPU(公开端点)→ 失败结果缓存 1 分钟,实测 3.44s → 0.53s。
**验收**:`verify_p1_fixes.py` 4/4、`verify_round2.py` 19/19、`verify_page_next.py` 6/6、
`verify_chat_owner.py` 3/3;单测全绿;全项目编译通过。测试数据已清(库里只剩 `tb-local@example.com`)。
**报告**:`outputs/security-audit-2026-10-25.md` 新增「七、第二轮修复与全局复查」。
**再待办**:仍未 commit / push(26 改 + 9 新);CSP 是否切 `enforce`;生产机补 glyf 轮廓字体。
**收尾复跑发现两个「假红」**(已写进 `topics/sec.md`):① 不给 `TB_BASE` 时脚本打的是远端
box-dev-api,没部署修复所以 4/4 FAIL;② 页面密码入口限流 10 次/分钟 + p1-2c 的 10 分钟锁定
会让 `verify_page_next.py` 后两项 429。顺手把该脚本判据从「Location 不含 evil」改成精确比对
`/_/<domain>`、每条载荷开新会话、429 单独报错。四套最终:4/4、19/19、6/6、3/3。库已清回只剩 `tb-local`。
---
## 第三轮:用户「按最优解决定」→ CSP 切 enforce + 字体落地 + 提交
**CSP 默认改 enforce**(`cspMode()` 空值 = enforce;写错值仍退 report)。依据不是猜:
写了 `outputs/sec-audit-2026-10-25/csp_sweep.py` + `csp_sweep.cjs`(Playwright + 本机 Chrome,
监听 `securitypolicyviolation`),report 模式先测一遍、enforce 再测一遍,**18 页面 + 2 图片端点零违规**。
巡检器自己做了变异自检(页面里主动 `fetch("http://csp-selftest.example/x")` → 确实报 connect-src),
否则「0 违规」可能只是监听器没挂上。
**补的漏项**:`browser.sentry-cdn.com` —— header.html 里 Sentry 在 `{{ if ne SentryDSN "" }}` 里,
本地没配 DSN 所以巡检碰不到,线上一开就会静默失效;现在按 `conf.App.SentryDSN` 条件放行 + 单测双向断言。
**分享卡字体**:补 glyf 系统候选(wqy-zenhei / wqy-microhei / DroidSansFallback / uming)并压到
Noto(CFF) 之前;`static/fonts` 同时按 CWD 和可执行文件目录找;`CheckShareCardFont()` 在 runWeb 里
启动即探(命中 Info 带路径,缺失 WARN 给 apt 命令)。实测 `apt install fonts-wqy-zenhei` 后
零配置即 `font=…wqy-zenhei.ttc`,`/share-card` 与单题分享卡从 500 → `200 image/jpeg`,
中文/二维码正常(图存 `outputs/sec-audit-2026-10-25/sharecard-*.jpg`)。
**提交**:三个 commit(7802bb2 安全修复 / e807dea 字体 / 430c6ef 记忆)。**push 失败**:
`git.shiroko.one` 的 https 存取凭据被拒(`Failed to authenticate user`),SSH 路(`id_ed25519_wsl`)
也未被该仓库授权 —— 需要用户更新 token 后自己 push。
**全量单测**:带 `DB_TYPE=postgres DB_USER=tamabox DB_PASSWORD=… DB_HOST=127.0.0.1` 全绿;
不带 DB 变量时一批 DB 门控用例会报 `Unknown database type: ""`(不是回归)。
+381
View File
@@ -0,0 +1,381 @@
# 2026-10-29
## 第二轮综合复查(非安全向)
用户要求「再确认还存在什么问题与风险点」+「从用户角度有什么值得改进和增加的功能」。
前一轮是安全专项(P1/P2/P3 全修完),这轮换到**业务正确性 / 运维 / 前端体验 / 产品能力**四个面。
产出:`outputs/review-2026-10-29.md`(分级清单 + 复现方式 + 产品建议)。
### 实测复现的两条 P0
1. **私聊 50 条截断**:`GetByConversationID` 是 `Order("created_at ASC") + Limit(50)`,
唯一调用方永远传 nil cursor;会话上限 200 → 第 51 条起**写库成功但永久不可见**。
本地造 55 条实测:页面只渲染 #1–#50。这不是性能问题,是「数据在库里但用户看不到」。
2. **访问日志泄露 token**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
Logger 记 `RequestURI`(含 query)。实测日志出现 `path=/_/tblocal/4?t=SECRETTOKEN123`。
`?t=` 是私密题钥匙、`?code=` 是改密凭证。**tracing 中间件早就为同一风险避开了 RequestURI
并写了注释,框架默认 Logger 漏了。**
### 代码确认的 P1
- **无优雅关闭**:全仓 `signal.Notify`/`Shutdown` 零命中,SIGTERM 直接杀进程 → 每次重启掐断在途 POST。
- **提问入库后图片失败不回滚**(`page.go:660` → `:680`),叠加 DuplicateGuard 5 分钟 → 用户以为没发出、5 分钟内不能重提。
- **Redis 两种故障**:session 在 Redis 上宕机即全站 500(flamego session load 直接 panic);
`cache.Cacher` **没有** `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退到 localhost:6379 → 没配 Redis 时找回密码静默坏掉。
- **处置不覆盖投票**:提问链路 7 处 `ActiveLevel`,投票链路 0 处。L4 处置后 `/_/domain` 已 302 到 /restricted,
但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
### 产品缺口(重要,别忘)
- **完全没有访问量数据**:`SiteAnalytics` 是全站聚合,无 per-user 维度,也没有 page view 表。
箱主无法知道自己被看了多少次。
- **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联;全仓 `localStorage` 零命中。
→ 建议前端用 localStorage 记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
- `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
- 模板零 `application/ld+json`(SEO 富摘要缺失),无 RSS。
- 图片原图直传(无前端压缩),配合 ReadTimeout 30s → 手机端 8MB 图必超时。
### 顺带确认的「不用动」
四语言字典 917 条完全对齐(`TestDictionaryComplete` PASS)、深色模式正文对比度 4.8–6.8:1 达标、
gzip 实现质量高、后台移动端短标签方案做得细。
### 已修(同日,六项全部端到端验证)
| # | 改动 | 验证 |
|---|---|---|
| 1 | `GetByConversationID` 改 `id DESC` 取最新 + 内存反转,limit 取 `conf.Limits.ChatMaxMessages`;游标配 DESC | 55 条会话实测渲染 55 条(改前 50);单测 3 项 + 变异(改回 ASC 变红) |
| 2 | 弃 `flamego.Classic()`,`flamego.New()` + 自写 `accessLogMiddleware`(只记 `URL.Path`;静态成功不记、4xx/5xx 记) | 日志 `msg=access path=/_/tblocal/4`,token 计数 0 |
| 3 | `signal.NotifyContext` + `srv.Shutdown(15s)` + 关连接池 | 8 个并发分享图请求中 SIGTERM → 8/8 成功 |
| 4 | 图片失败分支 `Questions.DeleteByID` 回滚 | 6MB 图 → 提问增量 0 且 `deleted_at` 非空;无图对照增量 1 |
| 5 | `cache.Cacher` 加 `if conf.Redis.Addr != ""`(否则 MemoryIniter);启动 `PingRedis` fail-fast | 停 Redis 后启动立即失败并指向 Redis |
| 6 | 投票 Page/Submit 加 L4、VotePublish 加 L2、探索页用 `ActiveLevels` 批量过滤 | L4 → `/vote` 302 到 /restricted;探索页该投票 0→2(撤销后);L2 下发布停 draft,撤销后 published |
新增 `vote.err.restrictedPublish` 四语言 key。回归:4/4、19/19、6/6、3/3,全量单测 + `go build ./...` 通过。
**踩到的两个坑**(值得记):
- 消息加载的兜底写成 `limit < 50 → 50`,会把箱主显式配置的小上限抬大,测试直接测出这个语义错误。
兜底只该处理**零值**(未初始化),不该替用户改配置。
- Python 的 **bytes 字面量不支持 `\uXXXX`**(只有 str 支持),写字面量 `b'...\uff08'` 会原样写入
`\uff08` 六个字符。CRLF 文件的字节级替换要用 `\xef\xbc\x88` 或 `.encode()`。
### 第二批(P2 + 高价值 P3,Docker 按用户要求跳过)
| # | 改动 | 验证 |
|---|---|---|
| 7 | ReadTimeout 30s→120s(8MB 图需 ≥68KB/s;ReadHeaderTimeout 仍 10s 防 slowloris) | 参数修正 |
| 8 | 投票结果改 stale-while-revalidate:过期先返旧值 + 后台按投票去重重算;Submit 从「删缓存」改「标记过期」(新增 `cache.Expire` / `GetWithExpiry`) | 2 万条:冷启 0.211s → 过期首读 0.023s(9.2x) |
| 9 | 三处幂等:`MarkDeletedByOwner` 条件更新+RowsAffected、`SetPinnedIfCurrent`、追问「上一条已回复」移进事务 | DB 测试 3 项全过 |
| 10 | 私聊首条三写入合并为一个事务(`CreateWithFirstMessage`) | message_count 与实际消息数一致 |
| 11 | `/healthz` + `/readyz`(DB ping 失败 503) | 停 PG 后 healthz 200 / readyz 503 |
| 12 | 验证码失败改同请求渲染,不再丢正文 | 200 + 正文回填 + 不建提问 |
| 13 | `tbBeginSubmit()` 抽出,reCAPTCHA 回调补防重复 | 静态验证(7 个模板受益) |
| 14 | waline 按页加载、`loading="lazy"`、safe-area、三处硬编码接字典(+4 key×4 语言) | /login 无 waline;en 页面零中文残留 |
| 15 | 5 处裸类型断言改带 ok | 编译 + 单测 |
新增 `verify_round3.py`(6 项)。第二批回归:6/6、4/4、19/19、6/6、3/3,全量单测 + build 通过。
**判据随实现更新**:`verify_p1_fixes.py` 的 p1-2b 原来用「是否 302」判断验证码先于密码,
第 12 项改成 200 后判据失效 → 改为「200 且页面报『验证码错误』」。语义不变,判据跟着渲染方式走。
### 这次踩的第三个坑(重要,我自己造成的)
**Windows 上 `Path.write_text()` 默认把 `\n` 转成 `\r\n`**,用它改 LF 文件会让整个文件行尾翻车。
- 症状:`gofmt -l` 突然把整文件标为未格式化。
- 后果:`git diff --stat` 会显示整文件重写。
- 正确写法:`path.write_text(s, encoding="utf-8", newline="\n")`,或用 bytes。
- **排查手段**:`git diff --stat` 是判断行尾污染最快的信号 —— 真实改动应该只有几十行,
整文件重写一定是行尾变了。恢复方式:`git show HEAD:<file>` 取其行尾,再统一替换回来。
另:本项目 `core.autocrlf=true`,仓库里存 LF、工作区 checkout 成 CRLF,
所以「工作区看到 CRLF」不等于「仓库里是 CRLF」,判断基准要看 `git show`。
---
## 第三批(数据安全 + 产品缺口)
1. **删除投票 / 删除提问改成一个事务 + 真删(`Unscoped`)**。
原来四步独立写入、前几步失败只 Warn → 中途失败留下「选项没了票还在」这类
界面修不好的状态,重试时投票已不存在、只能报 not exist。
更关键的是:所有模型都带 `dbutil.Model` 的软删除,原来全是软删 ——
提交记录里的参与者文本、`voter_ip`、`voter_token`、提问里的 `FromIP`
只是「界面上看不见」,库里永久留着。用户点删除的预期是删掉。
2. **配额与内容同事务**:抽出 `incrementQuotaTx(tx, userID, feature, delta)`,
提问 `Questions.Create`、私聊 `CreateWithFirstMessage`、投票提交
`VoteSubmissions.Create`(新增 `QuotaUserID` 字段)都在自己的事务里 +1。
原来 handler 里独立写入且失败只 Warn,`quota_count` 会永久偏低。
3. **追问改回答不再重复发邮件**:`UpdateAnswerByID` 返回 `wasFirst`(事务内读旧值判定),
handler 只在首次回答时发。原来改三遍措辞就发三封。
4. `isQuestioner` 的 token 比较改 `subtle.ConstantTimeCompare`(与 Pager 中间件同口径);
CSV 导出的**表头与选项列**补 `sanitizeCSVCell`(原来只有数据列走它)。
5. 写路径功能开关改 **fail-closed**:新增 `db.FeatureStateOfStrict`,
读配置失败时拒绝而不是回退默认「开启」。
6. **真 404 + HTML 500**:新增 `internal/route/errorpage.go`,`f.NotFound` 不再 302 到首页,
改 `writeErrorPage(ctx, 404, ...)`;`flamego.Recovery()` 换成 `recoveryMiddleware()`。
7. **提问者找回自己的提问**:成功页写 localStorage,箱主页显示提示条,
新增 `POST /api/v1/ask-status`(批量问「哪些已被回答」)。
### 这批踩到 / 学到的
- **flamego 的 `f.NotFound` 拿不到组级中间件**:`template.Templater` 与 `Contexter`
(含 session、CSRF)都挂在 `f.Group("", ...)` 上,NotFound 不经过它们。
所以在 NotFound 里注入 `template.Data` 会直接 panic:
`unable to invoke the 8th handler: value not found for type template.Data`。
错误页最终用**内联 HTML + i18n.T**(`pickErrorPageLang` 从 Accept-Language 猜语言),
不依赖模板系统 —— 错误页本来是最后一道防线,不该依赖可能出错的链路。
- **`gofmt` 会把注释里的列表规范化**:`// 1) ...` 会被改写成 `// 1. ...`。
自己写的中文注释列表如果用了 `1) 2) 3)`,`gofmt -l` 会标为未格式化 —— 不是行尾问题。
- **`Select(...)` 漏列 = 字段静默为空**:`ListByIDsAndUser` 一开始只选了
id/token/answer/created_at,摘要永远是空串,接口 12 项里就这一项红。
- **配额口径要按功能分别确认**:投票的定量收集是「累计收到的票数」(在提交成功时 +1),
不是「创建了几个投票」。所以我一度在删除投票里加了 -1,那是错的 —— 删投票不代表票没发生过。
- **CDP 验证纯前端功能的套路**(`verify_ask_history_ui.mjs`):
`chrome --headless=new --remote-debugging-port=9222`(必须作为独立后台进程跑,
跟在别的命令后面 `&` 会随 shell 退出被回收)→ node 用内置 `WebSocket` 连
`/json/list` 里任一 target → `Runtime.evaluate` 注入 localStorage → 重新 navigate →
断言 DOM → `Page.captureScreenshot`。注意 headless=new 的空标签页 `type` 常是 `other`,
不能只找 `type === "page"`。
- **控制台错误要区分来源**:页面里 R2 图片的 CORS 报错是既有问题,
把它算进「本次改动失败」只会得到假阳性。判据要按 URL/关键字过滤。
### 待办(仍未做)
- AutoMigrate 无版本化(改列名会静默丢数据)
- 无备份脚本(图片全在对象存储,只备份 DB 恢复后全是裂图)
- 32 个 `<label>` 无 `for=`、语义标签对比度 1.9:1
- 箱主数据面板(全站无 page view 数据)、Telegram/Webhook 通知、JSON-LD + RSS、图片前端压缩
## 箱主数据面板 + 图片前端压缩(本轮完成)
把「待办」里的两项落地,其余(AutoMigrate 版本化、备份脚本、label/对比度、Telegram/Webhook、JSON-LD)仍未做。
### 箱主数据面板(/user/insights)
- 新表 `box_views`(`internal/db/box_views.go`):一行 = 一访客×一天×一箱,唯一索引
`(user_id, viewed_on, visitor)`,`views` 累加。**刻意不内嵌 `dbutil.Model`**——
软删除 + 唯一索引会让被删行永久占槽(老坑)。
- 访客标识 = `HMAC(日盐, IP)` 前 32 hex(`internal/insights/recorder.go`):同日可去重(UV 准)、
跨日不可关联(隐私)。代价:近 7/30 天 UV = 各日 UV 之和,文案里已写明。
- 异步记录器:buffered channel 512 + 2s flush,构造即启动 goroutine(避免 Close/Run 竞态),
队列满即丢(统计尽力而为)。过滤:箱主本人、空 UA、爬虫 UA 子串、预取头(Sec-Purpose 等)。
- 聚合层 `internal/db/box_analytics.go`:BoxTrend(提问/回答按日)、BoxHours(24h 分布)、
BoxFunnel(已回答/已读未答/未读 + 回答率 + 24h 回复占比)。复用 analyticsDialectFor 的时区处理。
- 展示层 `internal/insights/page.go`:30 天 PV/UV 双柱趋势(CSS 高度百分比)、时段分布、
漏斗 + 回答率进度条、自动结论(`FormatInsights`)。**百分比整数值不带 .0**(formatPct)。
- 打点:`route/question/page.go` 的 Pager 末尾(所有错误分支/密码页已 return 之后),
`!ctx.IsLogged || ctx.User.ID != pageUser.ID` 才记。
- 挂载:`/user/insights` 在 reqUserSignIn 组内;`pageScope` 加 `/user/insights → admin`。
- 密钥复用 `server.xsrf_key`;`StartMaintenance` 每日清 400 天前的明细。
- 测试:`internal/db/box_views_test.go`(4 项,含「同批同键先内存合并」——PG 的 ON CONFLICT
不允许同条 INSERT 二次命中同一行)、`internal/insights/page_test.go`(pct/窗口切分/visitorID/爬虫过滤)。
### 图片前端压缩(templates/base/footer.html)
- 选图即压:canvas → WebP(长边 1600、质量 0.8,与服务端 GeneralEncodeSpec 对齐)。
- 三条边界:GIF/SVG 跳过(canvas 会把 GIF 变静止首帧)、压完不更小退回原图(<85% 才采用)、
透明 PNG 走 WebP(不支持 WebP 编码时退回 PNG 而非 JPEG,否则透明变黑)。
- `createImageBitmap(..., {imageOrientation:'from-image'})` 摆正 EXIF,老浏览器回退不带选项。
- submit capture 兜底:选图立刻提交时 `preventDefault` + `Promise.all(压缩)` + `form.submit()`,
避免二次触发防重复。
- 实测(CDP + 自写 WebSocket 客户端 `dev/_cdp.py`):4.6MB PNG → 11.7KB WebP(0.3%),
小图原样保留、GIF 跳过,三断言全过。
### 踩坑(本轮新)
- **同步脚本把 WSL 的 `conf/app.ini` 删了**:Windows 侧用 `tar | wsl rsync --delete` 同步源码,
目标机那份含 DB 密码/对象存储密钥的 app.ini 不在源目录里,被当多余文件删掉 → 服务重启起不来。
修:`dev/sync-wsl.sh` 里 `rsync --exclude "conf/app.ini"`,模板放 `dev/wsl-app.ini`(已 gitignore)。
- **配置丢了怎么找回**:进程内存 dump(`/proc/PID/mem` 扫描 `key = "value"` 模式)能捞出启动时
加载的全部 ini 键值(salt/xsrf_key/上传密钥/DB DSN 都在),配回 `conf/app.ini` 即可。
- **headless 里 canvas 编码大图超时是假象**:Windows Chrome 的 `--dump-dom` + `--virtual-time-budget`
会把 3000×2000 噪声图压得解码超时,`#out` 永远停在 running。换 WSL 内 `apt install chromium`
走 CDP 轮询(`Runtime.evaluate` 非 await 反复读结果)才拿到真值。
- **自写 CDP 的 WebSocket 客户端必须处理 continuation 分片**:CDP 大事件(console/DOM 属性)会
把一帧拆成多段,按「一帧=一消息」解析会让缓冲区永久错位,evaluate 永远等不到响应。修了 `_cdp.py`。
- **awaitPromise 长轮询会挂死自写 WS 客户端**:验证异步结果改用「navigate + 非 await 轮询读 DOM」。
## 全盘复查(数据面板/图片压缩之后的第二轮)
用户要求「再做一次全盘复查」。两个并行子代理扫了本轮新增代码(数据面板、图片压缩、
删除事务化、错误页、找回提问),交叉核对后确认的问题与处置:
### 已修(本轮)
1. **P1 提问真删不彻底**(`questions.go` DeleteByID 对主表 Unscoped,但追问/图片仍软删):
- `follow_ups.go DeleteByQuestionID`、`upload_images.go DeleteByQuestionID` 改 Unscoped 真删。
- 关联表 UploadImageQuestion 无 DeletedAt,无需 Unscoped(已注释)。
- 新增 `internal/db/question_delete_hard_test.go`:用 Unscoped 计数断言删除后追问/图片/关联行
物理上=0。**变异验证**:把追问改回软删,测试失败(expected 0 actual 1)。
2. **P1 BoxTrend 每日回答数按 created_at 分组**(应为 answered_at):`box_analytics.go` 加 answerDay
表达式(PG 用 `to_char(COALESCE(answered_at,updated_at) AT TIME ZONE ...)`,MySQL 用 CONVERT_TZ),
WHERE/GROUP/SELECT 三者口径一致。新增 `internal/db/box_analytics_test.go`:构造「40 天前创建、
昨天回答」的提问,断言计入昨天。**变异验证**:改回 d.day 后测试失败。
3. **P2 ask-status domain 存在性可枚举**:`ErrUserNotExists` 分支原返回空数组、正常分支返回带元素数组,
凭空/非空可枚举 domain。改为箱主不存在时也回显 `ok:false`(与「存在但 token 错」完全同形)。
4. **P2 私聊 SendMessage 写路径用 FeatureStateOf**(fail-open 变体):改 FeatureStateOfStrict,读配置失败即拒。
(此前靠「chat 默认关闭」巧合侥幸安全,现在按写路径约定统一。)
5. **P2 图片压缩 submit 兜底可重复提交**:capture 监听器里 tbBeginSubmit 从 Promise 回调提前到
preventDefault 的同步栈,物理阻止压缩窗口内双击重复提交。
### 判定不修(子代理误报,或有意设计)
- **投票删除不回退配额**:子代理当 bug,但这是**有意为之**——投票配额口径是「累计收到的票数」,
删投票不代表票没发生过(记忆里早记过:一度加 -1 是错的)。注释已写清。
- **recomputeHeights 里 `d.UV > maxPV` 死代码**:UV≤PV 恒成立,该行永不触发,但当前「PV/UV 同刻度」
恰好是设计意图,不算错。
- **TopDays 死代码**:`BoxViewsStore.TopDays` 无调用方,best-day 只回看 30 天。属口径/注释自相矛盾,
但不改数据安全,留待后续。
### 踩坑(本轮新)
- **gofmt -l 把「缺末尾换行」也标为未格式化**:Edit 工具写文件时可能丢末尾换行符,`gofmt -l`
会报 `\ No newline at end of file`。与行尾问题不同,补一个 `\n` 即可。
- **struct 字段对齐**:手写 struct 时字段没对齐(`Questions int64` 少一个空格),gofmt 会标。
用 `dev/gofmt_keep_crlf.py` 统一修。
- **CSRF token 登录后会轮换**:flamego/csrf 在登录成功后轮换 token(防固定),所以登录前 GET /login
拿到的 token 在登录后失效,POST 接口测试要登录后再从带表单的页面重新取。这导致 ask-status 的
真实 HTTP 验证没跑通(靠代码 diff 确认修复正确)。
- **`git show HEAD:file` 输出写到 /tmp 后 Windows Python 读不到**:/tmp 是 Git Bash 的虚拟路径,
Windows Python 看不到。要落盘验证行尾,写到仓库内(如 outputs/)再读。
## 三项待办落地:AutoMigrate 版本化 + 备份脚本 + Telegram/Webhook 通知
用户「先按1-2-3的顺序做」,期间两次纠正 R2 存储结构认知。
### 1. AutoMigrate 版本化迁移(internal/db/migrations.go)
- 新增 `schema_migrations` 表(version 主键 + name + applied_at),`runMigrations(db)` 按版本升序
执行未应用的迁移,每个迁移在事务里「跑 up + 记版本」。
- 把 db.Init 里散落的 5 个幂等补丁收编为版本迁移 v1~v5:
backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled /
normalizeChatQuota / normalizeHarassmentSetting。收编后**只跑一次**(原来每次启动都跑)。
- AutoMigrate 仍负责「加表/加列/加索引」(声明式、幂等、无数据丢失),版本迁移负责
AutoMigrate 做不了的破坏性变更(改列名/删列)与一次性回填。两者分工,互不替代。
- 迁移失败阻断启动(半途而废比静默跳过更危险)。
- 测试:migrations_test.go(幂等跑两遍不重复 / 失败阻断后续 / 清单严格升序)。
- 实测:真实库首次启动建表 + v1~v5 落账本,二次重启账本仍 5 行(幂等)。
### 2. 备份脚本(internal/cmd/backup.go,TamaBox backup)
- `--output <dir>` 输出;`--db-only` / `--images-only` 分开关。
- DB:调 pg_dump(PGPASSWORD 环境变量传密码避免 ps 泄漏),输出 db-<ts>.sql(标准恢复格式)。
- 图片:从 R2 下载 tamabox 站全部图片 + 写 images-manifest.json(key→size→md5)。
- 两个 CLI 入口(根 tamabox.go + cmd/tamabox.go)都注册了 Backup。
- 实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 30 秒下 15 个文件、
路径保持 tamabox/ 结构、大小与 List 一致。
### 3. Telegram/Webhook 通知(internal/notify/notify.go)
- 站级 Webhook([notify] webhook_url):新提问/新回答/新追问/新私聊/新投票回复统一 POST JSON。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id 字段):新提问/新投票点对点推送。
- 通知是「尽力而为」旁路:任一通道失败只记日志不抛错,不影响业务主流程;未配置则静默跳过。
- 6 处业务触发点接入 notify:page.go(新提问+新私聊)、question.go(新回答)、
follow_up.go(新追问+追问回答)、vote.go(新投票)。私聊消息保留仅邮件(已有节流,避免刷屏)。
- UI:profile 加 telegram_chat_id 输入框 + 四语言字典(admin.notifyTelegram* 3 条)。
- 测试:notify_test.go(webhook JSON 结构 / 未配置跳过)。
### 踩坑(本轮新)
- **R2 存储结构(用户两次纠正,重要!)**:图片实际存在 Cloudflare R2,CDN URL 形如
`https://r2.337845818.best/tamabox/2025/12/1767082504316655685.jpg`(bucket=tamabox,
key=年月/时间戳.扩展名,**无 picture/ 前缀**)。但 R2 API 侧(ListObjectsV2)返回的 key
**带 bucket 名作前缀**(`tamabox/2025/12/...jpg`)——这是 path-style bucket 的 R2 行为。
- 关键:`storage.GetS3Client` 的 endpoint 配置(image_endpoint 带 `/tamabox` 后缀 +
Bucket 参数 tamabox)对 GetObject/PutObject 恰好可用(aws-sdk 拼出 /tamabox/tamabox/<key>,
R2 容错),但 **ListObjectsV2 会拼成 /tamabox/tamabox?list-type=2 → 404 NoSuchKey**。
- 备份脚本的正确姿势:剥离 endpoint 的 `/tamabox` 后缀 + UsePathStyle=true + Bucket=tamabox,
List prefix="tamabox/",GetObject 直接用 List 返回的完整 key(含 tamabox/ 前缀)。
- 同一 R2 account(0c99...)下还有 blog/ 前缀的另一个站点内容(共享 account)。
- **代码的 PictureKeyPrefix="picture/" 与线上不一致**:mainline 代码上传 key 用 `picture/` 前缀,
但线上历史图片 key 是 `tamabox/` 前缀(即实际 key 无前缀、靠 bucket 隔离)。这是待澄清的
「代码 vs 线上」不一致,本轮未动,需用户确认是否要统一。
- **Windows Edit 又把 3 个 LF 文件转成 CRLF**(conf.go / users.go / form/user.go):
老坑复发,用 Python `b.replace(b'\r\n', b'\n')` 统一转回 LF 解决。
## R2 存储结构最终定论(用户贴出生产 app.ini [upload] 段后)
生产 app.ini 关键段:
```
[upload]
default_avatar = "https://r2.337845818.best/tamabox/tamabox_low.png"
default_qrbackgroundimage = "https://r2.337845818.best/tamabox/picture/2024/01/01.jpg"
image_endpoint = "https://<acct>.r2.cloudflarestorage.com/tamabox" # 注意带 /tamabox 后缀
image_bucket = "tamabox"
image_bucket_cdn_host = "r2.337845818.best/tamabox"
```
实测铁证(GetObject 精确验证):
- 生产配置(endpoint 带 /tamabox + Bucket=tamabox + Key)下,`key=2025/12/1767082504316655685.jpg`
返回 OK(187274),而 `key=picture/2024/01/01.jpg` 返回 404。
- **结论:R2 bucket tamabox 的真实 key = `YYYY/MM/<17位雪花ID>.<原扩展名>`,无 picture/ 前缀、
无 tamabox/ 前缀。**
- 代码 `PictureKeyPrefix="picture/"` + `randstr.Hex(15)` 生成的 key 在 R2 里**根本不存在**。
- `default_qrbackgroundimage` 里的 `picture/2024/01/01.jpg` 是**手动上传的站级静态资源**(孤立的),
不是代码生成的,且该 key 实测 404(可能已失效)。
**重大结论:生产部署的存储层代码 ≠ mainline 仓库的存储层。**
mainline 的 `picture/YYYY/MM/DD/<15hex>`(无扩展名)是一套**从未上线、与生产脱节**的实现;
生产真实用的是 `YYYY/MM/<雪花ID>.<原扩展名>`(无前缀)。差异太大不可能是小改动,
是存储层被整体替换过但改动没合回 mainline。
对备份脚本的影响:
- 备份脚本用「无后缀 endpoint + pathStyle + Bucket=tamabox + prefix=tamabox/」List,
返回的 key 带 tamabox/ 前缀(pathStyle 的 R2 行为),用同一 key GetObject 下载是自洽的、
能下全所有图片(含 picture/ 之外的真实 key)。
- 但落盘路径带 tamabox/ 前缀,与「真实 key 无前缀」在语义上不一致——恢复回传时需注意。
- 待用户决策:是否把 mainline 的 PictureKeyPrefix 和 key 生成逻辑改回生产真实格式
(`YYYY/MM/<雪花ID>.<ext>`),还是维持现状。这是存储层正确性问题,未动。
## 存储层 key 口径对齐生产(已落地)
用户确认「生产就是按 mainline 在 10-25 前最后一次修改部署的,有调整 git 查得到」,并拍板
「先备份当前源码,再按生产口径统一代码」。
### 根因最终定论(纠正之前「雪花ID」的说法)
- 生产 URL 里 `1767082504316655685` 不是雪花 ID,而是 **UnixNano 纳秒时间戳**(19 位):
`datetime.fromtimestamp(n/1e9)` = 2025-12-30 16:15:04,与路径 `2025/12` 完全吻合。
- **生产口径 key = `YYYY/MM/<UnixNano>.<原扩展名>`**(无前缀、无 day 层)。
- 铁证:`route/question/page.go:uploadImage`(提问配图入口)**早就是这个格式**:
`uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)`。
脱节的只有 `internal/storage` 包里三个函数(UploadAvatarToS3 / UploadBackgroundToS3 /
UploadPictureToS3 及 UploadPictureToOSS),它们还在用老的 `picture/ 前缀 + YYYY/MM/DD + 15hex + 无扩展名`。
- git 历史是**浅快照**:`PictureKeyPrefix` 只在 `e3e59e7 "chore: baseline snapshot"`(2026-09-10,
却是「最早提交」)出现一次,之前之后都没改过 —— 完整历史在快照之外,10-25 前的改动追溯不到。
### 改动(internal/storage/)
1. `picture.go`:`PictureKeyPrefix` 改 `""`;新增三个辅助函数:
- `uploadKey(ext)` → `YYYY/MM/<UnixNano><ext>`(唯一 key 生成点)
- `extFromContentType(contentType)` → MIME→扩展名映射(保证 key 扩展名与 ContentType 严格一致,
避免「.jpg 实为 webp」错配)
- `originalExt(filename)` → 取原扩展名并小写(OSS 原图直传通道用)
2. `s3.go`:key 生成改 `uploadKey(extFromContentType(contentType))`;删除 day 变量与 randstr 依赖。
注意转换成功用 `.webp`(contentType=image/webp),回退原图用原格式扩展名(contentType=image/jpeg→.jpg)。
3. `oss.go`:`UploadPictureToOSS` 的 `_ *multipart.FileHeader` 改 `fileHeader`(取原扩展名),
原样上传不转 webp,key 用 `uploadKey(originalExt(...))`。
4. 新增 `picture_test.go`:锁定 key 格式正则 `^\d{4}/\d{2}/\d{16,19}\.jpg$` + ext 映射 + originalExt。
### 验证
- gofmt 通过;`go build ./...` 通过;storage 全量测试 + 新增测试 PASS。
- 行尾:三个文件都是 LF(工作区 LF),Edit 未破坏(HEAD 对比行数变化正常,非整文件重写)。
### 踩坑(本轮新)
- **go 版本假象**:`go version` 报 1.27.3(GOROOT 指向 toolchain 目录),但 `GOTOOLCHAIN=local`
时暴露真实工具链是 **1.25.5**;go.mod 要求 1.27.3。正确编译姿势:直接调已下载的 toolchain 二进制
`$HOME/go/pkg/mod/golang.org/[email protected]/bin/go.exe`,
或去掉 GOSUMDB=off 让 toolchain 校验通过。用 `GOTOOLCHAIN=local` + 系统 go 会报
`go.mod requires go >= 1.27.3 (running go 1.25.5)`。
- **备份目录别放 .go 文件**:把 storage 源码备份到 `outputs/backup-*/` 后 `go build ./...`
会把备份当 Go 包编译(undefined: announcementConfig 等)。备份的 .go 文件要改 `.go.bak` 后缀,
或放到仓库外。
- **fmt 的 `%s` 对 int64**:`fmt.Sprintf` 用 `%s` 拼 int64 能编译但 `go vet`(测试时自动跑)报
`%s has arg of wrong type int64`,且语义模糊。纳秒时间戳必须用 `%d`。
+106
View File
@@ -0,0 +1,106 @@
# 2026-10-30
## 蜜罐周期安全汇总(周报/月报 + 地图 + 邮件)
用户需求:以周/月为单位汇总安全状况,把嗅探 IP 通过**公共服务**查地理位置,汇总到**地图**,
发到邮箱。起因是他发现线上有人隔一段时间换不同 IP 探测蜜罐。
### 关键认知:ipinfo 与 geoip 是两个维度(用户亲自纠正)
用户反馈「ipinfo 只能查机房,肉鸡全显示『未识别(普通网络)』」。原因是
`internal/security/ipinfo` 走 `projectdiscovery/cdncheck` 的离线 ASN 指纹,只认云厂商/CDN/WAF。
它答的是「**是不是代理**」,不是「**在哪儿**」。肉鸡(家宽、企业宽带、移动网络)必然是后者,
所以安全汇总必须另建一套真正的 IP 地理库 → 新包 `internal/security/geoip`。
### 新增文件
- `internal/security/geoip/geoip.go` — ip-api 批量打底(100/批、1.4s 节流)+ 腾讯精修国内
(只精修前 200 个,一 IP 一请求)。`Result{Geo, Unresolved, Requests, Failures}` 三态。
- `internal/security/report/{period,aggregate,mapdraw,html,geo,countryname}.go`
- `internal/db/{ipgeo,security_report}.go` — `ip_geo_cache`(主键 = `LimitKey`)、
`security_report_logs`(`period_key` 唯一索引保证幂等)
- `internal/cmd/report.go` — `honeypot report --period week|month` + 30 分钟一轮的调度器
- `templates/mail/security-report.html`
### 踩过的坑(都要记住)
1. **ip-api 免费版很慢**:22 个 IP 实测 4–10 秒,且同一批重复请求更慢。最初设 15s 超时 →
第一次真实数据就整批超时、定位率 0%。现在 `defaultTimeout = 45s` + 重试一次。
2. **缓存毒化**:早先把所有 miss 都写缓存并 `Failed=true`(TTL 3 天)→ 一次网络抖动
等于把整批 IP 钉成「查不到」三天,报告定位率莫名变 0 且无任何线索。
现在只有服务**明确回答**没数据才允许写失败缓存;请求失败绝不写。
3. **`addrs` 不能从 map 取顺序**:腾讯精修只取前 N 个,若按 map 迭代(随机)取,
每次运行精修到的 IP 都不同,配额花在长尾上。必须按 `keys` 原始顺序(= 命中数降序)建。
4. **地图标签会叠出假数字**:同一都市圈两个城市坐标只差 0.01°,标签精确重叠,
「AU 55」+「AU 30」叠成了看起来像 **AU 305** 的字符串 —— 读图的人会当结论。
已加 `labelPacker`(不重叠才画,右侧放不下换左侧)。
5. **不能无脑把腾讯结果的国家码写成 CN**:`provider=tencent` 时海外 IP 也走这条路。
现在从 `ad_info.nation` 精确判「中国」才给 CN。
### 合规(地图)
自绘底图**零国界**,只画经纬网 + 气泡 + 度数刻度;真实底图交给腾讯地图(配 key 才有,
未配给申请指引)。代码里不预置任何 key,测试里有禁词断言(googleapis/mapbox/osm/bing)。
报告里 HK/MO/TW 统一显示为「中国香港 / 中国澳门 / 中国台湾」(`countryname.go`),
且常见国家改用中文名(报告是给中文读者看的)。
### 端到端验证(WSL Debian + PG 17.11)
854 条仿真数据 / 22 个来源:首跑 36s(真实 ip-api)、次跑 1.3s(缓存命中),
定位覆盖 22/22、14 个国家/地区、未能定位 0 次。
### 顺手修的(别人改动留下的)
- `internal/route/honeypot_test.go` 的 `captureStore` 缺 3 个新方法 → 整个 route 包编译不过
- `templates/render_test.go` 的 `mockLoggedUser` 缺 `TelegramChatID` → 3 个渲染测试失败
## 蜜罐邮件提醒功能审阅(用户要求,重点找 bug 与改进点)
审阅报告:`outputs/review-honeypot-mail-2026-10-30.md`。基线测试全绿(mail + route 蜜罐相关)。
### 结论分级
- **P1(应修)`autoban.alerted` 去重口径不一致**:`map[string]bool` 永不过期,且 `cleanup()`
从不清理它 → 同一 IP 的告警**一生只发一次**;而蜜罐侧 `dedup` 用 24h TTL。两条链路口径不同,
长期盯防同一来源会漏掉后续攻击活动。建议改成带时间戳的 map + cleanup 按窗口清理。
- **P2(建议修)蜜罐告警空收件人不短路**:`route/honeypot.go:alert()` 没有
`SecurityAlertEmail == ""` 前置判断(autoban 侧有 `alertEmail != ""`)。蜜罐开着但没配收件人时,
每次凭据提交都走一遍无效 SMTP 拨号 + 留 Warn 日志,与「未配置静默跳过」预期不符。
- **P2(边界)邮件明文密码**:`SendHoneypotAlertMail` 把明文 password 渲染进 HTML。代码已意识到
是敏感物。建议维持明文(功能价值=运维立刻拿到字典)+ 强化「收件人必须是个人邮箱、禁群组」的
配置警示,而非一刀切掩码。
### 审阅中确认「已做对、无需改」的点(值得记住的设计)
- 凭据**类型层面**不入库:`db.HoneypotHit` 结构体没有 Username/Password 字段,编译期保证;
明文只落 0600 按天 JSONL。
- 蜜罐独立于 `enable_auto_ban`(`RecordHoneypot` 只看 `HoneypotEnabled`)。
- 纯 GET 探测不立即封(留「GET 表单→POST 提交」的咬钩机会)。
- 精确 IP 与 /64 封禁键分离;body 只读一次先提取凭据再判封禁;`clipRunes` 按 rune 截断防非法 UTF-8。
### 三项已修(commit 036156b)
1. **P1 `autoban.alerted` 去重口径**:`map[string]bool` → `map[string]time.Time`,新增
`alertCooldown = 24h` 常量 + `alertedRecently()` 辅助 + `cleanup()` 清理过期项。
与蜜罐侧 `alertDedupTTL=24h` 对齐。新增 `TestAlertCooldownExpires`(回拨 alerted 时间戳
模拟冷却期已过,断言能再次告警)。
2. **P2 空收件人短路**:`route.go` 注入 `sendAlert` 前判 `SecurityAlertEmail` 空值,空则置 nil,
`alert()` 的 `h.sendAlert == nil` 自然短路。(autoban 侧本就有 `alertEmail != ""` 判断,无需改。)
3. **P2 明文密码警示**:`conf/static.go` 注释 + `app.sample.ini` 注释强化「蜜罐邮件含明文密码,
收件人必须个人邮箱、禁群组/分发列表」。维持明文不改掩码(功能价值=运维立刻拿到字典)。
验证:autoban/route/mail/conf 测试全绿,`go build ./...` 通过,gofmt 干净,行尾未破坏(LF)。
## 更新日志文档 + 首页间距修复
1. **更新日志**:`outputs/changelog-2026-10-30.md`,站长口吻、口语化,只写用户可感知的功能
(数据面板/Telegram 通知/找回提问/图片压缩/404 页/验证码不丢正文/私聊完整记录)。
蜜罐、备份、迁移等纯后台内容刻意不写。注意:Telegram 条目依赖生产配好 [notify] 的 bot token。
2. **生产配置答疑**:蜜罐子项全有 sanitizeHoneypot 兜底,无需补;真正要显式加的是
honeypot_cred_dir、报告配置段(report_email 留空回退 security_alert_email)、[notify] 可选段。
生产 ban_window_seconds=10 比默认 60 短,已提示用户确认是否有意。
3. **首页间距修复(ea9ba3e)**:`templates/home.html` 里 tb-grid 之后的三张独立卡片
(域名迁移/一点提醒/近期更新)彼此零间距挤成一团。修法:`.tb-grid ~ .uk-card { margin-top: 16px }`
(模板内联 <style>,不碰 base.css,无需 bump StaticVersion)。
截图验证:headless=old + file:// 预览页,亮暗两主题间距均正常。
**坑:headless Chrome 的 --screenshot 相对路径写不进**(Failed to write file),必须用绝对路径。
-73
View File
@@ -1,73 +0,0 @@
# TamaBox-v4 长期笔记(索引版)
明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
## 协作
- 结论分级 P0/P1/P2;先根因取证 + 实测复现,别只静态推断。
- 改代码前先出变更清单;重大问题显式指出。用户常授权自主推进。
- 用户是运营者也是圈内人:处置必须可解释、可留痕、可申诉,不做自动级联封号。
## 构建与测试(详见 topics/build.md)
- 无 libwebp/GCC:`internal/storage/s3.go` 挡编译 → overlay 换桩 `dev/s3stub/s3_stub.go`。
- 「编译卡死」= `proxy.golang.org` 不可达。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`(DSN 不带库名)。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例。
- 本沙箱起不了 `TamaBox web`(session 固定 redis,6379 未开)→ HTTP 端到端只能部署后做。
- python urllib 不稳定(用 curl);python 别用 `/tmp`;PIL 在 `~/.workbuddy/binaries/python/envs/default`。
## 行尾 / 模板 / embed(topics/embed.md)
- 行尾混合不可推断,改前用 Python 数 `b'\r\n'`;gofmt 走 `dev/gofmt_keep_crlf.py`。
- `templates/embed.go` 是显式清单:新增**顶层**模板必须加 `//go:embed`。
- `//go:embed` 的 `*` 不匹配 `_`/`.` 开头文件。
## 审核 / 处置 / 举报(topics/review.md)
- 线上通道 = 七牛;裁决在 `censor/policy.go` + `moderation.Check`,新增调用点必须走 `moderation.Check`,不可用时 fail-closed。
- 严格度存 `users.moderation_strictness`;受处置用户强制 strict(`moderation.EffectiveStrictness`),查询失败回退用户设置。
- 同请求渲染只能用 `ctx.SetError`/`SetInternalError`,不能 `*Flash`。
- 处置四级 L1–L4;到期靠 `expires_at` 无 cron;`restrict` 是替换语义;比较用 `RestrictionAtLeast`。
- L2 强制私密须在审核通过后、与写回答同一条 UPDATE(`AnswerByIDPrivate`)。
- `ListAPI` 不经 Pager 中间件,L4 与页面密码须自校验;分享卡拦截须在缓存查询之前。
- 举报:免登录+验证码+限流;同 IP 同题唯一索引 → 幂等;箱主只看次数不给 IP。
- 告警 24h≥8 或 7d≥20 → 邮件;`[security] security_alert_email` 为空则静默跳过。
## GORM(topics/gorm.md)
- 默认值必须带引号;事务里每句 `tx.WithContext(ctx)`;链式对象带状态,统计/更新各自重建 query。
- 软删除+唯一索引 = 槽位永久占用;级联删除一律 `Unscoped()`。
## 前端样式(topics/style.md)
- `static/css/base.css`(`?v=` 取 `conf.StaticVersion`,改完 +1)。令牌在 `:root` + `html[data-theme="dark"]`,新样式一律 `var(--tb-*)`。
- 移动端(≤640px)语言/主题按钮在固定底栏 `#tb-mobile-utils`;箱主页**有意**无语言切换。
- UIkit 3.3.3:`.uk-flex::before/::after{display:none}`;主色是编译期常量,需在 base.css 末尾映射。
- 视觉自检:`TEST_PREVIEW=1 go test ./templates -run TestGenPreview`;截图用 `chrome --headless=old`。
## 主题(topics/theme.md、topics/builder.md)
- 三个注入点按箱主/发布者的 `Theme`;投票页语言也跟发布者(`applyOwnerLocale`)。
- 一套主题 = `static/css/themes/<slug>.css`,只覆盖令牌;显示名用 `{{ themeName ... }}`,别直接 `printf "theme.name.%s"`。
- `[theme] available` 是硬白名单(启动时读一次);css 必须成对写亮/暗两个选择器。
- 装饰层固定 10 槽,三组 `--tb-deco/-size/-position` 必须等长;位置用两值 `calc(0%|50%|100% ± N)`。
- 主题生成器 `dev/theme-builder/`:非破坏性图片处理、`layerSize`/`unitGeom` 必须共用同一函数、
`hideDeco` 会把 @media 10 槽全写 none(窄屏素材消失的头号来源)。
## 其它约定(topics/misc.md)
- 校验用 `valid:"required;maxlen:N"` 标签。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台**(用户要求不要加):站方动作只走 CLI / 邮件 / 改配置重启。
- CLI 有两个构建入口必须同步:根 `go build .` 与 Dockerfile 的 `./cmd/`。
- 现有 CLI:`web censor uid ban restore reports restrict unrestrict hide-public`。
- git `https://git.shiroko.one/tamakyi/tamabox`(main),`git push` 可直推;`outputs/` 已 gitignore。
- **ImageGen 的 `background: "transparent"` 至今不生效**(返回 RGB、无 alpha 通道)。要透明素材:
提示词把背景写成一个**具体纯色**(不要写 white,白衬衫/光环会被一起吃),
再按该色值算 alpha:`alpha = clip((maxChannelDiff - 6) / 12)`,最后 `MinFilter(3)` 收 1px。
闭合图形(如光环圆环)内部的白/底色用 floodfill 填不到,按色值抠才干净。
- 生成图的右下角有 "AI生成 WORKBUDDY" 水印,做素材时统一裁掉底部约 10%(切口平直,正好贴页面下沿)。
-34
View File
@@ -1,34 +0,0 @@
# 构建与测试(长版明细)
## 构建与测试
- **无 libwebp/GCC**:cgo 的 `internal/storage/s3.go` 挡编译 → overlay 换桩
`dev/s3stub/s3_stub.go`(`-overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json"`)。
要检查 s3.go 本身得 tar 复制项目 + `go mod edit -replace`(overlay 换不了 GOMODCACHE)。
- **「编译卡死」= `proxy.golang.org` 不可达**。每条 go 命令前
`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`(`go env -w` 沙箱失败)。
1C1G 是正式生产环境,别用「沙箱慢」解释超时。
- DB 测试:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432`
(DSN 不带库名,`NewTestDB` 自建临时库后 DROP)。带 DB 的 `go test ./...` 全绿。
- store 方法内用 `NewXStore(db.DB)`,别引用包级单例(测试下 nil panic;`FeatureStateOf` 有守卫)。
- ⚠️ **本沙箱起不了 `TamaBox web`**(session 固定 redis,6379 未开,启动 panic)→ HTTP 端到端只能部署后做。
探测端口 200 可能是别的进程,必须看日志确认 `Starting web server` 且无 panic。
- ⚠️ **本机 python urllib 不稳定**(代理下偶发读超时,curl 正常);python 别用 `/tmp`
(Windows 解析成 `C:\tmp`,与 MSYS 不通)。PIL 在 `~/.workbuddy/binaries/python/envs/default`。
- ⚠️ **验证 Go 二进制里有没有某个中文字符串,不能用 `strings`**:GNU strings 默认只输出
可打印 ASCII(0x20–0x7E),UTF-8 中文(0xE4+ 开头)整段被丢弃,`strings x | grep 中文`
恒为 0 —— 曾据此误判「用户没重新编译」,实际二进制是新的。
正确做法:`grep -ac "中文字符串" 二进制文件`(实测同一个二进制:strings=0、grep -a=1)。
- ⚠️ 替换二进制后**必须重启进程**:`go build -o X` 只改磁盘文件,正在跑的进程仍是旧映像。
若服务由 systemd/supervisor 管理,再手动 `./X web` 会因端口被占直接退出。
- ⚠️ **分享卡字体不在仓库里**:`static/fonts/` 从未入库也没 embed(`static/fs.go` 的清单里没有),
`/static/fonts/...` 走 HTTP 是 302 回首页。新环境不手工放字体 → `share-card` 恒 500
「字体文件未配置或无法加载」。老环境(如生产)能出图只是因为当年手工补过文件。
排查口诀:响应体是这句中文 → 一定是部署侧缺文件,不是代码回退。
`getFont` 现已支持 candidates(`font_path` → `static/fonts/*` → 系统 CJK 字体)且不缓存失败,
补文件后无需重启。
- ⚠️ **`GOSUMDB=off` 会让 toolchain 自动下载失败**:go.mod 要 1.27.3 而 PATH 上的 `go` 是旧版时,
会报 `golang.org/toolchain@...: verifying module: checksum database disabled by GOSUMDB=off`
(`go version` 显示的还是 shell 包装器的版本,容易误判成「版本没变」)。
解法:直接用缓存里的 toolchain 并禁用自动切换——
`PATH="$HOME/go/pkg/mod/golang.org/[email protected]/bin:$PATH" GOTOOLCHAIN=local go test ./...`
-54
View File
@@ -1,54 +0,0 @@
# 其它约定(长版明细)
## 其它约定
- 校验靠 `valid:"required;maxlen:N"` 标签(`internal/form/*.go`),别在 handler 手写。
- 终止性操作(停用)落独立公开页 `/deactivated`(注册在 `reqUserSignIn` 外);改密码后重登才用 flash + `/login`。
- 只有箱主 `/user` 面板,**无管理员角色、无站方后台,用户要求不要加**:站方动作只走 CLI / 邮件 / 改配置重启。
- git `https://git.shiroko.one/tamakyi/tamabox`(`main`)。**`git push` 能直接推**(本机 Git for Windows 已认证);
「Failed to authenticate user」是临时态。推前 `git log --oneline -3`。`outputs/` 已 gitignore,别整目录 add。
- 打包交付用 tar.gz(排除 `.git`/`.workbuddy`/`outputs`),放项目同级。
## 测试环境 box-dev-api.337845818.best
- 账号 `[email protected]` / `adminadmin`,公开页 `/_/admin6`。
- 登录要过算数验证码:`GET /login` 取 `_csrf`+cookie → `GET /api/captcha/generate` → 算 `a op b` →
`POST /login` 带 `g-recaptcha-response="<id>:<答案>"`。默认语言 **ja**,四语言文案要一起加。
- **部署有延迟**;模板是编译期 embed,**只重启不重编不会变**。用户说「已部署但没变化」→ 先抓线上 HTML 比对标记。
- 表单字段:提问 `content`,回答 `answer`。`GET /user/votes` 故意 302;匿名无邮箱提问成功直接 200 渲染。
- 自检:`dev/devcheck-e2e.py`(28 项)、`dev/devcheck-restrict.sh`(基线非 200 就别往下走;`RUN=1` 才真跑)。
- 图片编码三档(头像 512/q78、背景 1920/q80、通用 1600/q80),**EXIF 方向必须先归一**(转 WebP 会丢)。
## CLI(`TamaBox <cmd>`)
- ⚠️ **两个构建入口必须同步**:根 `go build .`(`tamabox.go`)与 Dockerfile 的 `./cmd/`(`cmd/tamabox.go`),
否则 `No help topic for '<cmd>'`。
- 现有:`web` `censor` `uid` `ban` `restore` `reports` `restrict` `unrestrict` `hide-public`。
- `restrict --domain|--email --level L1..L4 --days N --reason` / `unrestrict`;
`reports --domain|--email [--days]`(证据包)、`--pending`、`--stats --days N`;`hide-public [--only-reported] [--since N]`。
- 本地冒烟:`NEKOBOX_CONFIG_PATH=<ini>`;`conf.Init` 强制 `app.external_url`、`server.salt`、`server.xsrf_key` 非空。
- 蜜罐 CLI:`TamaBox honeypot hits|stats|prune`(`--since 24h` / `--show-password` / `--yes`),
注册须在 `cmd/tamabox.go` + 根 `tamabox.go` 两处同步。
- 蜜罐配置:`[security] enable_honeypot` / `honeypot_paths` / `honeypot_retain_days`(默认全关)。
## 行尾与测试坑(2026-10-21 蜜罐轮)
- ⚠️ `internal/route/route.go`、`internal/conf/static.go`、`conf/app.sample.ini` **实际是全 CRLF**;
`grep | cat -A` 不显示 `^M` 极易误判 → Python 替换用 `\r\n` 锚点,写后统计 `b'\r\n'` 复核。
- ⚠️ Go 里 `time.Duration(60)` = 60 **纳秒**,测试写裸数字会让封禁/冷却瞬间过期 → 一律 `60 * time.Minute`。
- ⚠️ 测 `IsBanned` → 403 必须注册中间件(`f.Use(autobanMiddleware(ab, hp))`),只注册路由+NotFound 会被绕过。
## 配置(ini)相关约定
- ⚠️ **ini 对未知键静默忽略** → 键名写错 = 配置不生效且无任何现场线索。
已加两道防线(`internal/conf/keynorm.go` + `unknown.go`):
键名归一化(自动纠正常见误拼)+ 未知键启动告警(附最接近的合法键名)。
- ⚠️ **键名必须与代码逐字一致**,包括警告文案里提到的键名 —— 写错等于误导运维。
- ⚠️ `MapTo` 在**键缺失/值非法时不覆盖**目标字段 → 可安全预置缺省值。
- ⚠️ **bool 的 ini 解析是「有键才赋值」** → 「未配置」与「显式 false」无法用零值区分,
必须用 `HasKey` 判定。缺省为 true 的开关尤其要小心(否则未配置 = 关闭)。
- 归一化必须在 `MapTo` **之前**执行,否则错拼的键已被丢弃。
- 合法键名白名单由**反射从结构体 ini tag 得出**,是单一事实来源;新增配置项自动纳入。
- `TestSampleIniHasNoUnknownKeys` 回归检测:官方样例配置零误报。
改结构体字段名时必须同步 `conf/app.sample.ini`,否则这个测试会失败。
- app.sample.ini 里不要留「代码中从未读取」的死配置(`title` / `xsrf_expire` 已清理)。
+72
View File
@@ -0,0 +1,72 @@
# 业务正确性 / 运维 / 前端体验(2026-10-29 第二轮复查)
安全向的结论在 `sec.md`。这里是非安全向的四类,完整清单见 `outputs/review-2026-10-29.md`。
> **状态**:下列「已实测确认的缺陷」「处置覆盖面」「Redis」三块已于同日修复并端到端验证。
> 紧接着做了第二批(P2 + 高价值 P3,共 9 项):投票结果 stale-while-revalidate、
> 三处幂等、私聊首条事务、`/healthz` `/readyz`、验证码失败保正文、`tbBeginSubmit`、
> waline 按页加载 / lazy / safe-area / i18n、5 处裸断言。
> 第三批(同日):删除投票/提问改事务 + 真删、配额与内容同事务、追问改回答不重复发信、
> token 常数时间比较 + CSV 注入补齐、写路径 fail-closed、真 404/HTML 500、
> 提问者找回自己的提问(localStorage + `/api/v1/ask-status`)。
> 条目保留是为了记住**为什么**会踩、以及验证该怎么做。
## 已修:两个 P0(都是实测出来的)
- **私聊 50 条截断(P0,已修)**:`internal/db/message.go` 的 `GetByConversationID` 是
`Order("created_at ASC") + Limit(50)`,而唯一调用方 `chat_handler.go:97` 永远传 `nil` cursor。
会话上限是 200 → 第 51 条起写库成功、`message_count` 在涨、页面显示 51/200,但列表永远只有最早 50 条。
实测造 55 条,页面只渲染 #1–#50。**取最新 N 条要 `Order("id DESC")` 再内存反转**;
游标 `id <` 也必须配 DESC(配升序会反复返回同一页)。
- **访问日志泄露 token(P0,已修)**:`flamego.Classic()` = `Use(Logger(), Recovery(), Static())`,
`Logger()` 记的是 `ctx.Request().RequestURI`(含 query)。实测出现
`path=/_/tblocal/4?t=SECRETTOKEN123`。`?t=` 是私密题钥匙,`?code=` 是改密凭证。
**tracing 中间件早就为同一风险避开 RequestURI 并写了注释,框架默认 Logger 漏了。**
改用 `flamego.New()` + 自写访问日志,只记 `URL.Path` 与白名单 query key。
## 处置(restrict)的覆盖面(投票已补齐)
- 提问链路 7 处 `ActiveLevel`(page L4 / question L3 / setPublic L2 / followUpAnswer L2 / shareCard 全级 / ListAPI L4 / moderation 锁定)。
- **投票链路 0 处**:`vote.Page`、`vote.Submit`、`VotePublish` 都不查。L4 处置后 `/_/domain` 已 302 到
`/restricted`,但 `/vote/{token}` 照常可投、可发新投票、探索页照常曝光。
- **教训:新增任何「用户发起」的写入口,都要问一句「这里要不要查 ActiveLevel」。**
## 运维面
- **无优雅关闭**(已修):全仓 `signal.Notify` / `server.Shutdown` 零命中,SIGTERM 直接杀进程。
`web.go` 里 `ListenAndServe` 一把梭。每次部署都掐断在途 POST。
- **Redis 两种故障**(已修):session 走 Redis 时 load 失败会 `panic`(flamego/session),被 Recovery 兜成 **全站 500**;
`cache.Cacher` 没写 `if conf.Redis.Addr != ""`,空 Addr 被 go-redis 回退 `localhost:6379` →
「不配 Redis」的部署里找回密码静默坏掉、私信邮件节流失效。启动时该 `PING` 一次并 fail fast。
- `ReadTimeout: 30s` 与 8MB 单图上限冲突(手机 4G 上行 1–2 Mbps → 8MB 必超时),正文也一起丢。
- 无 `/healthz`:`route.Home` 不碰 DB,DB 挂了 `/` 仍 200。
- `Dockerfile` 用 `golang:1.20` 而 `go.mod` 是 1.27.3 → **构建必失败**;`-X` 路径还是旧仓库名
`github.com/NekoWheel/NekoBox`,`BuildCommit` 永远是 `dev`。
- AutoMigrate 无版本化:GORM 只加列不删列,**改列名会静默留下旧列 + 新列全 NULL**,比迁移失败更难发现。
- 备份只有 `docs/cli.md` 一句 `pg_dump`;用户图片全在对象存储,**只恢复 DB = 全站裂图**。
## 前端
- `templates/question/history-template.html` 有硬编码中文(查看回答 / 加载更多 / 无更多留言),en/ja 用户直接看到。
`vote/page.html:140` 中英混排。**字典本身 917 条 × 4 语言完全对齐(有测试守着),问题只在模板层。**
- 提问页验证码失败走 `SetErrorFlash + Redirect` → **整段提问内容丢弃**;其他分支都是
`SetError + Success` 保留内容。最常见的失败反而最不该丢内容。
- `form.submit()` 不触发 submit 事件 → 验证码流(默认 captcha_type)完全没有防重复点击。
- `waline.js` 全站加载但只有 `home.html` / `item.html` 用;UIkit CSS 走第三方 CDN 且阻塞首屏。
- 32 个 `<label>` 无 `for=`;语义标签白字对比度约 1.9:1;全仓零 `loading="lazy"`。
- 移动端 `body{padding-bottom:56px}` 与 `env(safe-area-inset-bottom)` 冲突,iPhone 上遮住约 32px。
## 产品缺口(按 ROI)
1. **提问者找不回自己的提问**:不填邮箱时唯一手段是成功页那条链接,关页即失联。
全仓 `localStorage` 零命中。最小解:前端记 `{domain, id, token}`,箱主页显示「你有 N 条提问已被回答」。
2. **箱主没有自己的数据**:`SiteAnalytics` 是全站聚合(只有 `COUNT(DISTINCT user_id)`),
**全站没有 page view 表**。箱主不知道自己被看了多少次、回答率多少。
3. `NotifyType` 只有 `email` / `none`,无 Telegram / Webhook。
4. 模板零 `application/ld+json`(SEO 富摘要),无 RSS。
5. 图片原图直传,无前端压缩。
## 这一轮确认「不用动」的
四语言字典对齐、深色模式正文对比度 4.8–6.8:1、gzip 实现(204/304/Content-Length/Vary/Flusher 都处理对了)、
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
+3
View File
@@ -29,6 +29,9 @@ func main() {
cmd.Unrestrict,
cmd.HidePublic,
cmd.Honeypot,
cmd.Backup,
cmd.RestoreImages,
cmd.R2Outbox,
}
if err := app.Run(os.Args); err != nil {
logrus.WithError(err).Fatal("Failed to start application")
+112 -13
View File
@@ -66,19 +66,61 @@ trust_proxy = false
;
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
; 本程序在 trust_proxy = true 时同时支持这两个权威头。
; ⚠️ 权威头只在「TCP 对端可信」时才被采信(回环地址,或命中 trusted_proxies)。
; 这条是代码里的硬约束,不再依赖运维纪律 —— EdgeOne 不剥离客户端自带的 CF-Connecting-IP,
; 旧实现无条件优先读它,导致经 EdgeOne 进来的请求客户端 IP 完全可控:
; 限流 / IP 黑名单失效,且 autoban 按假 IP 记账可被用来栽赃任意正常用户。
; 双 CDN 并存时:① 两家回源网段都登记到 trusted_proxies;② 防火墙仅放行两家回源网段;
; ③ 仍建议在各家规则引擎里删掉「对方 CDN」的权威头(纵深防御)。
; 仅用 cloudflared Tunnel 回本机时对端是 127.0.0.1,无需任何额外配置。
; 重新启用 EdgeOne 前请先按本节核对,步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
trusted_proxies =
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
; EO-Client-IP(需源站已锁定)。
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 10.0.0.0/8),
; 或「CDN 直连回源」形态下该 CDN 的回源网段。
; 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
; 回源的连接必然由本机进程发起,外部无法伪造。
; 除回环以外,CDN 权威头也必须对端命中本清单才会被采信 —— 公网对端带来的
; CF-Connecting-IP / EO-Client-IP 一律忽略,改用 TCP 对端地址。
; 仅当 TCP 对端命中本清单时才解析 X-Forwarded-For / X-Real-IP。
; 注意:此清单用于你自己的基础设施(含你选定 CDN 的回源网段),
; 不是 CDN 的全量 CIDR 清单。
; 内容安全策略(CSP)模式:off / report / enforce,留空等同 report。
csp = enforce
; enforce(默认):真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与
; [recaptcha] 的域名自动派生,另内置模板实际引用的 cdnjs.snrat.com
; (UIkit/Alpine/dayjs/vConsole)、challenges.cloudflare.com 与 umami 统计域;
; 配了 sentry_dsn 时自动放行 browser.sentry-cdn.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)。不给上报地址时它等于没装,
; 所以只在「怀疑误伤、想先看证据」时临时用。
; off:完全不下发(排障逃生口)。
; 默认值的前提是白名单已用真浏览器逐页巡检过:
; outputs/sec-audit-2026-10-25/csp_sweep.py(20 个页面零违规)。
; 上线后发现误伤:把这里改成 report 或 off 再重启,不用改代码。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; report:只下发 Content-Security-Policy-Report-Only,页面照常加载,违规只进浏览器
; 控制台(配了 csp_report_uri 才上报)—— 用于上线前观察一周再切 enforce。
; enforce:真正拦截。脚本白名单由代码从 [upload] 的图片 CDN 域名与 [recaptcha] 的域名
; 自动派生,另内置模板实际引用的 cdnjs.snrat.com(UIkit/Alpine/dayjs/vConsole)
; 与 challenges.cloudflare.com。
; ⚠️ 日后新增第三方脚本 / 字体 / 接口域名,必须同步加进 internal/route/csp.go,
; 否则控制台会刷 "Refused to load ..." 且对应功能静默失效。
; off:完全不下发(排障逃生口)。
; 图片 / 样式 / 字体 / 连接放宽到 https:(箱主上传图与主题背景可能落在任意 CDN),
; 真正收紧的是 script-src、object-src、base-uri、form-action、frame-ancestors。
; CSP 违规上报地址(可选)。留空时违规只进浏览器控制台,不上报。
csp_report_uri =
; ⚠️ 限流与自动封禁的计数器都在进程内存里(internal/route/ratelimit.go、
; internal/security/autoban)。单副本部署(当前形态:cloudflared Tunnel 回一台机器)
; 下工作正常;一旦改成多副本 / 多机,每个副本各有一份计数(实际额度 × 副本数),
; 进程重启即清零。多副本前必须先把计数迁到 Redis。
; 封禁「事实」已落库(ban_records 表:键 / 原因 / 时长 / 到期时间),
; 重启丢的只是生效状态,账本还在。
enable_auto_ban = true
; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
ban_track_notfound = false
@@ -94,7 +136,9 @@ ban_threshold_404 = 20
ban_window_seconds = 60
; 临时封禁时长(分钟),到期自动解除,重启即清零
ban_duration_minutes = 15
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。
; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**:
; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。
security_alert_email =
; ── 蜜罐(虚假后台登录界面)────────────────────────────────────────────
@@ -153,6 +197,48 @@ honeypot_ban_minutes = 1440
; 建议单独挂一个卷,并让它不参与备份。
honeypot_cred_dir = data/honeypot-creds
; ── 安全状况汇总报告(周报 / 月报)──────────────────────────────
;
; 把周期内的蜜罐命中按来源 IP 聚合,再通过公共 IP 库定位到城市,
; 生成「世界地图 + 国家/城市排行 + 来源明细」的 HTML 报告并邮件送达。
;
; ⚠️ 与 cdncheck 离线指纹的区别:那个只认云厂商 / CDN / WAF 的 ASN,
; 肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
; 下面的 IP 库回答的是「这个 IP 在哪儿」,是另一个维度。
;
; 数据源(ip_geo_provider):
; auto(缺省) = ip-api.com 批量打底(全球、无需 key)+ 腾讯精修国内
; ipapi = 只用 ip-api.com
; tencent = 只用腾讯位置服务(必须配 key,否则降级 ipapi)
; none = 不查询任何外部服务,攻击者 IP 一个字节都不外发
; 隐私边界:auto / ipapi 会把攻击者 IP 发给 ip-api.com,tencent 发给腾讯。
ip_geo_provider = auto
; 腾讯位置服务 key(lbs.qq.com 申请,个人免费额度足够)。
; 两个用途:① 精修国内 IP 的省市与坐标;② 报告里可交互地图的底图。
; 留空 → 跳过国内精修,报告里只保留离线地图(经纬网 + 气泡)。
ip_geo_tencent_key =
; 周期性安全汇总邮件。缺省全开。
; 发送时机:周报在每周一、月报在每月 1 日之后的第一轮检查发出,覆盖
; 「上一个完整周期」;服务停机错过时间窗会在重新上线后补发;
; 幂等由 security_report_logs 的 period_key 唯一索引保证,不会重复发。
; 失败最多重试 5 次后停下(SMTP 配错时无限重试只会刷满日志),
; 之后可用 TamaBox honeypot report 手动补发。
honeypot_report_enabled = true
honeypot_report_weekly = true
honeypot_report_monthly = true
; 报告收件人。留空回退 security_alert_email。
; 单独一个键是为了能把「随时发生的封禁告警」与「定期汇总」分投到不同邮箱
; (例如告警进手机推送邮箱、汇总进归档邮箱)。
honeypot_report_email =
; 报告文件(HTML + PNG)的存放目录,按 0700 创建、文件 0600。
; 里面有攻击者 IP 明细,不要放进 Web 可访问的目录。
; 想先看内容不发送:TamaBox honeypot report --period week --no-email
honeypot_report_dir = data/reports
; 被封禁(自动封禁 / 蜜罐封禁)时返回的图片路径,相对路径以进程工作目录为基准。
; 留空、文件读不到、或内容不是图片 → 一律回退为纯文字 "403 Forbidden"。
; 只接受 image/* 的内容(png / jpeg / gif / webp):指向 app.ini 之类会被拒绝,
@@ -232,6 +318,19 @@ password = ""
port = 465
smtp = ""
[notify]
; ── 站级广播式通知通道(可选,均留空则只走 [mail] 点对点邮件)──────────────
; webhook_url:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
; (新提问 / 新回答 / 新追问 / 新私聊 / 新投票回复)统一 POST JSON 推送。
; 留空 = 不推送站级 Webhook。
; 注意:不同平台的 webhook 格式不同,这里发送统一 JSON(含 content/event/title/
; body/link/time 字段),Discord 可直接用 content 字段展示;其他平台建议在
; webhook 前端加一层转发做格式适配。
; webhook_url = ""
; telegram_bot_token:Telegram Bot token,配合每个箱主自填的 Chat ID 实现
; 「新提问」点对点通知。留空 = 禁用 Telegram 通知。
; telegram_bot_token = ""
[limits]
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
@@ -313,4 +412,4 @@ timezone = Asia/Shanghai
default = default
available = default,sakura,shiroko,new-sakura
; ⚠️ 没登记在 available 里的主题,即使 css 已随二进制发布也不会出现在设置页;
; 启动日志会打出「[theme] available 未登记以下主题」提醒。,shiroko
; 启动日志会打出「[theme] available 未登记以下主题」提醒。
Binary file not shown.
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""头像上传链路端到端验证(批次 A 分档 + B1 EXIF 归一)。
为什么走头像而不是提问图:
- 提问图那条路要经过内容审核/提问入库才能拿到 key,链路长且易受其它因素干扰;
- 头像强制 1:1 且 POST /user/profile/update 直接返回页面,便于立即取回 CDN URL。
验证两件事:
A 分档生效:上传 1600x1600 的大图,头像档 maxSide=512 -> 存盘应为 512x512
(改前是 1920,实测体积差 ~7 倍)
B1 EXIF Orientation=6 归一:上传「像素 512x256 + EXIF 顺时针 90」的方形图,
归一后仍应是 512x512(宽高互换但比例不变,故改用体积+尺寸双断言,
并额外验证竖长条图 256x512 + Orientation=6 -> 应变成 512x256)
用法:
python dev/devcheck-avatar.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbav%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_avatar(w, h, orientation=None):
"""生成一张带明显方位特征的方形头像。"""
from PIL import Image, ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
# 顶部一条粗红条:旋转 90 度后它会跑到侧面,方向一眼可辨
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被 Bind 挡下、头像根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
# 只保留勾选态的,避免把「本想取消」的项又勾上
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
# select 走第一个非空 option
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
# textarea
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload_avatar(c, data, fname="avatar.jpg"):
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [("avatar", fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
# 成功时是 302 跳回 /user/profile,头像 URL 在跳转后的页面里
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
# 从页面里取头像 URL:<img src="https://r2.../picture/..."> 或 og:image
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
if urls:
return urls[-1], None
# 没匹配到:落盘返回页并打印可疑片段,便于定位是「上传被拒」还是「正则没覆盖」
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbav_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未匹配到 picture URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbAv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbav%s@example.com" % ts, "tbav%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# 方形 1600x1600:验证 A 分档(应存成 512x512)
square = make_avatar(1600, 1600)
# EXIF Orientation=6(顺时针 90):像素存成横的 1536x512,归一后显示为竖的 512x1536,
# 再经头像档限边 512 -> 512x512。
# 注意必须用「像素横躺」的样本:头像有 1:1 比例校验,若像素本身就是竖的会被先拒掉,
# 那样就测不到 EXIF 分支了。
exif_shot = make_avatar(1536, 512, orientation=6)
print(" 本地样本 square 1600x1600%7d bytes" % len(square))
print(" 本地样本 exifshot 1536x512 (EXIF=6)%d bytes" % len(exif_shot))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBAv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ---- A 分档 ----
url, err = upload_avatar(c, square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
st, data = img.status, img.body
if not step("拉回头像", st == 200 and bool(data), "HTTP %s" % st):
return 1
kind, dims = sniff(data)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(data)))
step("A 分档生效:大图被缩到头像档 512(改前是 1920)",
dims == (512, 512), "实际 %s" % (dims,))
# ---- B1 EXIF ----
# 关键:两次上传的 URL 必须不同,否则说明第二次没生效(回退到了旧头像)。
url2, err2 = upload_avatar(c, exif_shot)
if not step("上传 EXIF 横躺头像(1536x512, Orientation=6)", bool(url2), err2 or (url2 or "")):
return 0
if url2 == url:
step("B1 EXIF 归一(第二次上传未生效,URL 未变)", False,
"两次上传返回同一 URL,无法区分;请检查是否被 1:1 校验拒绝")
return 0
img2 = c.get_binary(url2)
kind2, dims2 = sniff(img2.body)
print(" 存储结果:%s %s %d bytes" % (kind2, dims2, len(img2.body)))
# 归一后:像素 1536x512 -> 旋转成 512x1536 -> 再限边 512 -> 512x512
step("B1 EXIF Orientation=6 归一(1536x512 -> 512x1536 -> 512x512)",
dims2 == (512, 512), "实际 %s" % (dims2,))
# 体积对照:未归一时 1536x512 的 WebP 明显更大
step("B1 归一后体积已按 512 档压过(应与方形样本同量级)",
len(img2.body) < 6000, "%d bytes" % len(img2.body))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-360
View File
@@ -1,360 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TamaBox 部署后冒烟巡检(端到端)。
用途:对已部署环境跑一遍核心链路,用于发版/重新部署后的回归。
覆盖:注册 -> 登录 -> 个人页/提问列表/投票面板 -> 投票全链路(建票发布/公开页/匿名提交/
结果导出计数/草稿/表单型)-> 匿名提问 -> 停用
-> /deactivated 成功页 -> **同邮箱重注册必须被明确拒绝并提示联系站长**
-> 公开页只读巡检 -> /deactivated 四语言渲染。
语义约定(2026-09-15 起):停用 = 清空内容 + 保留用户行作「身份占位」,
故邮箱/域名继续被占用,重注册应得到「账号已停用,请找站长」的提示而不是 302 或 500。
用法:
python dev/devcheck-e2e.py [--base https://box-dev-api.337845818.best]
注意:
- 会在目标环境**真实创建账号并发送提问**(使用 tbe2e<时间戳>@example.com 之类的可识别测试邮箱),
仅在测试/开发环境运行。
- 每次完整运行都会**留下一个已停用的身份占位**(邮箱/域名被占用)。清理方式:
`TamaBox ban --email <该邮箱> --yes`(连行物理删除)或 `TamaBox restore --email <该邮箱> --yes`(恢复账号)。
- 依赖数学验证码(conf.Recaptcha.CaptchaType == "math");若站点改用 recaptcha/turnstile 则无法自动通过。
"""
import argparse
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
def alert(self):
m = re.search(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', self.body, re.S)
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", m.group(0))).strip()[:200] if m else ""
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def req(self, method, path, data=None, timeout=90):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = urllib.parse.urlencode(data).encode() if data is not None else None
if body:
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
# 读取错误响应体本身也可能超时(尤其 302 无体/远端慢),不能让它把巡检整轮打断
try:
body = e.read().decode("utf-8", "replace")
except Exception: # noqa: BLE001
body = ""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, "TRANSPORT_ERROR: %s: %s" % (type(e).__name__, e))
def get(self, p, **kw):
# 只读 GET 重试一次:远端偶发慢请求会产生 TRANSPORT_ERROR,避免巡检出现假失败。
r = self.req("GET", p, **kw)
if r.status == -1:
r = self.req("GET", p, **kw)
return r
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
"""取一道数学验证码并算出答案,返回 <id>:<answer>。"""
j = json.loads(c.get("/api/captcha/generate").body)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
ans = {"+": a + b, "-": a - b, "×": a * b}[op]
return "%s:%d" % (pid, ans)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
# 停用提示的跨语言特征串(站点默认语言可能是 ja,断言不能只看中文)
DEACTIVATED_HINTS = ("已停用", "has been deactivated", "は停止されています")
INTERNAL_ERROR_HINTS = ("サーバー内部エラー", "Internal Server Error", "服务器内部错误")
def is_deactivated_rejected(r):
"""停用后的重注册应被明确拒绝:HTTP 200 + 提示「账号已停用」,且不得是 500 兜底。"""
if r.status != 200:
return False
if any(h in r.body for h in INTERNAL_ERROR_HINTS):
return False
return any(h in r.body for h in DEACTIVATED_HINTS)
def check_public_pages(base):
"""公开页只读巡检:期望全部 200(/user/votes 之类的登录页除外)。"""
paths = ["/", "/explore", "/register", "/login", "/forgot-password",
"/sponsor", "/change-logs", "/about-me", "/robots.txt",
"/sitemap.xml", "/llms.txt", "/llms-full.txt", "/favicon.ico",
"/deactivated", "/api/captcha/generate", "/api/v1/site-stats"]
c = Client(base)
bad = []
for p in paths:
r = c.get(p, timeout=40)
if r.status != 200:
bad.append("%s=%s" % (p, r.status))
step("公开页只读巡检(%d 个端点)" % len(paths), not bad, ", ".join(bad))
def create_vote(c, title, vtype="single", options=None, fields=None, action="publish"):
"""提交一次投票创建(POST /user/votes)。
后端对创建动作一律按 draft=true 解析(草稿可随时暂存、跳过审核),
action=publish 时再走 validateForPublish + 内容审核;因此这里两种动作都可直接测。
"""
page = c.get("/user/votes/new")
data = {
"title": title, "description": "", "visibility": "public",
"voter_scope": "anyone", "show_result": "after_vote",
"type": vtype, "max_choices": "1", "allow_custom": "",
"start_at": "", "end_at": "", "quota_limit": "0",
"options": json.dumps(options or [], ensure_ascii=False),
"form_fields": json.dumps(fields or [], ensure_ascii=False),
"action": action, "_csrf": csrf_of(page.body),
}
return c.post("/user/votes", data)
def check_user_assets(owner):
"""登录态的接口/导出,以及停用页的「身份占位」说明。
停用页说明用**结构断言**(.uk-text-muted 段落非空),不写死文案 —— 站点语言随会话变化。
"""
r = owner.get("/api/v1/user")
step("登录态 /api/v1/user 返回 JSON", r.status == 200 and r.body.lstrip().startswith("{"),
"HTTP %s" % r.status)
step("登录态 /explore", owner.get("/explore").status == 200)
csrf = csrf_of(owner.get("/user/profile").body)
r = owner.post("/user/profile/export-json", {"_csrf": csrf})
step("导出 JSON", r.status == 200 and r.body.lstrip().startswith(("{", "[")),
"HTTP %s" % r.status)
r = owner.post("/user/profile/export", {"_csrf": csrf})
step("导出 Excel", r.status == 200, "HTTP %s %s" % (r.status, r.h.get("content-type", "")))
r = owner.get("/user/profile/deactivate")
note = re.search(r'class="uk-text-small uk-text-muted">(.*?)</span>', r.body, re.S)
step("停用页含身份占位说明", r.status == 200 and bool(note) and note.group(1).strip() != "")
def check_vote_flow(owner, base):
"""投票全链路:建票发布 -> 管理列表 -> 公开页 -> 匿名提交 -> 导出计数 -> 草稿 -> 表单型。"""
ts = time.strftime("%m%d%H%M%S")
title = "巡检单选投票%s" % ts
r = create_vote(owner, title, "single", options=["选项甲", "选项乙", "选项丙"])
step("创建单选投票并发布", r.status == 302 and "questions" in r.loc,
"HTTP %s -> %s %s" % (r.status, r.loc, r.alert()))
q = owner.get("/user/questions")
step("投票出现在管理列表", title in q.body)
mt = re.search(r"copyShareURL\('([^']+)'", q.body)
mv = re.search(r"/user/votes/(\d+)/edit", q.body)
token, vid = (mt.group(1) if mt else None), (mv.group(1) if mv else None)
step("取得分享 token / 投票 ID", bool(token and vid), "%s / %s" % (token, vid))
if not (token and vid):
return
anon = Client(base)
pv = anon.get("/vote/%s" % token)
step("公开投票页渲染", pv.status == 200 and title in pv.body, "HTTP %s" % pv.status)
mo = re.search(r'name="option_id" value="(\d+)"', pv.body)
step("公开页含可选项", bool(mo))
if not mo:
return
sub = anon.post("/vote/%s/submit" % token, {
"option_id": mo.group(1), "g-recaptcha-response": captcha(anon),
"custom_option": "", "_csrf": csrf_of(pv.body),
})
step("匿名提交投票", sub.status == 302, "HTTP %s -> %s %s" % (sub.status, sub.loc, sub.alert()))
# 导出仅作只读校验:单选导出的 CSV 每行是 <选项,票数>,末行为 <总参与,N>。
# 只认「总参与」这一行,避免把各选项票数一起求和导致 2 倍计数。
exp = owner.get("/user/votes/%s/export" % vid)
mtot = re.search(r"总参与,(\d+)", exp.body)
total = int(mtot.group(1)) if mtot else -1
step("导出显示总参与 = 1", exp.status == 200 and total == 1,
"HTTP %s 总参与=%s" % (exp.status, total))
dr = create_vote(owner, "巡检草稿%s" % ts, "single", options=["甲", "乙"], action="save")
step("保存草稿跳编辑页(?saved=1)",
dr.status == 302 and "/user/votes/" in dr.loc and "saved=1" in dr.loc,
"HTTP %s -> %s" % (dr.status, dr.loc))
fr = create_vote(owner, "巡检表单投票%s" % ts, "form", fields=[
{"field_type": "text", "label": "昵称", "required": True, "max_length": 50},
{"field_type": "textarea", "label": "建议", "required": False, "max_length": 200},
])
step("创建表单型投票并发布", fr.status == 302 and "questions" in fr.loc,
"HTTP %s -> %s %s" % (fr.status, fr.loc, fr.alert()))
def check_deactivated_i18n(base):
"""停用成功页四语言渲染(用语言无关的 ratio:3 标记 + 标题非空)。"""
bad = []
for lang in ["zh-CN", "zh-TW", "en", "ja"]:
c = Client(base)
c.get("/i18n/%s?to=/deactivated" % lang, timeout=40) # 302,语言写入 session
r = c.get("/deactivated", timeout=40) # 再请求一次才渲染新语言
title = re.search(r'uk-card-body uk-text-center".*?<h3[^>]*>([^<]*)</h3>',
r.body, re.S)
if not title or not title.group(1).strip():
bad.append(lang)
step("停用成功页四语言渲染", not bad, "缺失: " + ",".join(bad) if bad else "")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
ap.add_argument("--reuse-email", default="",
help="只跑一项:用该既有邮箱重新注册(用于复测停用后能否重注册)")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbCheck!2026"
if args.reuse_email:
domain = args.reuse_email.split("@")[0]
c = Client(base)
r = c.get("/register")
r = c.post("/register", {
"email": args.reuse_email, "domain": domain, "name": "TBCheck",
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("以既有邮箱重新注册(已停用则应被明确拒绝并提示找站长)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
return 0 if all(x[1] for x in RESULTS) else 1
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbe2e%s@example.com" % ts, "tbe2e%s" % ts
print("== 新账号:%s / %s ==" % (email, domain))
pub = Client(base)
r = pub.get("/register")
reg = pub.post("/register", {
"email": email, "domain": domain, "name": "TB%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("注册", reg.status == 302, "HTTP %s -> %s %s" % (reg.status, reg.loc, reg.alert()))
owner = Client(base)
r = owner.get("/login")
r = owner.post("/login", {
"email": email, "password": pwd,
"g-recaptcha-response": captcha(owner), "_csrf": csrf_of(r.body),
})
step("登录", r.status == 302, "HTTP %s -> %s" % (r.status, r.loc))
step("登录态 /user/profile", owner.get("/user/profile").status == 200)
step("登录态 /user/questions", owner.get("/user/questions").status == 200)
r = owner.get("/user/votes")
step("/user/votes 302 -> /user/questions#votes",
r.status == 302 and "questions" in r.loc, "HTTP %s -> %s" % (r.status, r.loc))
step("新建投票页", owner.get("/user/votes/new").status == 200)
check_user_assets(owner)
check_vote_flow(owner, base)
# 匿名提问:无邮箱时成功后直接 200 渲染提问成功页(不重定向),故只断言提问详情可达
r = pub.get("/_/%s" % domain)
step("打开提问箱", r.status == 200)
r = pub.post("/_/%s" % domain, {
"content": "自动化巡检测试提问(可忽略)",
"g-recaptcha-response": captcha(pub),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(r.body),
})
step("提交提问", r.status in (200, 302), "HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/user/profile/deactivate")
step("打开停用页", r.status == 200)
r = owner.post("/user/profile/deactivate", {"_csrf": csrf_of(r.body)})
step("停用后跳 /deactivated", r.status == 302 and "deactivated" in r.loc,
"HTTP %s -> %s" % (r.status, r.loc))
r = owner.get("/deactivated")
step("停用成功页渲染", r.status == 200 and "ratio: 3" in r.body, "HTTP %s" % r.status)
pub2 = Client(base)
r = pub2.get("/register")
r = pub2.post("/register", {
"email": email, "domain": domain, "name": "TB%sb" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(pub2), "language": "zh-CN", "agree_rules": "1",
"_csrf": csrf_of(r.body),
})
step("停用后同邮箱重注册被明确拒绝并提示找站长(核心回归)", is_deactivated_rejected(r),
"HTTP %s %s" % (r.status, r.alert()))
check_public_pages(base)
check_deactivated_i18n(base)
print("\n===== 汇总 =====")
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
n = sum(1 for x in RESULTS if not x[1])
print("\n共 %d 项,失败 %d 项" % (len(RESULTS), n))
return 1 if n else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
raise
except Exception as e: # noqa: BLE001
# 远端抖动等意外中断时,仍把已完成项的结论打出来,避免只剩一段 traceback
print("\n巡检意外中断:%s: %s" % (type(e).__name__, e))
for d, ok, ex in RESULTS:
print("%s %s%s" % ("PASS" if ok else "FAIL", d, (" (%s)" % ex) if ex else ""))
sys.exit(2)
-370
View File
@@ -1,370 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""图片上传链路端到端验证(批次 A / B1 / B2)。
针对已部署环境验证三件事:
B1 EXIF Orientation=6 的竖拍 JPEG 转换后必须变成竖图(核心验收点)
B2 动画 GIF 必须被转成 .webp(cwebp 可用性 + 提问路径与头像路径一致性)
A 转换后不比原图小时必须回退原图(用一张极小线框图验证)
做法:注册账号 -> 匿名提问(multipart 同时带三张图)-> 解析成功页里的 CDN URL
-> 逐张拉回字节,解析真实容器格式与尺寸。
用法:
python dev/devcheck-image.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于在本地生成测试图片,不参与被测逻辑)。
注意:会在目标环境真实创建账号并发送带图提问(测试邮箱前缀 tbimg<ts>@)。
清理:TamaBox ban --email <该邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import os
import tempfile
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
CREATED_EMAIL = ""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=120):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=120):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbimg%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
# ---------- 容器格式解析(纯 stdlib) ----------
def sniff(data):
"""返回 (kind, (w,h))。kind in {webp, jpeg, gif, png, unknown}"""
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp", webp_dims(data)
if data[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(data)
if data[:3] == b"GIF":
return "gif", gif_dims(data)
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png", png_dims(data)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def gif_dims(d):
return struct.unpack("<HH", d[6:10]) if len(d) >= 10 else None
def png_dims(d):
return struct.unpack(">II", d[16:24]) if len(d) >= 24 else None
# ---------- 测试图片生成(Pillow 仅此一处使用) ----------
def make_images():
from PIL import Image, ImageDraw
import io as _io
# B1:EXIF Orientation=6。像素存 240x120,正确归一后应显示 120x240。
base = Image.new("RGB", (240, 120))
dr = ImageDraw.Draw(base)
for i in range(0, 240, 20):
dr.line([(i, 0), (i, 120)], fill=(200, 30, 30), width=3)
dr.rectangle([0, 0, 239, 119], outline=(0, 0, 255), width=4)
exif = base.getexif()
exif[0x0112] = 6
buf = _io.BytesIO()
base.save(buf, "JPEG", quality=88, exif=exif)
portrait = buf.getvalue()
# B2:6 帧动画 GIF。加大到 640x480,确保转换后仍小于原图(否则会走回退)。
frames = []
pal_frames = []
for f in range(6):
im = Image.new("RGB", (640, 480), (240, 240, 240))
d = ImageDraw.Draw(im)
d.ellipse([40 + f * 60, 120, 200 + f * 60, 280], fill=(220, 40, 40))
d.rectangle([300, 60 + f * 20, 600, 200], fill=(30, 90, 220))
pal_frames.append(im.convert("P", palette=Image.ADAPTIVE, colors=64))
gbuf = _io.BytesIO()
pal_frames[0].save(gbuf, "GIF", save_all=True, append_images=pal_frames[1:],
duration=120, loop=0)
animated = gbuf.getvalue()
# A:极小线框图(200x200),WebP 有损编码后反而比 PNG 大 -> 应回退原图。
wire = Image.new("RGB", (200, 200), (255, 255, 255))
d = ImageDraw.Draw(wire)
for i in range(0, 200, 20):
d.line([(i, 0), (i, 199)], fill=(0, 0, 0))
d.line([(0, i), (199, i)], fill=(0, 0, 0))
wbuf = _io.BytesIO()
wire.save(wbuf, "PNG")
wireframe = wbuf.getvalue()
return {"portrait": portrait, "anim": animated, "wire": wireframe}
def main():
global CREATED_EMAIL
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbImg!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbimg%s@example.com" % ts, "tbimg%s" % ts
CREATED_EMAIL = email
print("== 账号:%s / %s ==" % (email, domain))
imgs = make_images()
for k, v in imgs.items():
print(" 本地样本 %-9s %7d bytes" % (k, len(v)))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBImg%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
page = c.get("/_/%s" % domain)
if not step("打开提问箱", page.status == 200, "HTTP %s" % page.status):
return 1
# route/question/page.go 只取 f.Images[0](单张上限),
# 故每张样本各发一条提问,逐张验证。
fetched = []
for key, (fname, ctype) in (("portrait", ("portrait.jpg", "image/jpeg")),
("anim", ("anim.gif", "image/gif")),
("wire", ("wire.png", "image/png"))):
box = c.get("/_/%s" % domain)
body, ct = multipart({
"content": "图片上传链路验证 %s(可忽略)" % key,
"g-recaptcha-response": captcha(c),
"receive_reply_email": "", "private": "", "ask_password": "",
"start_chat_val": "", "_csrf": csrf_of(box.text),
}, [("images", fname, ctype, imgs[key])])
res = c.post("/_/%s" % domain, data=body, content_type=ct)
if not step("提交带图提问 [%s]" % key, res.status in (200, 302),
"HTTP %s -> %s" % (res.status, res.loc)):
continue
# 成功页不渲染图片(templates/question/success.html 无图片区),
# 故从成功页取出 question ID,再访问详情页取图片 key。
qid = re.search(r'/_/%s/(\d+)' % re.escape(domain), res.text)
if not qid:
step(" └ 取提问 ID [%s]" % key, False, "成功页未给出 /_/<domain>/<id>")
continue
detail = c.get("/_/%s/%s" % (domain, qid.group(1)))
# 提问图的 key 不带 picture/ 前缀(是 "<年>/<月>/<纳秒>.<ext>"),
# 模板拼成 https://<CDN>/<key>,故按 CDN host + 数字开头路径来匹配。
urls = sorted(set(re.findall(
r'https://r2\.[^"\'\s<>]+/\d{4}/\d{1,2}/\d+\.\w+', detail.text)))
if not urls:
step(" └ 详情页回显图片 [%s]" % key, False,
"HTTP %s,未找到 picture URL" % detail.status)
continue
step(" └ 详情页回显图片 [%s]" % key, True, "%d 个" % len(urls))
for u in urls:
st, data = c.get_binary(u)
if st != 200 or not data:
step(" └ 拉回图片 [%s]" % key, False, "HTTP %s" % st)
continue
kind, dims = sniff(data)
fetched.append((key, kind, dims, len(data)))
print(" %-14s %-7s %-12s %7d bytes" % (u.rsplit("/", 1)[-1], kind, dims, len(data)))
print("\n== 断言 ==")
# B1
portrait = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (120, 240)]
land = [f for f in fetched if f[0] == "portrait" and f[1] in ("webp", "jpeg")
and f[2] == (240, 120)]
if portrait:
step("B1 EXIF Orientation=6 已归一(240x120 -> 120x240)", True,
"%s, %d bytes" % (portrait[0][1], portrait[0][3]))
elif land:
step("B1 EXIF Orientation=6 已归一", False, "仍是 240x120,说明未应用 EXIF 旋转")
else:
step("B1 EXIF 方向归一", False, "未匹配到 240x120 / 120x240 的图片,实际: %s" %
[(f[1], f[2]) for f in fetched])
# B2:GIF 样本必须变成 webp;关键是按 key 归属判定,而不是看整体有没有 webp
gif_res = [f for f in fetched if f[0] == "anim"]
if not gif_res:
step("B2 动画 GIF 转 WebP", False, "未取到 anim 样本结果")
elif gif_res[0][1] == "webp":
step("B2 动画 GIF 已转为 WebP", True,
"%dx%d, %d bytes(原 GIF %d bytes,-%.0f%%)" % (
gif_res[0][2][0], gif_res[0][2][1], gif_res[0][3], len(imgs["anim"]),
(1 - gif_res[0][3] / len(imgs["anim"])) * 100))
elif gif_res[0][1] == "gif":
step("B2 动画 GIF 已转为 WebP", False,
"仍是原始 GIF %d bytes -> cwebp 不可用或转换失败" % gif_res[0][3])
else:
step("B2 动画 GIF 转 WebP", False, "得到 %s(预期 webp)" % gif_res[0][1])
# A:线框图应回退原图(PNG),而非转成更大的 WebP
wire = [f for f in fetched if f[0] == "wire"]
if not wire:
step("A 线框图兜底回退", False, "未取到 wire 样本结果")
elif wire[0][1] == "png":
step("A 转换无收益时回退原图(线框图保持 PNG)", True,
"%d bytes(原 PNG %d bytes)" % (wire[0][3], len(imgs["wire"])))
else:
step("A 线框图兜底回退", False,
"被转成了 %s(%d bytes)——本应回退原图" % (wire[0][1], wire[0][3]))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
-419
View File
@@ -1,419 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上传链路端到端验证(A 分档 / B1 EXIF 归一 + EXIF 比例校验 / B3 单次解码)。
为什么主用例走「背景」而不是头像
--------------------------------
头像要求 1:1,是**对称**比例:EXIF 归一(5/6/7/8 交换宽高)后比例不变。
所以无论修没修比例校验的 EXIF 缺口,头像路径的表现完全一样 —— **测不出缺口**。
背景要求 16:9,非对称,且用户真实场景就是「手机竖拍一张照当背景」:
像素 1080x1920(竖,比例 0.5625)+ EXIF Orientation=6(顺时针 90)
-> 用户肉眼看到的是 1920x1080 横图,比例 1.7778,符合 16:9
-> 修复前:只看原始像素 0.5625 vs 1.7778,偏差 1.215 > 0.01 → **被误拒**
-> 修复后:先交换宽高得 1920x1080,偏差 0 → 放行
头像用例保留,但只用来回归 A 分档(不再承担 EXIF 比例校验的验证职责)。
用法:
python dev/devcheck-upload.py [--base https://box-dev-api.337845818.best]
依赖:Pillow(仅用于本地生成测试图片)
清理:TamaBox ban --email <输出中的测试邮箱> --yes
"""
import argparse
import http.cookiejar
import io
import json
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from PIL import Image, ImageOps
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
RESULTS = []
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
return None
class Resp:
def __init__(self, status, headers, body):
self.status = status
self.h = {k.lower(): v for k, v in headers.items()}
self.body = body
@property
def loc(self):
return self.h.get("location", "")
@property
def text(self):
return self.body.decode("utf-8", "replace")
class Client:
def __init__(self, base):
self.base = base
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.cj), NoRedirect())
def _send(self, req, timeout=180):
try:
with self.op.open(req, timeout=timeout) as r:
return Resp(r.status, dict(r.headers), r.read())
except urllib.error.HTTPError as e:
try:
body = e.read()
except Exception: # noqa: BLE001
body = b""
return Resp(e.code, dict(e.headers), body)
except Exception as e: # noqa: BLE001
return Resp(-1, {}, ("TRANSPORT_ERROR: %s" % e).encode())
def req(self, method, path, data=None, content_type=None, timeout=180):
url = path if path.startswith("http") else self.base + path
headers = {"User-Agent": UA, "Referer": self.base + path.split("?")[0]}
body = None
if data is not None:
body = data if isinstance(data, bytes) else urllib.parse.urlencode(data).encode()
headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
return self._send(urllib.request.Request(url, data=body, headers=headers, method=method),
timeout)
def get(self, p, **kw):
r = self.req("GET", p, **kw)
return r if r.status != -1 else self.req("GET", p, **kw)
def post(self, p, data=None, **kw):
return self.req("POST", p, data=data, **kw)
def get_binary(self, url):
return self._send(urllib.request.Request(
url, headers={"User-Agent": UA}, method="GET"), timeout=60)
def step(desc, ok, extra=""):
RESULTS.append((desc, ok, extra))
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", desc, (" (%s)" % extra) if extra else ""))
return ok
def redness_bbox(im):
"""返回红色标记条的包围盒 (x0, y0, x1, y1)。
用来做「方向是否真的被旋转」的判据:尺寸对不代表方向对,必须看特征位置。
有损 WebP 会让红色略有偏差,故阈值放宽并按 4px 步长采样。
"""
W, H = im.size
px = im.load()
xs, ys = [], []
for y in range(0, H, 4):
for x in range(0, W, 4):
r, g, b = px[x, y]
if r > 150 and g < 90 and b < 90:
xs.append(x)
ys.append(y)
return (min(xs), min(ys), max(xs), max(ys)) if xs else None
def csrf_of(html):
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else None
def captcha(c):
j = json.loads(c.get("/api/captcha/generate").text)
pid, q = j["data"]["id"], j["data"]["question"]
m = re.match(r"\s*(\d+)\s*([+\-×])\s*(\d+)\s*=", q)
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
return "%s:%d" % (pid, {"+": a + b, "-": a - b, "×": a * b}[op])
def multipart(fields, files):
boundary = "----tbuv%s" % uuid.uuid4().hex
buf = io.BytesIO()
for k, v in fields.items():
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"\r\n\r\n' % k).encode())
buf.write(("%s\r\n" % v).encode())
for name, fn, ct, data in files:
buf.write(("--%s\r\n" % boundary).encode())
buf.write(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, fn)).encode())
buf.write(("Content-Type: %s\r\n\r\n" % ct).encode())
buf.write(data)
buf.write(b"\r\n")
buf.write(("--%s--\r\n" % boundary).encode())
return buf.getvalue(), "multipart/form-data; boundary=%s" % boundary
def sniff(d):
if len(d) >= 12 and d[:4] == b"RIFF" and d[8:12] == b"WEBP":
return "webp", webp_dims(d)
if d[:3] == b"\xff\xd8\xff":
return "jpeg", jpeg_dims(d)
if d[:8] == b"\x89PNG\r\n\x1a\n":
return "png", (struct.unpack(">II", d[16:24]) if len(d) >= 24 else None)
return "unknown", None
def webp_dims(d):
c = d[12:16]
if c == b"VP8 " and len(d) >= 30:
return (struct.unpack("<H", d[26:28])[0] & 0x3FFF,
struct.unpack("<H", d[28:30])[0] & 0x3FFF)
if c == b"VP8L" and len(d) >= 25:
b = struct.unpack("<I", d[21:25])[0]
return (b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1
if c == b"VP8X" and len(d) >= 30:
return ((d[24] | d[25] << 8 | d[26] << 16) + 1,
(d[27] | d[28] << 8 | d[29] << 16) + 1)
return None
def jpeg_dims(d):
i = 2
while i + 9 < len(d):
if d[i] != 0xFF:
i += 1
continue
m = d[i + 1]
if m in (0xC0, 0xC1, 0xC2, 0xC3):
h, w = struct.unpack(">HH", d[i + 5:i + 9])
return (w, h)
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
i += 2
continue
i += 2 + struct.unpack(">H", d[i + 2:i + 4])[0]
return None
def make_img(w, h, orientation=None, portrait=False):
"""生成带方位特征的图片。
portrait=True 时在**左侧**画一条粗红色竖条:EXIF 顺时针 90 后它会跑到顶部,
用于像素级确认方向确实被归一了(而非仅尺寸数字对)。
"""
from PIL import ImageDraw
im = Image.new("RGB", (w, h), (30, 120, 200))
d = ImageDraw.Draw(im)
if portrait:
d.rectangle([0, 0, max(2, w // 12), h], fill=(230, 30, 30))
d.text((max(4, w // 12 + 6), h // 2), "TOP-IS-LEFT", fill=(255, 255, 255))
else:
d.rectangle([0, 0, w, max(2, h // 12)], fill=(230, 30, 30))
d.text((w // 4, max(4, h // 12 + 4)), "TOP-IS-UP", fill=(255, 255, 255))
for i in range(0, w, max(8, w // 20)):
d.line([(i, h // 4), (i, h)], fill=(255, 255, 255), width=2)
buf = io.BytesIO()
if orientation:
exif = im.getexif()
exif[0x0112] = orientation
im.save(buf, "JPEG", quality=90, exif=exif)
else:
im.save(buf, "JPEG", quality=90)
return buf.getvalue()
def scrape_profile_fields(html):
"""从 profile 页抓取所有 input 的 name/value,保证回填必填项。
UpdateProfile 的必填字段有十来个(昵称/介绍/5 个二维码颜色/点大小/透明度…),
漏一个就会因 `valid:"required"` 被Bind 挡下、图片根本不会走到上传分支。
"""
fields = {}
for m in re.finditer(r'<input\b[^>]*>', html, re.I):
tag = m.group(0)
nm = re.search(r'name="([^"]+)"', tag, re.I)
if not nm:
continue
name = nm.group(1)
if name in ("avatar", "background", "backgroundimage", "_csrf", "submit"):
continue
typ = re.search(r'type="([^"]+)"', tag, re.I)
if typ and typ.group(1).lower() in ("checkbox", "radio"):
if not re.search(r'\bchecked\b', tag, re.I):
continue
val = re.search(r'value="([^"]*)"', tag, re.I)
fields[name] = val.group(1) if val else ""
for m in re.finditer(r'<select\b[^>]*name="([^"]+)"[^>]*>(.*?)</select>', html, re.I | re.S):
name, body = m.group(1), m.group(2)
opts = re.findall(r'<option\b([^>]*)>', body, re.I)
chosen = ""
for o in opts:
vm = re.search(r'value="([^"]*)"', o, re.I)
v = vm.group(1) if vm else ""
if re.search(r'\bselected\b', o, re.I):
chosen = v
break
if not chosen:
chosen = v
fields[name] = chosen
for m in re.finditer(r'<textarea\b[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', html, re.I | re.S):
fields[m.group(1)] = re.sub(r"<[^>]+>", "", m.group(2)).strip()
return fields
def upload(c, field, data, fname="img.jpg"):
"""上传图片到 profile 表单的指定字段(avatar / background)。
返回 (图片CDN URL 或 None, 错误描述或 None)。
"""
page = c.get("/user/profile")
if page.status != 200:
return None, "profile HTTP %s" % page.status
fields = scrape_profile_fields(page.text)
fields["_csrf"] = csrf_of(page.text)
body, ct = multipart(fields, [(field, fname, "image/jpeg", data)])
res = c.post("/user/profile/update", data=body, content_type=ct)
if res.status == 302 and res.loc:
res = c.get(res.loc if res.loc.startswith("/") else "/user/profile")
if res.status != 200:
return None, "update 后页面 HTTP %s" % res.status
if field == "background":
m = re.search(r'id="bg-prev"[^>]*src="([^"]+)"', res.text) or \
re.search(r'src="([^"]+)"[^>]*id="bg-prev"', res.text)
url = m.group(1) if m else None
else:
urls = re.findall(r'https://r2\.[^"\'\s<>]+/picture/[0-9]+/\d{2}/\d{2}/[0-9a-f]+', res.text)
if not urls:
m = re.search(r'(https://r2\.[^"\'\s<>]+/picture/[^"\'\s<>]+)', res.text)
urls = [m.group(1)] if m else []
url = urls[-1] if urls else None
if url:
return url, None
dump = "C:/Users/Tamakyi Gekka/AppData/Local/Temp/tbuv_profile.html"
with open(dump, "wb") as f:
f.write(res.body)
alerts = re.findall(r'class="uk-alert[^"]*"[^>]*>(.*?)</div>', res.text, re.S)
hint = " | ".join(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", a)).strip()[:80]
for a in alerts[:3])
return None, "未取到图片 URL(已存 %s)%s%s" % (dump, (" 提示:" + hint) if hint else "",
(" HTTP %s" % res.status))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="https://box-dev-api.337845818.best")
args = ap.parse_args()
base = args.base.rstrip("/")
pwd = "TbUv!2026"
ts = time.strftime("%m%d%H%M%S")
email, domain = "tbuv%s@example.com" % ts, "tbuv%s" % ts
print("== 账号:%s / %s ==" % (email, domain))
# ---- 本地样本 ----
square = make_img(1600, 1600) # A 分档回归:头像
bg_ok = make_img(1920, 1080) # 背景 16:9,无 EXIF(正向对照)
bg_ori6 = make_img(1080, 1920, orientation=6, portrait=True) # 核心用例
bg_bad = make_img(1080, 1920) # 无 EXIF 的竖图:应被拒(16:9 校验仍生效)
print(" 样本 avatar 1600x1600 %7d B" % len(square))
print(" 样本 bg-ok 1920x1080 %7d B" % len(bg_ok))
print(" 样本 bg-ori6 1080x1920 (EXIF=6) %7d B" % len(bg_ori6))
print(" 样本 bg-bad 1080x1920 (无 EXIF) %7d B" % len(bg_bad))
c = Client(base)
r = c.get("/register")
reg = c.post("/register", {
"email": email, "domain": domain, "name": "TBUv%s" % ts,
"password": pwd, "repeat_password": pwd,
"g-recaptcha-response": captcha(c), "language": "zh-CN",
"_csrf": csrf_of(r.text),
})
if not step("注册", reg.status == 302, "HTTP %s -> %s" % (reg.status, reg.loc)):
return 1
c = Client(base)
r = c.get("/login")
lg = c.post("/login", {"email": email, "password": pwd,
"g-recaptcha-response": captcha(c), "_csrf": csrf_of(r.text)})
if not step("登录", lg.status == 302, "HTTP %s -> %s" % (lg.status, lg.loc)):
return 1
# ================= A 分档(头像回归) =================
print("\n-- A 分档(头像 512/q78)--")
url, err = upload(c, "avatar", square)
if not step("上传方形大头像(1600x1600)", url is not None, err or (url or "")):
return 1
img = c.get_binary(url)
if not step("拉回头像", img.status == 200 and bool(img.body), "HTTP %s" % img.status):
return 1
kind, dims = sniff(img.body)
print(" 存储结果:%s %s %d bytes" % (kind, dims, len(img.body)))
step("A 分档生效:大图被缩到头像档 512", dims == (512, 512), "实际 %s" % (dims,))
# ================= B1 背景正向对照(无 EXIF) =================
print("\n-- 背景 16:9 正向对照(无 EXIF)--")
url_bg, err = upload(c, "background", bg_ok)
if not step("上传 16:9 背景(1920x1080,无 EXIF)", url_bg is not None, err or (url_bg or "")):
return 1
r1 = c.get_binary(url_bg)
k1, d1 = sniff(r1.body)
print(" 存储结果:%s %s %d bytes" % (k1, d1, len(r1.body)))
step("背景档 1920 且比例正确", k1 == "webp" and d1 == (1920, 1080), "实际 %s %s" % (k1, d1))
# ================= B1 核心:EXIF Orientation=6 的竖拍背景 =================
print("\n-- B1 核心:EXIF 比例校验缺口(手机竖拍当背景)--")
print(" 像素 1080x1920 + EXIF=6 ->肉眼看到 1920x1080 (16:9)")
print(" 修复前:按像素 0.5625 判比例 -> 偏差 1.215 >> 0.01 -> 被误拒")
url_o, err = upload(c, "background", bg_ori6)
if not step("上传 EXIF 竖拍背景(1080x1920, Orientation=6)", url_o is not None,
err or (url_o or "")):
print(" >>> 若此处 FAIL,说明 EXIF 比例校验缺口仍未修复")
return 1
if url_o == url_bg:
step("EXIF 背景确实换了新图", False, "两次上传返回同一 URL(第二次被拒、沿用旧值)")
return 1
r2 = c.get_binary(url_o)
k2, d2 = sniff(r2.body)
print(" 存储结果:%s %s %d bytes" % (k2, d2, len(r2.body)))
step("B1 EXIF 归一后按16:9 存盘(1920x1080)", k2 == "webp" and d2 == (1920, 1080),
"实际 %s %s" % (k2, d2))
step("B1 归一后限边生效(长边 1920)",
d2 is not None and max(d2) == 1920, "实际 %s" % (d2,))
# 尺寸对了不代表方向对了 —— 与 Pillow exif_transpose 作像素级黄金标准比对:
# 定位红色条的包围盒,两边必须一致(原图红条在左侧,顺时针 90 后应跑到顶部)。
ref = ImageOps.exif_transpose(Image.open(io.BytesIO(bg_ori6))).convert("RGB")
got = Image.open(io.BytesIO(r2.body)).convert("RGB")
if ref.size == got.size:
bb_ref, bb_got = redness_bbox(ref), redness_bbox(got)
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)",
bb_ref is not None and bb_ref == bb_got,
"黄金标准 %s vs 存盘 %s" % (bb_ref, bb_got))
else:
step("B1 像素级方向与 Pillow exif_transpose 一致(红条包围盒)", False,
"尺寸不同:黄金标准 %s vs 存盘 %s" % (ref.size, got.size))
# ================= 反向用例:EXIF 修复不能放松比例校验 =================
print("\n-- 反向用例:无 EXIF 的竖图仍应被拒(确认没把校验放得过宽)--")
url_bad, err_bad = upload(c, "background", bg_bad)
# 期望:上传被拒 -> bg-prev 仍是上一步的 ori6 图(url_o)
if url_bad == url_o:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "bg-prev 保持 ori6 那张")
elif url_bad is None:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", True, "上传被拒(%s)" % (err_bad or "")[:60])
else:
step("无 EXIF 的 9:16 竖图被 16:9 校验拒绝", False,
"竟被接受并存成 %s" % (url_bad,))
print("\n== 结果:%d/%d 通过 ==" % (sum(1 for _, ok, _ in RESULTS if ok), len(RESULTS)))
for d, ok, e in RESULTS:
if not ok:
print(" FAIL: %s %s" % (d, e))
print("清理:TamaBox ban --email %s --yes" % email)
return 0 if all(ok for _, ok, _ in RESULTS) else 1
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# 把 Windows 侧工作区同步到 WSL 里的测试部署目录。
#
# 为什么不能直接 rsync --delete:WSL 侧有几份「只存在于目标机」的文件,
# 源目录里没有它们,--delete 会把它们当多余文件删掉:
# - conf/app.ini 本地测试配置(数据库密码、对象存储密钥;模板见 dev/wsl-app.ini)
# 第一次踩这个坑时整份 app.ini 被删,服务器重启后起不来。
#
# 用法:在仓库根目录执行
# bash dev/sync-wsl.sh
set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)"
DST=/home/tamakyi/tb/src
cd "$SRC"
tar -cf - \
--exclude=.git \
--exclude=outputs \
--exclude=.workbuddy \
. 2>/dev/null |
wsl -d Debian -e bash -c '
set -e
DST=/home/tamakyi/tb/src
rm -rf /tmp/tbsync && mkdir -p /tmp/tbsync
tar -xf - -C /tmp/tbsync
# 只同步代码与资源,绝不动目标侧的 conf/app.ini
rsync -a --delete --exclude "conf/app.ini" /tmp/tbsync/ "$DST"/
rm -rf /tmp/tbsync
echo "SYNCED -> $DST"
'
+56 -5
View File
@@ -32,7 +32,7 @@ ps -eo pid,lstart,cmd | grep '[t]amabox' # 看进程启动时间,与二进
```bash
./tamabox --help | sed -n '/COMMANDS/,/GLOBAL/p'
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot
# web / censor / uid / ban / restore / reports / restrict / unrestrict / hide-public / honeypot / backup / restore-images
```
> 想确认某个字符串有没有编进二进制,**不要用 `strings`** —— 它默认只输出可打印 ASCII,
@@ -77,10 +77,13 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
| `censor` | 重新审核历史内容 | 是 | 审核规则变更后复检 |
| `uid` | 为用户生成/补齐 uid | 是 | 数据修复 |
| `honeypot` | 扫描器诱捕取证(假登录页) | 部分是(`prune` / `purge-creds`) | 看谁在扫、拿到了什么字典 |
| `backup` | 备份 PostgreSQL 与配置 bucket 的全部 R2 对象 | 写本地备份目录 | 灾难恢复前留档 |
| `restore-images` | 校验并恢复 R2 图片及元数据 | 是(除 `--dry-run`) | 图片灾难恢复、隔离恢复演练 |
| `web` | 启动 Web 服务 | — | 生产入口 |
除 `reports`、`honeypot hits/stats/paths/size` 外,所有写操作都要求交互式输入大写 `YES` 确认;
除 `reports`、`honeypot hits/stats/paths/size` 外,既有运营写操作要求交互式输入大写 `YES` 确认;
脚本化时加 `--yes`。注意 `honeypot prune` / `purge-creds` 的确认词是 **`yes`**(小写)。
`restore-images` 不做交互确认:实际写入必须显式提供 `--yes`,`--dry-run` 除外。
---
@@ -329,7 +332,55 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 8. 典型处置流程
## 8. `backup` / `restore-images` —— 数据库与 R2 图片
### 8.1 备份
```bash
# 数据库 + 配置 bucket 的全部对象
./tamabox backup --output /var/backups/tamabox
# 只备份对象,或只备份数据库
./tamabox backup --images-only --output /var/backups/tamabox
./tamabox backup --db-only --output /var/backups/tamabox
```
图片备份使用**空前缀**分页列举配置 bucket 的全部对象,不再只查 `tamabox/`。根日期 key、
`picture/`、`tamabox/` 和静态资源都会按原 key 保存到 `<output>/images/`。
备份会先完成全部分页 List、key 与本地路径预检,再开始下载;非法路径、目录 key、文件/目录碰撞会中止备份。
`images-manifest.json` 记录实际下载字节的 `size`、`sha256`,以及 GET 返回的 `etag`、
`content_type`、`cache_control`。对象与 manifest 都采用临时文件原子落盘;下载流中断会整对象重试,
最终失败不会留下 `.part`。空 bucket 的 manifest 是 `[]`。
### 8.2 恢复与隔离演练
```bash
# 校验全部本地文件并检查默认防覆盖,不写远端
./tamabox restore-images --input /var/backups/tamabox --dry-run
# 实际恢复到原 key;必须显式 --yes
./tamabox restore-images --input /var/backups/tamabox --yes
# 明确允许替换已存在对象
./tamabox restore-images --input /var/backups/tamabox --yes --overwrite
# 在原完整 key 前加隔离前缀,不覆盖生产 key
./tamabox restore-images --input /var/backups/tamabox --yes \
--target-prefix tamabox-backup-test/manual-restore/
```
`--input` 可指向备份目录,也可直接指向 `images-manifest.json`。恢复会在任何远端操作前校验全部
manifest 条目和本地文件(普通文件、非 symlink、大小、实际 SHA-256)。默认先 Head 目标;任一目标已存在
即失败且零 Put,只有 `--overwrite` 才允许替换。`--dry-run` 不写对象,实际写入必须 `--yes`。
`--target-prefix` 会加在 manifest 的完整 key 前;非空前缀末尾没有 `/` 时会自动补一个。
Put 保留内容、原 key 路径(额外添加指定前缀)、`ContentType` 与 `CacheControl`,并使用单对象超时和有限重试。
**恢复不是事务**:中途失败会明确报告已成功数量,已上传对象不会自动回滚。
---
## 9. 典型处置流程
1. **发现**:收到告警邮件(同一箱主 24h ≥ 8 条 或 7d ≥ 20 条)→ 或箱主后台出现「被举报 N 次」。
2. **取证**:`reports --domain <domain>` 看条目、占比、历史处置。
@@ -351,7 +402,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 9. 排错
## 10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
@@ -374,7 +425,7 @@ pg_dump -Fc <dbname> > before-$(date +%F-%H%M).dump
---
## 10. 明确不做的事
## 11. 明确不做的事
- **不做自动处置**:举报数达标只发邮件,不自动封禁。这个场景里举报本身就是攻击武器。
- **不做申诉后台**:申诉走邮件,处置结果由 CLI 写入。
+1 -1
View File
@@ -125,7 +125,7 @@ require (
github.com/projectdiscovery/blackrock v0.0.2 // indirect
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
github.com/projectdiscovery/utils v0.11.1 // indirect
github.com/redis/go-redis/v9 v9.5.1 // indirect
github.com/redis/go-redis/v9 v9.5.1
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+33
View File
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
return e.value, true
}
// GetWithExpiry 读取缓存值与其过期时间,**不判断、不删除**过期条目。
//
// 供 stale-while-revalidate 使用:调用方拿到过期的旧值后可以先返回给用户,
// 再在后台重算。Get 做不到这一点——它遇到过期项就直接删掉并返回 false,
// 于是「过期」和「不存在」变成同一个结果,调用方只能同步等待重算。
func (c *TTLCache) GetWithExpiry(key string) (value interface{}, expiresAt time.Time, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok {
return nil, time.Time{}, false
}
return e.value, e.expiresAt, true
}
// Expire 把条目标记为「已过期」但**保留其值**。
//
// 与 Delete 的区别很关键:Delete 之后下次读是彻底 miss,调用方只能同步重算;
// Expire 之后下次读仍然拿得到旧值,于是可以「先返回旧值 + 后台刷新」。
// 写多读多的场景(例如投票:每来一票都要让结果失效)只有用 Expire 才不会
// 把每次写入都变成下一次读的同步 O(N) 重算。
// key 不存在或已过期时什么也不做。
func (c *TTLCache) Expire(key string) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return
}
e.expiresAt = time.Now().Add(-time.Second)
c.items[key] = e
}
// Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
+66
View File
@@ -59,3 +59,69 @@ func TestTTLCacheConcurrent(t *testing.T) {
}
wg.Wait()
}
// TestTTLCacheGetWithExpiryKeepsEntry 锁住一个关键语义:
// GetWithExpiry **不删除**过期条目,否则 stale-while-revalidate 拿不到旧值,
// 只能退化成「过期即彻底 miss → 同步重算」,那正是投票结果 O(N²) 的成因。
func TestTTLCacheGetWithExpiryKeepsEntry(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Nanosecond)
time.Sleep(2 * time.Millisecond)
v, exp, ok := c.GetWithExpiry("k")
if !ok {
t.Fatal("expired entry should still be readable via GetWithExpiry")
}
if v.(string) != "v" {
t.Fatalf("expected stale value v, got %v", v)
}
if !time.Now().After(exp) {
t.Fatal("entry should already be expired")
}
// 再读一次仍然在(证明上一次调用没有顺手删掉它)
if _, _, ok := c.GetWithExpiry("k"); !ok {
t.Fatal("GetWithExpiry must not evict the entry")
}
// 而普通 Get 仍按原语义返回 miss(并清掉它)
if _, ok := c.Get("k"); ok {
t.Fatal("Get should report miss for an expired entry")
}
}
// TestTTLCacheGetWithExpiryMissing 不存在的 key 必须返回 ok=false,
// 且不能凭空造出一个零值过期时间让调用方误判为「过期但有值」。
func TestTTLCacheGetWithExpiryMissing(t *testing.T) {
c := NewTTLCache(8)
if _, _, ok := c.GetWithExpiry("nope"); ok {
t.Fatal("expected miss for unknown key")
}
}
// TestTTLCacheExpireKeepsValue 锁住 Expire 与 Delete 的区别:
// Expire 后条目仍在(可拿到旧值并触发后台刷新),而 Delete 之后彻底 miss。
func TestTTLCacheExpireKeepsValue(t *testing.T) {
c := NewTTLCache(8)
c.Set("k", "v", time.Hour)
c.Expire("k")
v, exp, ok := c.GetWithExpiry("k")
if !ok || v.(string) != "v" {
t.Fatalf("Expire must keep the value, got %v %v", v, ok)
}
if !time.Now().After(exp) {
t.Fatal("Expire must push expiresAt into the past")
}
// 普通 Get 立刻视为过期
if _, ok := c.Get("k"); ok {
t.Fatal("Get should see the expired entry as a miss")
}
// 对不存在的 key 调用 Expire 不应 panic 也不应创建条目
c.Expire("absent")
if _, _, ok := c.GetWithExpiry("absent"); ok {
t.Fatal("Expire must not create an entry")
}
}
+585
View File
@@ -0,0 +1,585 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
imageObjectAttemptTimeout = 60 * time.Second
imageObjectMaxAttempts = 3
imageObjectRetryBackoff = 100 * time.Millisecond
)
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
//
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
// 都以根日期 key、picture/ 或 tamabox/ 等形式存在 R2 上,数据库里只存 key 或 CDN URL。
// 只备份数据库的话,恢复后这些引用指向的图片若已不在 R2,页面就全是裂图。
//
// 输出目录结构:
//
// <output>/
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
// images/<key> # R2 对象,保持原 key 路径
// images-manifest.json # key → 实际大小 / SHA-256 / GET 元数据
//
// 用法:TamaBox backup --output /var/backups/tamabox
//
// TamaBox backup --output . --db-only # 只备份数据库
// TamaBox backup --output . --images-only # 只备份图片
var Backup = &cli.Command{
Name: "backup",
Usage: "Back up the database and all R2 objects to a local directory",
Action: runBackup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Value: ".",
Usage: "Output directory for the backup files",
},
&cli.BoolFlag{
Name: "db-only",
Usage: "Only back up the database (skip R2 images)",
},
&cli.BoolFlag{
Name: "images-only",
Usage: "Only back up R2 images (skip the database)",
},
},
}
func runBackup(ctx *cli.Context) error {
if ctx.Bool("db-only") && ctx.Bool("images-only") {
return errors.New("--db-only and --images-only cannot be used together")
}
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
outDir := ctx.String("output")
if outDir == "" {
return errors.New("--output must not be empty")
}
if err := os.MkdirAll(outDir, 0o700); err != nil {
return errors.Wrapf(err, "create output directory %q", outDir)
}
dbOnly := ctx.Bool("db-only")
imagesOnly := ctx.Bool("images-only")
if !imagesOnly {
if err := backupDatabase(ctx.Context, outDir); err != nil {
return err
}
}
if !dbOnly {
if err := backupImages(ctx.Context, outDir); err != nil {
return err
}
}
logrus.WithField("dir", outDir).Info("备份完成")
return nil
}
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
//
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
func backupDatabase(ctx context.Context, outDir string) error {
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
}
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
}
ts := time.Now().Format("20060102-150405")
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
cmd := exec.CommandContext(ctx, pgDump,
"--host", conf.Database.Host,
"--port", fmt.Sprintf("%d", conf.Database.Port),
"--username", conf.Database.User,
"--dbname", conf.Database.Name,
"--no-owner", "--no-privileges",
"--format", "plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
var dumpErr strings.Builder
if err := writeFileAtomically(outFile, func(w io.Writer) error {
cmd.Stdout = w
cmd.Stderr = &dumpErr
if err := cmd.Run(); err != nil {
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
}
return nil
}); err != nil {
return err
}
logrus.WithField("file", outFile).Info("数据库已备份")
return nil
}
// writeFileAtomically 保留数据库、manifest 与对象下载共用的原子写修复:
// 只有完整写入、Sync 并关闭成功后才替换目标,失败时清理临时 .part 文件。
func writeFileAtomically(outFile string, write func(io.Writer) error) error {
f, err := os.CreateTemp(filepath.Dir(outFile), "."+filepath.Base(outFile)+"-*.part")
if err != nil {
return errors.Wrapf(err, "create temporary file for %q", outFile)
}
tmpFile := f.Name()
defer os.Remove(tmpFile)
defer f.Close()
if err := write(f); err != nil {
return err
}
if err := f.Sync(); err != nil {
return errors.Wrapf(err, "sync temporary file for %q", outFile)
}
if err := f.Close(); err != nil {
return errors.Wrapf(err, "close temporary file for %q", outFile)
}
if err := os.Rename(tmpFile, outFile); err != nil {
return errors.Wrapf(err, "finalize %q", outFile)
}
return nil
}
// imageManifestEntry 记录实际下载内容的大小和 SHA-256,以及 GET 返回的原始元数据。
// ETag 只是服务商元数据,不能当作内容 MD5 或完整性校验的替代品。
type imageManifestEntry struct {
Key string `json:"key"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
ETag string `json:"etag,omitempty"`
ContentType string `json:"content_type,omitempty"`
CacheControl string `json:"cache_control,omitempty"`
}
type backupS3API interface {
ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error)
}
type backupDownloadPlan struct {
key string
destination string
}
type downloadedImageObject struct {
size int64
sha256 string
etag string
contentType string
cacheControl string
}
// backupImages 默认使用空前缀列举配置 bucket 的全部对象,而不是假定图片都在 tamabox/ 下。
func backupImages(ctx context.Context, outDir string) error {
client, err := backupS3Client(ctx)
if err != nil {
return errors.Wrap(err, "get R2 client for backup")
}
return backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, outDir, "")
}
// backupImagesWithClient 允许测试和隔离演练显式指定 prefix。
// 所有分页 List、key 校验和本地路径预检都在第一次 GetObject 之前完成。
func backupImagesWithClient(ctx context.Context, client backupS3API, bucket, outDir, prefix string) error {
keys, err := listAllBackupKeys(ctx, client, bucket, prefix)
if err != nil {
return err
}
imageDir := filepath.Join(outDir, "images")
plans, err := planBackupDownloads(imageDir, keys)
if err != nil {
return err
}
manifest := make([]imageManifestEntry, 0, len(plans))
var totalBytes int64
for _, plan := range plans {
if err := ensureBackupDestinationDirectory(imageDir, plan.destination); err != nil {
return err
}
downloaded, err := downloadObject(ctx, client, bucket, plan.key, plan.destination)
if err != nil {
return err
}
manifest = append(manifest, imageManifestEntry{
Key: plan.key,
Size: downloaded.size,
SHA256: downloaded.sha256,
ETag: downloaded.etag,
ContentType: downloaded.contentType,
CacheControl: downloaded.cacheControl,
})
totalBytes += downloaded.size
}
manifestFile := filepath.Join(outDir, "images-manifest.json")
if err := writeFileAtomically(manifestFile, func(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(manifest); err != nil {
return errors.Wrap(err, "write manifest")
}
return nil
}); err != nil {
return err
}
logrus.WithFields(logrus.Fields{
"objects": len(manifest),
"bytes": totalBytes,
"dir": imageDir,
"prefix": prefix,
}).Info("图片已备份")
return nil
}
func listAllBackupKeys(ctx context.Context, client backupS3API, bucket, prefix string) ([]string, error) {
keys := make([]string, 0)
var continuationToken *string
seenTokens := make(map[string]struct{})
for {
page, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
ContinuationToken: continuationToken,
})
if err != nil {
return nil, errors.Wrapf(err, "list R2 objects prefix %q", prefix)
}
if page == nil {
return nil, errors.New("list R2 objects returned a nil page")
}
for _, object := range page.Contents {
// nil key 与空 key 一样是无效输入;统一转成空串,由完整 List 后的预检拒绝。
keys = append(keys, aws.ToString(object.Key))
}
if !aws.ToBool(page.IsTruncated) {
return keys, nil
}
next := aws.ToString(page.NextContinuationToken)
if next == "" {
return nil, errors.New("truncated R2 list response has no continuation token")
}
if _, exists := seenTokens[next]; exists {
return nil, errors.Errorf("R2 list continuation token %q repeated", next)
}
seenTokens[next] = struct{}{}
continuationToken = aws.String(next)
}
}
func planBackupDownloads(imageDir string, keys []string) ([]backupDownloadPlan, error) {
plans := make([]backupDownloadPlan, 0, len(keys))
files := make(map[string]string, len(keys))
directories := make(map[string]string)
root, err := filepath.Abs(imageDir)
if err != nil {
return nil, errors.Wrap(err, "resolve images directory")
}
directories[canonicalLocalPath(root)] = ""
for _, key := range keys {
destination, err := localImagePath(root, key)
if err != nil {
return nil, err
}
canonicalDestination := canonicalLocalPath(destination)
if previous, exists := files[canonicalDestination]; exists {
return nil, errors.Errorf("objects %q and %q map to the same local file", previous, key)
}
if previous, exists := directories[canonicalDestination]; exists {
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
}
for parent := filepath.Dir(destination); pathWithinDirectory(root, parent); parent = filepath.Dir(parent) {
canonicalParent := canonicalLocalPath(parent)
if previous, exists := files[canonicalParent]; exists {
return nil, errors.Errorf("file/directory collision between objects %q and %q", previous, key)
}
if _, exists := directories[canonicalParent]; !exists {
directories[canonicalParent] = key
}
if parent == root {
break
}
}
files[canonicalDestination] = key
plans = append(plans, backupDownloadPlan{key: key, destination: destination})
}
if err := prepareBackupImageRoot(root); err != nil {
return nil, err
}
for _, plan := range plans {
if err := preflightBackupLocalPath(root, plan.destination); err != nil {
return nil, err
}
}
return plans, nil
}
func validateObjectKey(key string) error {
if key == "" {
return errors.New("R2 object key must not be empty")
}
if strings.ContainsRune(key, 0) {
return errors.Errorf("R2 object key %q contains NUL", key)
}
if strings.Contains(key, `\`) {
return errors.Errorf("R2 object key %q contains a backslash", key)
}
if strings.HasSuffix(key, "/") {
return errors.Errorf("R2 object key %q is a directory key", key)
}
if strings.HasPrefix(key, "/") || filepath.IsAbs(filepath.FromSlash(key)) || filepath.VolumeName(filepath.FromSlash(key)) != "" ||
(len(key) >= 2 && ((key[0] >= 'A' && key[0] <= 'Z') || (key[0] >= 'a' && key[0] <= 'z')) && key[1] == ':') {
return errors.Errorf("R2 object key %q is an absolute or volume-qualified path", key)
}
for _, segment := range strings.Split(key, "/") {
if segment == "" || segment == "." || segment == ".." {
return errors.Errorf("R2 object key %q contains an empty or dot path segment", key)
}
}
return nil
}
func localImagePath(imageDir, key string) (string, error) {
if err := validateObjectKey(key); err != nil {
return "", err
}
root, err := filepath.Abs(imageDir)
if err != nil {
return "", errors.Wrap(err, "resolve images directory")
}
destination := filepath.Join(root, filepath.FromSlash(key))
if !pathWithinDirectory(root, destination) || destination == root {
return "", errors.Errorf("R2 object key %q escapes the images directory", key)
}
return destination, nil
}
func pathWithinDirectory(root, candidate string) bool {
rel, err := filepath.Rel(root, candidate)
if err != nil || filepath.IsAbs(rel) {
return false
}
return rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func canonicalLocalPath(path string) string {
cleaned := filepath.Clean(path)
if runtime.GOOS == "windows" {
return strings.ToLower(cleaned)
}
return cleaned
}
func prepareBackupImageRoot(imageDir string) error {
info, err := os.Lstat(imageDir)
if os.IsNotExist(err) {
if err := os.MkdirAll(imageDir, 0o700); err != nil {
return errors.Wrap(err, "create images directory")
}
info, err = os.Lstat(imageDir)
}
if err != nil {
return errors.Wrap(err, "inspect images directory")
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("images path %q must be a real directory, not a symlink or file", imageDir)
}
return nil
}
func preflightBackupLocalPath(imageDir, destination string) error {
info, err := os.Lstat(destination)
if err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
return errors.Errorf("backup destination %q is not a regular file", destination)
}
if err != nil && !os.IsNotExist(err) {
return errors.Wrapf(err, "inspect backup destination %q", destination)
}
for parent := filepath.Dir(destination); parent != imageDir; parent = filepath.Dir(parent) {
if !pathWithinDirectory(imageDir, parent) {
return errors.Errorf("backup destination %q escapes images directory", destination)
}
info, err := os.Lstat(parent)
if os.IsNotExist(err) {
continue
}
if err != nil {
return errors.Wrapf(err, "inspect backup parent %q", parent)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("backup parent %q is not a real directory", parent)
}
}
return nil
}
func ensureBackupDestinationDirectory(imageDir, destination string) error {
root, err := filepath.Abs(imageDir)
if err != nil {
return err
}
parent := filepath.Dir(destination)
rel, err := filepath.Rel(root, parent)
if err != nil || !pathWithinDirectory(root, parent) {
return errors.Errorf("invalid backup destination %q", destination)
}
if rel == "." {
return nil
}
current := root
for _, segment := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, segment)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
if err := os.Mkdir(current, 0o700); err != nil && !os.IsExist(err) {
return errors.Wrapf(err, "create backup directory %q", current)
}
info, err = os.Lstat(current)
}
if err != nil {
return errors.Wrapf(err, "inspect backup directory %q", current)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("backup parent %q is not a real directory", current)
}
}
return nil
}
// backupS3Client 与上传、恢复共用 storage.NewS3Client 的 endpoint 语义:
// endpoint 可不带路径或只带 /<bucket>,请求始终以 Bucket 参数和原始完整 key 寻址。
// List 返回什么 key 就下载什么 key,不额外添加或剥离 bucket 前缀。
func backupS3Client(ctx context.Context) (*s3.Client, error) {
return storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
}
// downloadObject 原子下载单个对象,并返回实际落盘内容的 SHA-256 和 GET 元数据。
// 每次尝试都有独立 60 秒 context;流中断会从头重试整个对象,最多 3 次。
func downloadObject(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
var lastErr error
for attempt := 0; attempt < imageObjectMaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return downloadedImageObject{}, err
}
attemptCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout)
downloaded, err := downloadObjectOnce(attemptCtx, client, bucket, key, destination)
cancel()
if err == nil {
return downloaded, nil
}
lastErr = err
if ctx.Err() != nil {
return downloadedImageObject{}, ctx.Err()
}
if attempt == imageObjectMaxAttempts-1 {
break
}
if err := waitForImageObjectRetry(ctx, imageObjectRetryBackoff<<attempt); err != nil {
return downloadedImageObject{}, err
}
}
return downloadedImageObject{}, errors.Wrapf(lastErr, "download %q after %d attempts", key, imageObjectMaxAttempts)
}
func downloadObjectOnce(ctx context.Context, client backupS3API, bucket, key, destination string) (downloadedImageObject, error) {
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return downloadedImageObject{}, errors.Wrapf(err, "get R2 object %q", key)
}
if out == nil || out.Body == nil {
return downloadedImageObject{}, errors.Errorf("get R2 object %q returned an empty response body", key)
}
hash := sha256.New()
var size int64
writeErr := writeFileAtomically(destination, func(w io.Writer) error {
var err error
size, err = io.Copy(io.MultiWriter(w, hash), out.Body)
if err != nil {
return errors.Wrapf(err, "download %q", key)
}
if out.ContentLength != nil && size != aws.ToInt64(out.ContentLength) {
return errors.Wrapf(io.ErrUnexpectedEOF, "download %q: GET content length is %d, received %d", key, aws.ToInt64(out.ContentLength), size)
}
return nil
})
_ = out.Body.Close()
if writeErr != nil {
return downloadedImageObject{}, writeErr
}
return downloadedImageObject{
size: size,
sha256: hex.EncodeToString(hash.Sum(nil)),
etag: aws.ToString(out.ETag),
contentType: aws.ToString(out.ContentType),
cacheControl: aws.ToString(out.CacheControl),
}, nil
}
func waitForImageObjectRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+437
View File
@@ -0,0 +1,437 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
const maxImageManifestBytes = 64 << 20 // 64 MiB,足以容纳大量对象且避免恶意清单耗尽内存。
// RestoreImages 从 backup 生成的 images-manifest.json 和 images/ 恢复 R2 对象。
var RestoreImages = &cli.Command{
Name: "restore-images",
Usage: "Validate and restore backed-up images to R2",
Action: runRestoreImages,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Required: true,
Usage: "Backup directory or images-manifest.json path",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "Validate local files and remote targets without writing objects",
},
&cli.BoolFlag{
Name: "yes",
Usage: "Confirm actual remote writes",
},
&cli.BoolFlag{
Name: "overwrite",
Usage: "Allow replacing objects that already exist",
},
&cli.StringFlag{
Name: "target-prefix",
Value: "",
Usage: "Prefix prepended to every restored key (for isolated drills)",
},
},
}
type restoreImagesOptions struct {
Input string
DryRun bool
Yes bool
Overwrite bool
TargetPrefix string
}
// restoreObjectStore 的 PutObject 是带重试的高层操作,便于离线 fake 精确断言零写入。
type restoreObjectStore interface {
HeadObject(context.Context, *s3.HeadObjectInput, ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
PutObject(context.Context, *s3.PutObjectInput) error
}
type r2RestoreObjectStore struct {
client *s3.Client
}
func (s *r2RestoreObjectStore) HeadObject(ctx context.Context, input *s3.HeadObjectInput, options ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
return s.client.HeadObject(ctx, input, options...)
}
func (s *r2RestoreObjectStore) PutObject(ctx context.Context, input *s3.PutObjectInput) error {
// storage.PutObject 提供每次独立 60 秒超时、最多 3 次重试、Body 回绕和可取消退避。
return storage.PutObject(ctx, s.client, input)
}
type restorePlannedObject struct {
entry imageManifestEntry
sourcePath string
targetKey string
}
func runRestoreImages(ctx *cli.Context) error {
opts := restoreImagesOptions{
Input: ctx.String("input"),
DryRun: ctx.Bool("dry-run"),
Yes: ctx.Bool("yes"),
Overwrite: ctx.Bool("overwrite"),
TargetPrefix: ctx.String("target-prefix"),
}
if opts.Input == "" {
return errors.New("--input must not be empty")
}
if !opts.DryRun && !opts.Yes {
return errors.New("actual restore writes require --yes (or use --dry-run)")
}
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
client, err := backupS3Client(ctx.Context)
if err != nil {
return errors.Wrap(err, "get R2 client for image restore")
}
return restoreImagesWithStore(ctx.Context, &r2RestoreObjectStore{client: client}, conf.Upload.ImageBucket, opts)
}
func restoreImagesWithStore(ctx context.Context, store restoreObjectStore, bucket string, opts restoreImagesOptions) error {
if opts.Input == "" {
return errors.New("restore input must not be empty")
}
if !opts.DryRun && !opts.Yes {
return errors.New("actual restore writes require --yes (or use --dry-run)")
}
manifestPath, imageDir, err := resolveRestoreInput(opts.Input)
if err != nil {
return err
}
entries, err := readImageManifest(manifestPath)
if err != nil {
return err
}
// 关键安全顺序:先验证所有 manifest 条目和全部本地文件,再做任何 Head/Put。
// 因此重复 key、非法 hash/size、缺文件、篡改或 symlink 都不会造成部分上传。
objects := make([]restorePlannedObject, 0, len(entries))
for _, entry := range entries {
targetKey, err := restoreTargetKey(opts.TargetPrefix, entry.Key)
if err != nil {
return err
}
sourcePath, err := localImagePath(imageDir, entry.Key)
if err != nil {
return err
}
if err := validateRestoreLocalFile(imageDir, sourcePath, entry); err != nil {
return err
}
objects = append(objects, restorePlannedObject{
entry: entry,
sourcePath: sourcePath,
targetKey: targetKey,
})
}
// 默认先 Head 所有目标。任何目标已存在都会在 Put 循环开始前失败,保证零 Put。
if !opts.Overwrite {
for _, object := range objects {
headCtx, cancel := context.WithTimeout(ctx, imageObjectAttemptTimeout)
_, err := store.HeadObject(headCtx, &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(object.targetKey),
})
cancel()
if err == nil {
return errors.Errorf("restore target %q already exists; no objects were uploaded (use --overwrite to replace)", object.targetKey)
}
if !isS3ObjectNotFound(err) {
return errors.Wrapf(err, "check restore target %q; no objects were uploaded", object.targetKey)
}
}
}
if opts.DryRun {
logrus.WithFields(logrus.Fields{
"objects": len(objects),
"prefix": opts.TargetPrefix,
}).Info("图片恢复演练完成,未写入对象")
return nil
}
succeeded := 0
for _, object := range objects {
file, err := openRestoreFileForPut(object.sourcePath, object.entry.Size)
if err != nil {
return errors.Wrapf(err, "open validated restore file for %q after %d successful uploads; restore is not transactional", object.entry.Key, succeeded)
}
input := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(object.targetKey),
Body: file,
ContentLength: aws.Int64(object.entry.Size),
ContentType: nonEmptyStringPointer(object.entry.ContentType),
CacheControl: nonEmptyStringPointer(object.entry.CacheControl),
}
putErr := store.PutObject(ctx, input)
closeErr := file.Close()
if putErr != nil {
return errors.Wrapf(putErr, "restore object %q to %q failed after %d successful uploads; restore is not transactional", object.entry.Key, object.targetKey, succeeded)
}
if closeErr != nil {
return errors.Wrapf(closeErr, "close restored file %q (remote object was uploaded); restore is not transactional", object.sourcePath)
}
succeeded++
}
logrus.WithFields(logrus.Fields{
"objects": succeeded,
"prefix": opts.TargetPrefix,
}).Info("图片恢复完成")
return nil
}
func resolveRestoreInput(input string) (manifestPath, imageDir string, err error) {
info, err := os.Lstat(input)
if err != nil {
return "", "", errors.Wrapf(err, "inspect restore input %q", input)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", "", errors.Errorf("restore input %q must not be a symlink", input)
}
manifestPath = input
if info.IsDir() {
manifestPath = filepath.Join(input, "images-manifest.json")
} else if !info.Mode().IsRegular() {
return "", "", errors.Errorf("restore input %q is neither a directory nor a regular manifest file", input)
}
return manifestPath, filepath.Join(filepath.Dir(manifestPath), "images"), nil
}
func readImageManifest(manifestPath string) ([]imageManifestEntry, error) {
info, err := os.Lstat(manifestPath)
if err != nil {
return nil, errors.Wrapf(err, "inspect image manifest %q", manifestPath)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.Errorf("image manifest %q must be a regular non-symlink file", manifestPath)
}
if info.Size() > maxImageManifestBytes {
return nil, errors.Errorf("image manifest %q exceeds the %d-byte limit", manifestPath, maxImageManifestBytes)
}
file, err := os.Open(manifestPath)
if err != nil {
return nil, errors.Wrapf(err, "open image manifest %q", manifestPath)
}
data, readErr := io.ReadAll(io.LimitReader(file, maxImageManifestBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, errors.Wrap(readErr, "read image manifest")
}
if closeErr != nil {
return nil, errors.Wrap(closeErr, "close image manifest")
}
if len(data) > maxImageManifestBytes {
return nil, errors.Errorf("image manifest %q exceeds the %d-byte limit", manifestPath, maxImageManifestBytes)
}
decoder := json.NewDecoder(bytes.NewReader(data))
var entries []imageManifestEntry
if err := decoder.Decode(&entries); err != nil {
return nil, errors.Wrap(err, "decode image manifest")
}
if entries == nil {
return nil, errors.New("image manifest must be a JSON array, not null")
}
var trailing json.RawMessage
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, errors.New("image manifest contains trailing JSON")
}
return nil, errors.Wrap(err, "image manifest contains trailing data")
}
seen := make(map[string]struct{}, len(entries))
for index := range entries {
entry := &entries[index]
if err := validateObjectKey(entry.Key); err != nil {
return nil, errors.Wrapf(err, "invalid manifest entry %d", index)
}
if _, exists := seen[entry.Key]; exists {
return nil, errors.Errorf("image manifest contains duplicate key %q", entry.Key)
}
seen[entry.Key] = struct{}{}
if entry.Size < 0 {
return nil, errors.Errorf("manifest entry %q has negative size %d", entry.Key, entry.Size)
}
decodedHash, err := hex.DecodeString(entry.SHA256)
if err != nil || len(decodedHash) != sha256.Size {
return nil, errors.Errorf("manifest entry %q has invalid SHA-256 %q", entry.Key, entry.SHA256)
}
entry.SHA256 = strings.ToLower(entry.SHA256)
}
return entries, nil
}
func restoreTargetKey(prefix, key string) (string, error) {
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
target := prefix + key
if err := validateObjectKey(target); err != nil {
return "", errors.Wrapf(err, "invalid restore target for source key %q", key)
}
return target, nil
}
func validateRestoreLocalFile(imageDir, sourcePath string, entry imageManifestEntry) error {
root, err := filepath.Abs(imageDir)
if err != nil {
return err
}
if err := validateRestoreDirectoryChain(root, filepath.Dir(sourcePath)); err != nil {
return err
}
info, err := os.Lstat(sourcePath)
if err != nil {
return errors.Wrapf(err, "inspect local image for %q", entry.Key)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.Errorf("local image for %q must be a regular non-symlink file", entry.Key)
}
if info.Size() != entry.Size {
return errors.Errorf("local image size mismatch for %q: manifest=%d actual=%d", entry.Key, entry.Size, info.Size())
}
file, err := os.Open(sourcePath)
if err != nil {
return errors.Wrapf(err, "open local image for %q", entry.Key)
}
openedInfo, statErr := file.Stat()
if statErr != nil {
_ = file.Close()
return errors.Wrapf(statErr, "stat opened local image for %q", entry.Key)
}
if !openedInfo.Mode().IsRegular() || openedInfo.Size() != entry.Size {
_ = file.Close()
return errors.Errorf("opened local image for %q is not the expected regular file", entry.Key)
}
hash := sha256.New()
_, hashErr := io.Copy(hash, file)
closeErr := file.Close()
if hashErr != nil {
return errors.Wrapf(hashErr, "hash local image for %q", entry.Key)
}
if closeErr != nil {
return errors.Wrapf(closeErr, "close local image for %q", entry.Key)
}
actualHash := hex.EncodeToString(hash.Sum(nil))
if actualHash != entry.SHA256 {
return errors.Errorf("local image SHA-256 mismatch for %q: manifest=%s actual=%s", entry.Key, entry.SHA256, actualHash)
}
return nil
}
func validateRestoreDirectoryChain(root, parent string) error {
rootInfo, err := os.Lstat(root)
if err != nil {
return errors.Wrapf(err, "inspect restore images directory %q", root)
}
if rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() {
return errors.Errorf("restore images path %q must be a real directory", root)
}
rel, err := filepath.Rel(root, parent)
if err != nil || !pathWithinDirectory(root, parent) {
return errors.Errorf("restore file parent %q escapes images directory", parent)
}
if rel == "." {
return nil
}
current := root
for _, segment := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, segment)
info, err := os.Lstat(current)
if err != nil {
return errors.Wrapf(err, "inspect restore parent directory %q", current)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Errorf("restore parent %q must be a real directory", current)
}
}
return nil
}
func openRestoreFileForPut(sourcePath string, expectedSize int64) (*os.File, error) {
info, err := os.Lstat(sourcePath)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != expectedSize {
return nil, fmt.Errorf("local file %q changed after validation", sourcePath)
}
file, err := os.Open(sourcePath)
if err != nil {
return nil, err
}
openedInfo, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if !openedInfo.Mode().IsRegular() || openedInfo.Size() != expectedSize {
_ = file.Close()
return nil, fmt.Errorf("local file %q changed after validation", sourcePath)
}
return file, nil
}
func isS3ObjectNotFound(err error) bool {
if err == nil {
return false
}
var statusErr interface{ HTTPStatusCode() int }
if errors.As(err, &statusErr) && statusErr.HTTPStatusCode() == 404 {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NotFound", "NoSuchKey", "404":
return true
}
}
return false
}
func nonEmptyStringPointer(value string) *string {
if value == "" {
return nil
}
return aws.String(value)
}
@@ -0,0 +1,184 @@
package cmd
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/storage"
)
// TestR2BackupRestoreIntegration 默认跳过。只有显式设置 TB_R2_INTEGRATION=1,
// 并通过 NEKOBOX_CONFIG_PATH 提供真实 R2 配置时才运行。
// 测试只访问 tamabox-backup-test/<随机>/ 下的 source/ 与 target/ 两个专属前缀,
// 绝不执行全桶 List,也不会读取、覆盖或删除其他 key。
func TestR2BackupRestoreIntegration(t *testing.T) {
if os.Getenv("TB_R2_INTEGRATION") != "1" {
t.Skip("set TB_R2_INTEGRATION=1 to run the real R2 integration test")
}
if os.Getenv("NEKOBOX_CONFIG_PATH") == "" {
t.Fatal("NEKOBOX_CONFIG_PATH must point to the R2 integration configuration")
}
require.NoError(t, conf.Init())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
client, err := storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
require.NoError(t, err)
random := make([]byte, 12)
_, err = rand.Read(random)
require.NoError(t, err)
basePrefix := fmt.Sprintf("tamabox-backup-test/%d-%s/", time.Now().UTC().UnixNano(), hex.EncodeToString(random))
sourcePrefix := basePrefix + "source/"
targetPrefix := basePrefix + "target/"
objects := []struct {
relativeKey string
body []byte
contentType string
cacheControl string
}{
{
relativeKey: "2026/11/04/root.webp",
body: []byte("tamabox-r2-integration-root"),
contentType: "image/webp",
cacheControl: "public, max-age=60",
},
{
relativeKey: "picture/2026/11/04/picture.webp",
body: []byte("tamabox-r2-integration-picture"),
contentType: "image/webp",
cacheControl: "public, max-age=120",
},
}
// created 只记录本测试成功创建的精确 key。任何失败清理也只删除这些 key。
created := make(map[string]bool)
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cleanupCancel()
for key := range created {
if err := storage.DeleteObject(cleanupCtx, client, conf.Upload.ImageBucket, key); err != nil {
t.Errorf("clean up own R2 key %q: %v", key, err)
}
}
})
for _, object := range objects {
key := sourcePrefix + object.relativeKey
err := storage.PutObject(ctx, client, &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(key),
Body: bytes.NewReader(object.body),
ContentLength: aws.Int64(int64(len(object.body))),
ContentType: aws.String(object.contentType),
CacheControl: aws.String(object.cacheControl),
})
require.NoError(t, err)
created[key] = true
}
backupDir := t.TempDir()
require.NoError(t, backupImagesWithClient(ctx, client, conf.Upload.ImageBucket, backupDir, sourcePrefix))
manifest, err := readImageManifest(backupDir + string(os.PathSeparator) + "images-manifest.json")
require.NoError(t, err)
require.Len(t, manifest, len(objects))
manifestKeys := make(map[string]bool, len(manifest))
for _, entry := range manifest {
manifestKeys[entry.Key] = true
}
for _, object := range objects {
require.True(t, manifestKeys[sourcePrefix+object.relativeKey])
}
for _, object := range objects {
sourceKey := sourcePrefix + object.relativeKey
require.NoError(t, storage.DeleteObject(ctx, client, conf.Upload.ImageBucket, sourceKey))
delete(created, sourceKey)
}
store := &integrationTrackingRestoreStore{
base: &r2RestoreObjectStore{client: client},
onUpload: func(key string) {
created[key] = true
},
}
require.NoError(t, restoreImagesWithStore(ctx, store, conf.Upload.ImageBucket, restoreImagesOptions{
Input: backupDir,
Yes: true,
TargetPrefix: targetPrefix,
}))
for _, object := range objects {
targetKey := targetPrefix + sourcePrefix + object.relativeKey
require.True(t, created[targetKey], "restore must report its own successful Put")
output, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(targetKey),
})
require.NoError(t, err)
body, readErr := io.ReadAll(output.Body)
closeErr := output.Body.Close()
require.NoError(t, readErr)
require.NoError(t, closeErr)
digest := sha256.Sum256(object.body)
require.Equal(t, fmt.Sprintf("%x", digest), fmt.Sprintf("%x", sha256.Sum256(body)))
require.Equal(t, object.contentType, aws.ToString(output.ContentType))
require.Equal(t, object.cacheControl, aws.ToString(output.CacheControl))
}
for key := range created {
require.NoError(t, storage.DeleteObject(ctx, client, conf.Upload.ImageBucket, key))
delete(created, key)
}
requireOwnPrefixEmpty(ctx, t, client, conf.Upload.ImageBucket, sourcePrefix)
requireOwnPrefixEmpty(ctx, t, client, conf.Upload.ImageBucket, targetPrefix)
}
type integrationTrackingRestoreStore struct {
base restoreObjectStore
onUpload func(string)
}
func (s *integrationTrackingRestoreStore) HeadObject(ctx context.Context, input *s3.HeadObjectInput, options ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
return s.base.HeadObject(ctx, input, options...)
}
func (s *integrationTrackingRestoreStore) PutObject(ctx context.Context, input *s3.PutObjectInput) error {
if err := s.base.PutObject(ctx, input); err != nil {
return err
}
s.onUpload(aws.ToString(input.Key))
return nil
}
func requireOwnPrefixEmpty(ctx context.Context, t *testing.T, client *s3.Client, bucket, prefix string) {
t.Helper()
require.NotEmpty(t, prefix)
output, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
require.NoError(t, err)
require.Empty(t, output.Contents, "专属 prefix 清理后必须为空:%q", prefix)
require.False(t, aws.ToBool(output.IsTruncated), "随机专属 prefix 下对象数不应触发分页")
}
+603
View File
@@ -0,0 +1,603 @@
package cmd
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/require"
)
type fakeBackupObject struct {
body []byte
etag string
contentType string
cacheControl string
interruptedAttempts int
wrongLengthAttempts int
}
type fakeBackupS3 struct {
pages []*s3.ListObjectsV2Output
listInputs []*s3.ListObjectsV2Input
objects map[string]fakeBackupObject
getCalls map[string]int
getOrder []string
}
func (f *fakeBackupS3) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
f.listInputs = append(f.listInputs, input)
index := len(f.listInputs) - 1
if index >= len(f.pages) {
return nil, fmt.Errorf("unexpected ListObjectsV2 call %d", index+1)
}
return f.pages[index], nil
}
func (f *fakeBackupS3) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
key := aws.ToString(input.Key)
if f.getCalls == nil {
f.getCalls = make(map[string]int)
}
f.getCalls[key]++
f.getOrder = append(f.getOrder, key)
object, exists := f.objects[key]
if !exists {
return nil, fmt.Errorf("unexpected GetObject key %q", key)
}
var body io.ReadCloser
if f.getCalls[key] <= object.interruptedAttempts {
body = io.NopCloser(&interruptedReader{data: append([]byte(nil), object.body...)})
} else {
body = io.NopCloser(strings.NewReader(string(object.body)))
}
contentLength := int64(len(object.body))
if f.getCalls[key] <= object.wrongLengthAttempts {
contentLength++
}
return &s3.GetObjectOutput{
Body: body,
ContentLength: aws.Int64(contentLength),
ETag: aws.String(object.etag),
ContentType: aws.String(object.contentType),
CacheControl: aws.String(object.cacheControl),
}, nil
}
type interruptedReader struct {
data []byte
}
func (r *interruptedReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.ErrUnexpectedEOF
}
n := copy(p, r.data)
r.data = r.data[n:]
if len(r.data) == 0 {
return n, io.ErrUnexpectedEOF
}
return n, nil
}
func backupListPage(keys []string, truncated bool, nextToken string) *s3.ListObjectsV2Output {
objects := make([]types.Object, 0, len(keys))
for _, key := range keys {
objects = append(objects, types.Object{
Key: aws.String(key),
Size: aws.Int64(999999), // 故意不可信;manifest 必须记录实际下载字节数。
ETag: aws.String("list-etag-must-not-be-used"),
})
}
output := &s3.ListObjectsV2Output{Contents: objects, IsTruncated: aws.Bool(truncated)}
if nextToken != "" {
output.NextContinuationToken = aws.String(nextToken)
}
return output
}
func TestBackupImagesWithClientCoversAllKeysAndPages(t *testing.T) {
keys := []string{
"2026/11/04/root.webp",
"picture/2026/11/04/picture.webp",
"tamabox/2025/12/historical.jpg",
"static/site.css",
}
objects := make(map[string]fakeBackupObject, len(keys))
for index, key := range keys {
objects[key] = fakeBackupObject{
body: []byte(fmt.Sprintf("actual-body-%d", index)),
etag: fmt.Sprintf("get-etag-%d", index),
contentType: []string{"image/webp", "image/webp", "image/jpeg", "text/css"}[index],
cacheControl: fmt.Sprintf("public, max-age=%d", index+60),
}
}
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{
backupListPage(keys[:2], true, "page-2"),
backupListPage(keys[2:], false, ""),
},
objects: objects,
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "configured-bucket", outDir, ""))
require.Len(t, client.listInputs, 2)
for index, input := range client.listInputs {
require.Equal(t, "configured-bucket", aws.ToString(input.Bucket))
require.Equal(t, "", aws.ToString(input.Prefix), "生产默认必须列举整个 bucket")
if index == 0 {
require.Nil(t, input.ContinuationToken)
} else {
require.Equal(t, "page-2", aws.ToString(input.ContinuationToken))
}
}
require.ElementsMatch(t, keys, client.getOrder)
manifestBytes, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
require.NoError(t, err)
var manifest []imageManifestEntry
require.NoError(t, json.Unmarshal(manifestBytes, &manifest))
require.Len(t, manifest, len(keys))
for index, entry := range manifest {
require.Equal(t, keys[index], entry.Key)
object := objects[entry.Key]
require.Equal(t, int64(len(object.body)), entry.Size)
digest := sha256.Sum256(object.body)
require.Equal(t, fmt.Sprintf("%x", digest), entry.SHA256)
require.Equal(t, object.etag, entry.ETag)
require.Equal(t, object.contentType, entry.ContentType)
require.Equal(t, object.cacheControl, entry.CacheControl)
path, err := localImagePath(filepath.Join(outDir, "images"), entry.Key)
require.NoError(t, err)
content, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, object.body, content)
}
}
func TestBackupImagesWithClientSupportsIsolatedPrefixAndEmptyManifest(t *testing.T) {
t.Run("prefix", func(t *testing.T) {
key := "tamabox-backup-test/unit/source/root.webp"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("isolated"), contentType: "image/webp"},
},
}
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "tamabox-backup-test/unit/source/"))
require.Len(t, client.listInputs, 1)
require.Equal(t, "tamabox-backup-test/unit/source/", aws.ToString(client.listInputs[0].Prefix))
require.Equal(t, []string{key}, client.getOrder)
})
t.Run("empty array", func(t *testing.T) {
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(nil, false, "")}}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
content, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
require.NoError(t, err)
require.Equal(t, "[]", strings.TrimSpace(string(content)))
})
}
func TestBackupRejectsPathAttacksBeforeAnyDownload(t *testing.T) {
tests := []struct {
name string
key *string
}{
{name: "nil key", key: nil},
{name: "empty key", key: aws.String("")},
{name: "absolute POSIX", key: aws.String("/outside")},
{name: "absolute Windows", key: aws.String("C:/outside")},
{name: "volume relative", key: aws.String("C:outside")},
{name: "parent", key: aws.String("../outside")},
{name: "nested parent", key: aws.String("picture/../outside")},
{name: "backslash", key: aws.String(`picture\outside`)},
{name: "NUL", key: aws.String("picture/\x00outside")},
{name: "empty segment", key: aws.String("picture//outside")},
{name: "leading dot segment", key: aws.String("./outside")},
{name: "inner dot segment", key: aws.String("picture/./outside")},
{name: "directory", key: aws.String("picture/")},
{name: "dot only", key: aws.String(".")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := backupListPage(nil, false, "")
page.Contents = []types.Object{{Key: tt.key}}
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{page}}
err := backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "")
require.Error(t, err)
require.Empty(t, client.getOrder, "非法 key 必须在任何下载前被拒绝")
})
}
}
func TestBackupPreflightsAllPagesAndLocalCollisionsBeforeDownload(t *testing.T) {
t.Run("invalid key on later page", func(t *testing.T) {
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{
backupListPage([]string{"picture/valid.webp"}, true, "next"),
backupListPage([]string{"../escape"}, false, ""),
},
objects: map[string]fakeBackupObject{"picture/valid.webp": {body: []byte("valid")}},
}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
require.Len(t, client.listInputs, 2)
require.Empty(t, client.getOrder)
})
for _, keys := range [][]string{{"a", "a/b"}, {"a/b", "a"}, {"same", "same"}} {
t.Run(fmt.Sprintf("collision %q", keys), func(t *testing.T) {
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(keys, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
require.Empty(t, client.getOrder)
})
}
t.Run("existing file blocks planned directory", func(t *testing.T) {
outDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(outDir, "images", "a"), []byte("existing"), 0o600))
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a/b"}, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Empty(t, client.getOrder)
})
t.Run("existing directory blocks planned file", func(t *testing.T) {
outDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images", "a"), 0o700))
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a"}, false, "")}}
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Empty(t, client.getOrder)
})
}
func TestBackupRetriesInterruptedStreamsAndLeavesNoPart(t *testing.T) {
t.Run("eventual success", func(t *testing.T) {
key := "picture/retry.webp"
body := []byte("complete-object-after-retries")
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: body, interruptedAttempts: 2, etag: "successful-get-etag"},
},
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Equal(t, 3, client.getCalls[key])
content, err := os.ReadFile(filepath.Join(outDir, "images", "picture", "retry.webp"))
require.NoError(t, err)
require.Equal(t, body, content)
requireNoPartFiles(t, filepath.Join(outDir, "images", "picture"))
})
t.Run("short body relative to GET content length", func(t *testing.T) {
key := "short-body.webp"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("complete-on-second-attempt"), wrongLengthAttempts: 1},
},
}
outDir := t.TempDir()
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
require.Equal(t, 2, client.getCalls[key])
requireNoPartFiles(t, filepath.Join(outDir, "images"))
})
t.Run("final failure", func(t *testing.T) {
key := "retry.txt"
client := &fakeBackupS3{
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
objects: map[string]fakeBackupObject{
key: {body: []byte("never-complete"), interruptedAttempts: 3},
},
}
outDir := t.TempDir()
err := backupImagesWithClient(context.Background(), client, "bucket", outDir, "")
require.Error(t, err)
require.Contains(t, err.Error(), "after 3 attempts")
require.Equal(t, 3, client.getCalls[key])
require.NoFileExists(t, filepath.Join(outDir, "images", key))
requireNoPartFiles(t, filepath.Join(outDir, "images"))
require.NoFileExists(t, filepath.Join(outDir, "images-manifest.json"))
})
}
type capturedRestorePut struct {
key string
body []byte
contentType string
cacheControl string
size int64
}
type fakeRestoreStore struct {
existing map[string]bool
headErrors map[string]error
putFailures map[string]error
headCalls []string
puts []capturedRestorePut
}
func (f *fakeRestoreStore) HeadObject(_ context.Context, input *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
key := aws.ToString(input.Key)
f.headCalls = append(f.headCalls, key)
if err := f.headErrors[key]; err != nil {
return nil, err
}
if f.existing[key] {
return &s3.HeadObjectOutput{}, nil
}
return nil, &types.NotFound{}
}
func (f *fakeRestoreStore) PutObject(_ context.Context, input *s3.PutObjectInput) error {
body, err := io.ReadAll(input.Body)
if err != nil {
return err
}
key := aws.ToString(input.Key)
f.puts = append(f.puts, capturedRestorePut{
key: key,
body: body,
contentType: aws.ToString(input.ContentType),
cacheControl: aws.ToString(input.CacheControl),
size: aws.ToInt64(input.ContentLength),
})
return f.putFailures[key]
}
type restoreFixtureObject struct {
key string
body []byte
storedBody []byte
manifestSize *int64
manifestSHA string
contentType string
cacheControl string
}
func createRestoreFixture(t *testing.T, objects []restoreFixtureObject) string {
t.Helper()
dir := t.TempDir()
manifest := make([]imageManifestEntry, 0, len(objects))
for _, object := range objects {
storedBody := object.storedBody
if storedBody == nil {
storedBody = object.body
}
path := filepath.Join(dir, "images", filepath.FromSlash(object.key))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
require.NoError(t, os.WriteFile(path, storedBody, 0o600))
size := int64(len(object.body))
if object.manifestSize != nil {
size = *object.manifestSize
}
digest := sha256.Sum256(object.body)
hash := fmt.Sprintf("%x", digest)
if object.manifestSHA != "" {
hash = object.manifestSHA
}
manifest = append(manifest, imageManifestEntry{
Key: object.key,
Size: size,
SHA256: hash,
ETag: "source-get-etag",
ContentType: object.contentType,
CacheControl: object.cacheControl,
})
}
data, err := json.MarshalIndent(manifest, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "images-manifest.json"), data, 0o600))
return dir
}
func TestRestoreImagesPreservesKeysContentAndMetadataWithTargetPrefix(t *testing.T) {
objects := []restoreFixtureObject{
{key: "2026/11/04/root.webp", body: []byte("root-body"), contentType: "image/webp", cacheControl: "public, max-age=60"},
{key: "picture/2026/11/04/pic.webp", body: []byte("picture-body"), contentType: "image/webp", cacheControl: "public, max-age=120"},
{key: "static/site.css", body: []byte("body{}"), contentType: "text/css", cacheControl: "no-cache"},
}
input := createRestoreFixture(t, objects)
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: input,
Yes: true,
TargetPrefix: "isolated-restore", // 不带斜杠也应规范化为恰好一个路径分隔符。
})
require.NoError(t, err)
require.Len(t, store.headCalls, len(objects))
require.Len(t, store.puts, len(objects))
byKey := make(map[string]capturedRestorePut, len(store.puts))
for _, put := range store.puts {
byKey[put.key] = put
}
for _, object := range objects {
target := "isolated-restore/" + object.key
put := byKey[target]
require.Equal(t, object.body, put.body)
require.Equal(t, object.contentType, put.contentType)
require.Equal(t, object.cacheControl, put.cacheControl)
require.Equal(t, int64(len(object.body)), put.size)
}
}
func TestRestoreImagesDefaultRefusesOverwriteWithZeroPut(t *testing.T) {
objects := []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
{key: "second.webp", body: []byte("second")},
}
store := &fakeRestoreStore{existing: map[string]bool{"second.webp": true}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, objects),
Yes: true,
})
require.Error(t, err)
require.Contains(t, err.Error(), "no objects were uploaded")
require.Empty(t, store.puts)
}
func TestRestoreImagesOverwriteAndDryRun(t *testing.T) {
t.Run("overwrite", func(t *testing.T) {
store := &fakeRestoreStore{existing: map[string]bool{"picture.webp": true}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("replacement")}}),
Yes: true,
Overwrite: true,
})
require.NoError(t, err)
require.Empty(t, store.headCalls, "显式 overwrite 不需要默认防覆盖 Head")
require.Len(t, store.puts, 1)
require.Equal(t, []byte("replacement"), store.puts[0].body)
})
t.Run("dry run without yes", func(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("dry-run")}}),
DryRun: true,
TargetPrefix: "drill/",
})
require.NoError(t, err)
require.Equal(t, []string{"drill/picture.webp"}, store.headCalls)
require.Empty(t, store.puts)
})
t.Run("actual write requires yes", func(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("write")}}),
})
require.Error(t, err)
require.Contains(t, err.Error(), "--yes")
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
})
}
func TestRestoreRejectsTamperedFilesBeforeAnyRemoteCall(t *testing.T) {
wrongSize := int64(999)
tests := []struct {
name string
second restoreFixtureObject
}{
{
name: "SHA256 tamper with unchanged size",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), storedBody: []byte("SECOND"),
},
},
{
name: "size tamper",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), manifestSize: &wrongSize,
},
},
{
name: "manifest hash tamper",
second: restoreFixtureObject{
key: "second.webp", body: []byte("second"), manifestSHA: strings.Repeat("0", 64),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeRestoreStore{}
input := createRestoreFixture(t, []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
tt.second,
})
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, Yes: true})
require.Error(t, err)
require.Empty(t, store.headCalls, "全部本地文件应在任何远端操作前完成校验")
require.Empty(t, store.puts, "首个有效文件也不能在后续文件校验失败前上传")
})
}
}
func TestReadImageManifestRejectsInvalidStructure(t *testing.T) {
validHash := strings.Repeat("a", 64)
tests := []struct {
name string
json string
}{
{name: "null", json: `null`},
{name: "trailing JSON", json: `[] {}`},
{name: "trailing garbage", json: `[] garbage`},
{name: "duplicate key", json: fmt.Sprintf(`[{"key":"same","size":0,"sha256":%q},{"key":"same","size":0,"sha256":%q}]`, validHash, validHash)},
{name: "short hash", json: `[{"key":"image","size":1,"sha256":"abc"}]`},
{name: "non hex hash", json: fmt.Sprintf(`[{"key":"image","size":1,"sha256":%q}]`, strings.Repeat("z", 64))},
{name: "negative size", json: fmt.Sprintf(`[{"key":"image","size":-1,"sha256":%q}]`, validHash)},
{name: "path traversal", json: fmt.Sprintf(`[{"key":"../image","size":1,"sha256":%q}]`, validHash)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "images-manifest.json")
// 非数组用例直接保留原 JSON;其余用例同样应被解析层拒绝。
require.NoError(t, os.WriteFile(path, []byte(tt.json), 0o600))
_, err := readImageManifest(path)
require.Error(t, err)
})
}
}
func TestRestoreRejectsSymlinkBeforeRemoteCalls(t *testing.T) {
input := createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}})
source := filepath.Join(input, "images", "picture.webp")
outside := filepath.Join(t.TempDir(), "outside.webp")
require.NoError(t, os.WriteFile(outside, []byte("content"), 0o600))
require.NoError(t, os.Remove(source))
if err := os.Symlink(outside, source); err != nil {
t.Skipf("当前平台无法创建 symlink:%v", err)
}
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, DryRun: true})
require.Error(t, err)
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
}
func TestRestoreReportsPartialFailureWithoutClaimingTransaction(t *testing.T) {
store := &fakeRestoreStore{putFailures: map[string]error{"second.webp": errors.New("injected put failure")}}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{
{key: "first.webp", body: []byte("first")},
{key: "second.webp", body: []byte("second")},
}),
Yes: true,
})
require.Error(t, err)
require.Contains(t, err.Error(), "1 successful uploads")
require.Contains(t, err.Error(), "not transactional")
require.Len(t, store.puts, 2)
}
func TestRestoreRejectsUnsafeTargetPrefixBeforeRemoteCalls(t *testing.T) {
store := &fakeRestoreStore{}
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}}),
Yes: true,
TargetPrefix: "../outside/",
})
require.Error(t, err)
require.Empty(t, store.headCalls)
require.Empty(t, store.puts)
}
+96
View File
@@ -0,0 +1,96 @@
package cmd
import (
"encoding/json"
"errors"
"flag"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
)
func TestRunBackupRejectsMutuallyExclusiveModes(t *testing.T) {
set := flag.NewFlagSet("backup", flag.ContinueOnError)
set.Bool("db-only", true, "")
set.Bool("images-only", true, "")
err := runBackup(cli.NewContext(nil, set, nil))
require.EqualError(t, err, "--db-only and --images-only cannot be used together")
}
func TestWriteFileAtomically(t *testing.T) {
t.Run("success", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
require.NoError(t, writeFileAtomically(target, func(w io.Writer) error {
_, err := io.WriteString(w, "complete backup")
return err
}))
content, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, "complete backup", string(content))
requireNoPartFiles(t, dir)
})
t.Run("failure preserves previous backup", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
require.NoError(t, os.WriteFile(target, []byte("known good"), 0o600))
writeErr := errors.New("dump failed")
err := writeFileAtomically(target, func(w io.Writer) error {
_, _ = io.WriteString(w, "partial")
return writeErr
})
require.ErrorIs(t, err, writeErr)
content, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, "known good", string(content))
requireNoPartFiles(t, dir)
})
t.Run("failure leaves no new backup", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "backup.sql")
writeErr := errors.New("dump failed")
err := writeFileAtomically(target, func(w io.Writer) error {
_, _ = io.WriteString(w, "partial")
return writeErr
})
require.ErrorIs(t, err, writeErr)
require.NoFileExists(t, target)
requireNoPartFiles(t, dir)
})
}
func requireNoPartFiles(t *testing.T, dir string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(dir, "*.part"))
require.NoError(t, err)
require.Empty(t, matches)
}
func TestImageManifestEntryIntegrityFields(t *testing.T) {
entry := imageManifestEntry{
Key: "tamabox/image.webp",
Size: 3,
SHA256: "actual-content-sha256",
ETag: "provider-etag",
}
encoded, err := json.Marshal(entry)
require.NoError(t, err)
require.JSONEq(t, `{
"key":"tamabox/image.webp",
"size":3,
"sha256":"actual-content-sha256",
"etag":"provider-etag"
}`, string(encoded))
require.NotContains(t, string(encoded), "md5")
}
+3 -1
View File
@@ -95,7 +95,9 @@ func runBan(ctx *cli.Context) error {
return errors.Wrap(err, "ban user and cleanup")
}
logrus.WithField("user_id", user.ID).Info("用户已封禁,关联数据已清理")
// 数据库事务已经提交,先明确输出这一事实,再进行非事务性的 R2 清理。
logrus.WithField("user_id", user.ID).Info("数据库封禁与关联数据清理已提交")
drainR2OutboxAfterBan(ctx.Context)
return nil
}
+3
View File
@@ -25,6 +25,7 @@ import (
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
// TamaBox honeypot size 报告表行数与磁盘占用
// TamaBox honeypot report --period week 生成安全汇总周报(含地图)并发邮件
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
//
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
@@ -68,6 +69,8 @@ var Honeypot = &cli.Command{
Usage: "Report honeypot table row count and disk usage",
Action: runHoneypotSize,
},
// 周期安全汇总:把命中按 IP 定位到城市,出地图报告并邮件送达。
reportCommand,
{
Name: "paths",
Usage: "List active decoy paths (built-in + honeypot_paths)",
+365
View File
@@ -0,0 +1,365 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/storage"
)
const (
r2OutboxDefaultLimit = 100
r2OutboxWorkerInterval = time.Minute
r2OutboxInitialBackoff = time.Minute
r2OutboxMaxBackoff = time.Hour
r2OutboxMaxLastErrorSize = 1024
)
var errR2OutboxNotConfigured = errors.New("R2 configuration is incomplete")
// R2Outbox 有界处理一批持久化的 R2 对象删除任务。
var R2Outbox = &cli.Command{
Name: "r2-outbox",
Usage: "Drain a bounded batch of pending R2 object deletion tasks",
Action: runR2Outbox,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "limit",
Value: r2OutboxDefaultLimit,
Usage: "Maximum number of due tasks to process",
},
},
}
type r2OutboxTaskStore interface {
ListDue(ctx context.Context, limit int) ([]*db.R2DeleteTask, error)
Delete(id uint) error
MarkFailed(id uint, nextRetry time.Time, lastError string) error
HasReferences(ctx context.Context, bucket, key string) (bool, error)
}
type r2ObjectDeleter interface {
DeleteObject(ctx context.Context, bucket, key string) error
}
type r2S3ObjectDeleter struct {
client *s3.Client
}
func (d *r2S3ObjectDeleter) DeleteObject(ctx context.Context, bucket, key string) error {
return storage.DeleteObject(ctx, d.client, bucket, key)
}
type r2OutboxResult struct {
Listed int
Removed int
ObjectsDeleted int
ReferenceOnly int
Failed int
Skipped int
Remaining int
}
// processR2Outbox 串行处理一批到期任务。单个任务失败不会中断同批后续任务;
// context 取消则立即停止,且不会把关闭过程中的取消误记为一次任务失败。
func processR2Outbox(ctx context.Context, store r2OutboxTaskStore, deleter r2ObjectDeleter, limit int) (result r2OutboxResult, err error) {
defer func() {
result.Remaining = result.Listed - result.Removed
}()
tasks, err := store.ListDue(ctx, limit)
if err != nil {
if ctx.Err() != nil {
return result, ctx.Err()
}
return result, fmt.Errorf("list R2 outbox tasks: %s", sanitizeR2OutboxError(err))
}
result.Listed = len(tasks)
for _, task := range tasks {
if err := ctx.Err(); err != nil {
return result, err
}
if task == nil {
result.Failed++
logrus.Warn("R2 outbox 返回了空任务,已跳过")
continue
}
logger := logrus.WithField("task_id", task.ID)
if task.Bucket != conf.Upload.ImageBucket {
result.Skipped++
logger.Warn("R2 outbox 任务 bucket 与当前配置不匹配,任务已保留")
continue
}
referenced, refErr := store.HasReferences(ctx, task.Bucket, task.Key)
if ctx.Err() != nil {
return result, ctx.Err()
}
if refErr != nil {
result.Failed++
markR2OutboxTaskFailed(store, task, refErr)
continue
}
if referenced {
// 还有引用时只清理过期 outbox 记录,绝不删除对象。最后一个引用被
// 删除时,对应数据库事务会重新入队一条任务。
if err := store.Delete(task.ID); err != nil {
result.Failed++
logger.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox 引用任务清理失败,任务已保留")
continue
}
result.Removed++
result.ReferenceOnly++
continue
}
deleteErr := deleter.DeleteObject(ctx, task.Bucket, task.Key)
if deleteErr != nil && !isS3ObjectNotFound(deleteErr) {
if ctx.Err() != nil {
return result, ctx.Err()
}
result.Failed++
markR2OutboxTaskFailed(store, task, deleteErr)
continue
}
// storage.DeleteObject 已将 404 归一为成功;这里再次识别 404,保证其他
// deleter 实现也遵循相同的幂等语义。只有任务记录也删除后才算完成。
if err := store.Delete(task.ID); err != nil {
result.Failed++
logger.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 对象已删除,但 outbox 任务清理失败;后续将幂等重试")
continue
}
result.Removed++
result.ObjectsDeleted++
}
return result, nil
}
func markR2OutboxTaskFailed(store r2OutboxTaskStore, task *db.R2DeleteTask, cause error) {
nextRetry := time.Now().Add(r2OutboxBackoff(task.Attempts))
lastError := sanitizeR2OutboxError(cause)
if err := store.MarkFailed(task.ID, nextRetry, lastError); err != nil {
logrus.WithFields(logrus.Fields{
"task_id": task.ID,
"processing_error": lastError,
"persist_error": sanitizeR2OutboxError(err),
}).Warn("R2 outbox 任务失败且重试状态写入失败,任务已保留")
return
}
logrus.WithFields(logrus.Fields{
"task_id": task.ID,
"error": lastError,
"next_retry": nextRetry,
}).Warn("R2 outbox 任务处理失败,已安排重试")
}
func r2OutboxBackoff(attempts int) time.Duration {
if attempts < 0 {
attempts = 0
}
// 1、2、4、8、16、32 分钟后,下一次本应到 64 分钟,直接封顶一小时。
if attempts >= 6 {
return r2OutboxMaxBackoff
}
return r2OutboxInitialBackoff << attempts
}
// sanitizeR2OutboxError 在持久化或记录错误前移除已知配置与凭据,并限制为
// 1024 字节。截断保持 UTF-8 边界,避免向 text 列写入半个多字节字符。
func sanitizeR2OutboxError(err error) string {
if err == nil {
return ""
}
message := err.Error()
sensitiveValues := []string{
conf.Database.DSN,
conf.Database.Password,
conf.Upload.ImageEndpoint,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
conf.Upload.ImageBucket,
conf.Upload.ImageBucketCDNHost,
}
for _, value := range sensitiveValues {
if value != "" {
message = strings.ReplaceAll(message, value, "[redacted]")
}
}
if len(message) <= r2OutboxMaxLastErrorSize {
return message
}
end := r2OutboxMaxLastErrorSize
for end > 0 && !utf8.ValidString(message[:end]) {
end--
}
return message[:end]
}
func r2OutboxConfigured() bool {
return strings.TrimSpace(conf.Upload.ImageEndpoint) != "" &&
strings.TrimSpace(conf.Upload.ImageAccessID) != "" &&
strings.TrimSpace(conf.Upload.ImageAccessSecret) != "" &&
strings.TrimSpace(conf.Upload.ImageBucket) != ""
}
func newR2OutboxObjectDeleter(ctx context.Context) (r2ObjectDeleter, error) {
if !r2OutboxConfigured() {
return nil, errR2OutboxNotConfigured
}
client, err := storage.NewS3Client(
ctx,
conf.Upload.ImageEndpoint,
conf.Upload.ImageBucket,
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
)
if err != nil {
return nil, fmt.Errorf("initialize R2 client: %s", sanitizeR2OutboxError(err))
}
return &r2S3ObjectDeleter{client: client}, nil
}
// startR2OutboxWorker 只能在 db.Init 成功后调用。goroutine 由调用方提供的
// WaitGroup 跟踪,Web 退出时先取消 ctx,再等待正在执行的一轮收尾。
func startR2OutboxWorker(ctx context.Context, wg *sync.WaitGroup) {
if !r2OutboxConfigured() {
return
}
deleter, err := newR2OutboxObjectDeleter(ctx)
if err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox worker 启动失败,Web 服务继续运行")
return
}
wg.Add(1)
go func() {
defer wg.Done()
runR2OutboxWorker(ctx, db.R2DeleteTasks, deleter)
}()
}
func runR2OutboxWorker(ctx context.Context, store r2OutboxTaskStore, deleter r2ObjectDeleter) {
ticker := time.NewTicker(r2OutboxWorkerInterval)
defer ticker.Stop()
for {
// 启动先立即跑一轮;处理期间不启动并发轮次。若一轮超过一分钟,
// ticker 只会合并唤醒,不会形成多个 worker 同时消费。
_, err := processR2Outbox(ctx, store, deleter, r2OutboxDefaultLimit)
if err != nil {
if ctx.Err() != nil {
return
}
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("R2 outbox 本轮处理失败,将在下一分钟重试")
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func drainR2Outbox(ctx context.Context, limit int) (r2OutboxResult, error) {
deleter, err := newR2OutboxObjectDeleter(ctx)
if err != nil {
return r2OutboxResult{}, err
}
return processR2Outbox(ctx, db.R2DeleteTasks, deleter, limit)
}
func runR2Outbox(ctx *cli.Context) error {
limit := ctx.Int("limit")
if limit <= 0 {
return errors.New("--limit must be greater than zero")
}
if err := conf.Init(); err != nil {
return fmt.Errorf("load configuration: %w", err)
}
if !r2OutboxConfigured() {
return errR2OutboxNotConfigured
}
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return fmt.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
gdb, err := db.Init(dbType, dsn)
if err != nil {
return fmt.Errorf("connect to database: %w", err)
}
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn("关闭数据库连接池失败")
}
}()
}
result, err := drainR2Outbox(ctx.Context, limit)
if err != nil {
return fmt.Errorf("drain R2 outbox: %s", sanitizeR2OutboxError(err))
}
logR2OutboxResult("R2 outbox 本轮处理完成", result)
return nil
}
func logR2OutboxResult(message string, result r2OutboxResult) {
entry := logrus.WithFields(logrus.Fields{
"listed": result.Listed,
"removed": result.Removed,
"object_deletes": result.ObjectsDeleted,
"reference_only": result.ReferenceOnly,
"failed": result.Failed,
"skipped": result.Skipped,
"remaining": result.Remaining,
})
if result.Remaining > 0 {
entry.Warn(message + ",仍有任务待重试或保留")
return
}
entry.Info(message)
}
// drainR2OutboxAfterBan 在封禁事务提交后执行。R2 失败只影响对象清理进度,
// 不向 runBan 返回错误,避免运维误以为需要重新执行不可逆的数据库封禁。
func drainR2OutboxAfterBan(ctx context.Context) {
result, err := drainR2Outbox(ctx, r2OutboxDefaultLimit)
if err != nil {
logrus.WithField("error", sanitizeR2OutboxError(err)).Warn(
"数据库封禁与清理已提交,但 R2 outbox 本轮未能处理;任务仍保留,无需重跑 ban,可稍后运行 r2-outbox",
)
return
}
if result.Remaining > 0 {
logR2OutboxResult("数据库封禁与清理已提交;R2 对象清理将在后续重试,无需重跑 ban", result)
return
}
logR2OutboxResult("数据库封禁与清理已提交;R2 outbox 本轮处理完成", result)
}
+270
View File
@@ -0,0 +1,270 @@
package cmd
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
)
type fakeR2OutboxFailure struct {
nextRetry time.Time
lastError string
}
type fakeR2OutboxStore struct {
tasks []*db.R2DeleteTask
references map[string]bool
referenceErrs map[string]error
referenceCalls []string
deleted map[uint]bool
deleteErr error
failures map[uint]fakeR2OutboxFailure
markFailedErr error
}
func newFakeR2OutboxStore(tasks ...*db.R2DeleteTask) *fakeR2OutboxStore {
return &fakeR2OutboxStore{
tasks: tasks,
references: make(map[string]bool),
referenceErrs: make(map[string]error),
deleted: make(map[uint]bool),
failures: make(map[uint]fakeR2OutboxFailure),
}
}
func (s *fakeR2OutboxStore) ListDue(ctx context.Context, limit int) ([]*db.R2DeleteTask, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
tasks := s.tasks
if limit >= 0 && len(tasks) > limit {
tasks = tasks[:limit]
}
return append([]*db.R2DeleteTask(nil), tasks...), nil
}
func (s *fakeR2OutboxStore) Delete(id uint) error {
if s.deleteErr != nil {
return s.deleteErr
}
s.deleted[id] = true
return nil
}
func (s *fakeR2OutboxStore) MarkFailed(id uint, nextRetry time.Time, lastError string) error {
if s.markFailedErr != nil {
return s.markFailedErr
}
s.failures[id] = fakeR2OutboxFailure{nextRetry: nextRetry, lastError: lastError}
return nil
}
func (s *fakeR2OutboxStore) HasReferences(ctx context.Context, _, key string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
s.referenceCalls = append(s.referenceCalls, key)
return s.references[key], s.referenceErrs[key]
}
type fakeR2DeleteCall struct {
bucket string
key string
}
type fakeR2ObjectDeleter struct {
calls []fakeR2DeleteCall
errs map[string]error
onDelete func(context.Context, string, string) error
}
func (d *fakeR2ObjectDeleter) DeleteObject(ctx context.Context, bucket, key string) error {
d.calls = append(d.calls, fakeR2DeleteCall{bucket: bucket, key: key})
if d.onDelete != nil {
return d.onDelete(ctx, bucket, key)
}
return d.errs[key]
}
type fakeR2HTTPStatusError int
func (e fakeR2HTTPStatusError) Error() string {
return fmt.Sprintf("R2 returned HTTP %d", int(e))
}
func (e fakeR2HTTPStatusError) HTTPStatusCode() int {
return int(e)
}
func setR2OutboxTestConfig(t *testing.T) {
t.Helper()
old := conf.Upload
conf.Upload.ImageEndpoint = "https://r2.example.test"
conf.Upload.ImageAccessID = "test-access-id"
conf.Upload.ImageAccessSecret = "test-secret"
conf.Upload.ImageBucket = "configured-bucket"
conf.Upload.ImageBucketCDNHost = "cdn.example.test"
t.Cleanup(func() {
conf.Upload = old
})
}
func TestProcessR2OutboxSuccessAndNotFound(t *testing.T) {
setR2OutboxTestConfig(t)
for _, tt := range []struct {
name string
deleteErr error
}{
{name: "success"},
{name: "not found is success", deleteErr: fakeR2HTTPStatusError(404)},
} {
t.Run(tt.name, func(t *testing.T) {
first := &db.R2DeleteTask{ID: 1, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
second := &db.R2DeleteTask{ID: 2, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
store := newFakeR2OutboxStore(first, second)
deleter := &fakeR2ObjectDeleter{errs: map[string]error{"second.webp": tt.deleteErr}}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Equal(t, 2, result.Listed)
require.Equal(t, 2, result.Removed)
require.Equal(t, 2, result.ObjectsDeleted)
require.Zero(t, result.Failed)
require.Zero(t, result.Remaining)
require.Equal(t, []fakeR2DeleteCall{
{bucket: conf.Upload.ImageBucket, key: "first.webp"},
{bucket: conf.Upload.ImageBucket, key: "second.webp"},
}, deleter.calls, "任务必须按 ListDue 顺序串行处理")
require.True(t, store.deleted[first.ID])
require.True(t, store.deleted[second.ID])
require.Empty(t, store.failures)
})
}
}
func TestProcessR2OutboxReferencedTaskDeletesNoObject(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 11, Bucket: conf.Upload.ImageBucket, Key: "shared.webp"}
store := newFakeR2OutboxStore(task)
store.references[task.Key] = true
deleter := &fakeR2ObjectDeleter{}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, deleter.calls, "仍有引用时绝不能调用 R2 删除")
require.True(t, store.deleted[task.ID], "过期任务本身应删除,最后引用移除时会重新入队")
require.Equal(t, 1, result.ReferenceOnly)
require.Equal(t, 1, result.Removed)
require.Zero(t, result.ObjectsDeleted)
require.Zero(t, result.Remaining)
}
func TestProcessR2OutboxReferenceErrorSchedulesRetry(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 21, Bucket: conf.Upload.ImageBucket, Key: "unknown.webp"}
store := newFakeR2OutboxStore(task)
store.referenceErrs[task.Key] = errors.New("reference query failed")
deleter := &fakeR2ObjectDeleter{}
before := time.Now()
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, deleter.calls)
require.False(t, store.deleted[task.ID])
require.Contains(t, store.failures, task.ID)
failure := store.failures[task.ID]
require.Equal(t, "reference query failed", failure.lastError)
require.WithinDuration(t, before.Add(time.Minute), failure.nextRetry, 2*time.Second)
require.Equal(t, 1, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxDeleteFailureUsesBackoffAndBoundsLastError(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 31, Bucket: conf.Upload.ImageBucket, Key: "retry.webp", Attempts: 2}
store := newFakeR2OutboxStore(task)
longErr := fmt.Errorf("delete failed endpoint=%s credential=%s %s",
conf.Upload.ImageEndpoint,
conf.Upload.ImageAccessSecret,
strings.Repeat("x", 1100),
)
deleter := &fakeR2ObjectDeleter{errs: map[string]error{task.Key: longErr}}
before := time.Now()
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Len(t, deleter.calls, 1)
require.False(t, store.deleted[task.ID])
require.Contains(t, store.failures, task.ID)
failure := store.failures[task.ID]
require.WithinDuration(t, before.Add(4*time.Minute), failure.nextRetry, 2*time.Second)
require.LessOrEqual(t, len(failure.lastError), r2OutboxMaxLastErrorSize)
require.NotContains(t, failure.lastError, conf.Upload.ImageEndpoint)
require.NotContains(t, failure.lastError, conf.Upload.ImageAccessSecret)
require.Equal(t, 1, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxBucketMismatchIsRetainedWithoutDeletion(t *testing.T) {
setR2OutboxTestConfig(t)
task := &db.R2DeleteTask{ID: 41, Bucket: "different-bucket", Key: "foreign.webp"}
store := newFakeR2OutboxStore(task)
deleter := &fakeR2ObjectDeleter{}
result, err := processR2Outbox(context.Background(), store, deleter, 100)
require.NoError(t, err)
require.Empty(t, store.referenceCalls, "bucket 不匹配时不应查询当前 bucket 的引用")
require.Empty(t, deleter.calls)
require.Empty(t, store.failures)
require.False(t, store.deleted[task.ID])
require.Equal(t, 1, result.Skipped)
require.Zero(t, result.Failed)
require.Equal(t, 1, result.Remaining)
}
func TestProcessR2OutboxContextCancellationStopsWithoutMarkFailed(t *testing.T) {
setR2OutboxTestConfig(t)
first := &db.R2DeleteTask{ID: 51, Bucket: conf.Upload.ImageBucket, Key: "first.webp"}
second := &db.R2DeleteTask{ID: 52, Bucket: conf.Upload.ImageBucket, Key: "second.webp"}
store := newFakeR2OutboxStore(first, second)
ctx, cancel := context.WithCancel(context.Background())
deleter := &fakeR2ObjectDeleter{
onDelete: func(ctx context.Context, _, _ string) error {
cancel()
return ctx.Err()
},
}
result, err := processR2Outbox(ctx, store, deleter, 100)
require.ErrorIs(t, err, context.Canceled)
require.Len(t, deleter.calls, 1, "取消后不得继续处理第二个任务")
require.Empty(t, store.failures, "优雅关闭导致的取消不应增加任务失败次数")
require.Empty(t, store.deleted)
require.Zero(t, result.Failed)
require.Equal(t, 2, result.Remaining)
}
func TestR2OutboxBackoffCapsAtOneHour(t *testing.T) {
for _, tt := range []struct {
attempts int
expected time.Duration
}{
{attempts: -1, expected: time.Minute},
{attempts: 0, expected: time.Minute},
{attempts: 1, expected: 2 * time.Minute},
{attempts: 2, expected: 4 * time.Minute},
{attempts: 5, expected: 32 * time.Minute},
{attempts: 6, expected: time.Hour},
{attempts: 100, expected: time.Hour},
} {
require.Equal(t, tt.expected, r2OutboxBackoff(tt.attempts))
}
}
+551
View File
@@ -0,0 +1,551 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"fmt"
"html"
"html/template"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/geoip"
"github.com/tamakyi/TamaBox/internal/security/report"
"github.com/tamakyi/TamaBox/internal/shared"
)
// reportCommand 是 honeypot report 子命令。
var reportCommand = &cli.Command{
Name: "report",
Usage: "Generate a weekly/monthly security summary (map + email)",
Description: "统计周期内的蜜罐命中,把来源 IP 通过公共 IP 库定位到城市,\n" +
" 汇总成带世界地图的报告,并(可选)发送到 security_alert_email。",
Flags: []cli.Flag{
&cli.StringFlag{Name: "period", Value: "week", Usage: "week or month"},
&cli.StringFlag{Name: "to", Usage: "Override recipient (default: honeypot_report_email / security_alert_email)"},
&cli.StringFlag{Name: "dir", Usage: "Output directory (default: honeypot_report_dir)"},
&cli.BoolFlag{Name: "no-email", Usage: "Only generate files, do not send email"},
},
Action: runHoneypotReport,
}
// reportResult 一次生成的产物。
type reportResult struct {
Summary *report.Summary
HTMLPath string
PNGPath string
To string
Sent bool
}
// runHoneypotReport 手动生成/补发一份报告。
//
// 存在的意义不只是「测一下能不能跑」:自动周报发出去以后,运维经常需要
// 「把上周的再发我一份」或「先出一份看看内容对不对」——手动入口必须与
// 自动入口走完全相同的代码路径,否则手动验证过的东西和线上跑的不是一回事。
func runHoneypotReport(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
now := time.Now()
var period report.Period
switch strings.ToLower(strings.TrimSpace(ctx.String("period"))) {
case "week", "w", "":
period = report.PreviousWeek(now)
case "month", "m":
period = report.PreviousMonth(now)
default:
return errors.Errorf("--period 只能是 week 或 month(收到 %q)", ctx.String("period"))
}
dir := ctx.String("dir")
if dir == "" {
dir = conf.Security.HoneypotReportDir
}
res, err := buildSecurityReport(context.Background(), database, period, dir)
if err != nil {
return err
}
printSecurityReport(res)
if ctx.Bool("no-email") {
fmt.Println()
fmt.Printf("已生成(未发信):\n %s\n %s\n", res.HTMLPath, res.PNGPath)
return nil
}
to := strings.TrimSpace(ctx.String("to"))
if to == "" {
to = reportRecipient()
}
if to == "" {
return errors.New("未配置收件人:请在 [security] 设置 honeypot_report_email 或 security_alert_email,或用 --to 指定")
}
if err := sendSecurityReport(res, to); err != nil {
return err
}
fmt.Printf("\n已发送到 %s\n", to)
return nil
}
// reportRecipient 返回报告收件人。
//
// 优先 honeypot_report_email,回退 security_alert_email —— 后者早就存在,
// 不给已有部署增加必填项;想分开投递的人再单独配前者。
func reportRecipient() string {
if v := strings.TrimSpace(conf.Security.HoneypotReportEmail); v != "" {
return v
}
return strings.TrimSpace(conf.Security.SecurityAlertEmail)
}
// buildSecurityReport 跑完整的「读数据 → 定位 → 聚合 → 落盘」流程。
func buildSecurityReport(cctx context.Context, database *gorm.DB, period report.Period, dir string) (*reportResult, error) {
store := db.NewHoneypotStore(database)
sources, err := store.AggregateSources(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "聚合蜜罐来源")
}
paths, err := store.AggregatePaths(cctx, period.Start, period.End, 20)
if err != nil {
return nil, errors.Wrap(err, "聚合被探测路径")
}
stamps, err := store.TimestampsBetween(cctx, period.Start, period.End)
if err != nil {
return nil, errors.Wrap(err, "读取命中时间戳")
}
// 上一周期同为等长窗口,用于「较上期 ↑/↓」。
prevHits, err := countBetween(cctx, store, period.Start.Add(-period.End.Sub(period.Start)), period.Start)
if err != nil {
return nil, errors.Wrap(err, "统计上一周期")
}
// 先按 LimitKey 合并,再只对合并后的键做定位 —— 顺序按命中数降序,
// 因为腾讯精修有配额上限,要优先花在最活跃的来源上。
keys := mergeKeysByHits(sources)
resolver := geoip.New(geoip.Options{
Provider: conf.Security.IPGeoProvider,
TencentKey: conf.Security.IPGeoTencentKey,
})
geo := report.ResolveWithCache(cctx, db.NewIPGeoStore(database), resolver, keys)
sum := report.Build(report.Input{
Period: period,
Sources: sources,
Paths: paths,
Timestamps: stamps,
Geo: geo,
PrevHits: prevHits,
ResolverDesc: resolver.Describe(),
Now: time.Now(),
})
htmlPath, pngPath, err := report.WriteFiles(sum, dir, report.HTMLOptions{
// 报告里的可交互地图用同一个 key(腾讯位置服务)。
TencentMapKey: conf.Security.IPGeoTencentKey,
})
if err != nil {
return nil, errors.Wrap(err, "写报告文件")
}
return &reportResult{Summary: sum, HTMLPath: htmlPath, PNGPath: pngPath}, nil
}
// countBetween 统计 [from, to) 的命中数。
func countBetween(cctx context.Context, store db.HoneypotStore, from, to time.Time) (int64, error) {
after, err := store.CountSince(cctx, from)
if err != nil {
return 0, err
}
upTo, err := store.CountSince(cctx, to)
if err != nil {
return 0, err
}
// CountSince 是「>= since」,两次相减即区间内数量;
// 时钟回拨等异常情况下可能出现负数,钳到 0。
if after < upTo {
return 0, nil
}
return after - upTo, nil
}
// mergeKeysByHits 把来源按封禁口径(LimitKey)合并,返回按命中数降序的键。
func mergeKeysByHits(sources []*db.HoneypotSourceAgg) []string {
hits := make(map[string]int64, len(sources))
for _, s := range sources {
hits[shared.LimitKey(s.IP)] += s.Hits
}
keys := make([]string, 0, len(hits))
for k := range hits {
keys = append(keys, k)
}
// 按命中数降序;同次数按键名升序保证顺序稳定 —— 否则每次跑报告时
// 「腾讯精修覆盖的那 N 个 IP」会随机漂移,两个周期的数据不可比。
sort.Slice(keys, func(i, j int) bool {
if hits[keys[i]] != hits[keys[j]] {
return hits[keys[i]] > hits[keys[j]]
}
return keys[i] < keys[j]
})
return keys
}
// sendSecurityReport 发信(内嵌 PNG 地图 + 附 HTML 报告)。
func sendSecurityReport(res *reportResult, to string) error {
sum := res.Summary
payload := mail.SecurityReportMail{
To: to,
Title: fmt.Sprintf("【狼的提问箱】%s", sum.Period.Title()),
Period: sum.Period.Label(),
Generated: sum.GeneratedAt.Format("2006-01-02 15:04"),
SummaryHTML: template.HTML(buildSummaryHTML(sum)),
CountriesHTML: template.HTML(buildCountriesHTML(sum)),
Notes: template.HTML(buildNotesHTML(sum)),
Attachments: []mail.MailAttachment{{
Path: res.HTMLPath,
Name: sum.Period.Filename() + ".html",
}},
}
// 内嵌地图:只有真的有命中才发(无数据时地图是一张空白经纬网,没有信息量)。
if !sum.Empty && len(sum.Cities) > 0 {
payload.MapCID = "honeypot-map"
payload.InlineImages = []mail.MailInlineImage{{
Path: res.PNGPath,
CID: "honeypot-map",
}}
}
if err := mail.SendSecurityReportMail(payload); err != nil {
return errors.Wrap(err, "发送报告邮件")
}
res.Sent = true
res.To = to
return nil
}
// printSecurityReport 在终端打印报告摘要(手动运行时看)。
func printSecurityReport(res *reportResult) {
sum := res.Summary
fmt.Println(sum.Period.Title())
fmt.Println(strings.Repeat("-", 72))
fmt.Printf(" 窗口:%s\n", sum.Period.Label())
fmt.Printf(" 命中:%d 次(上期 %d)\n", sum.TotalHits, sum.PrevHits)
fmt.Printf(" 独立来源:%d 个,覆盖 %d 个国家/地区\n", sum.UniqueSources, sum.UniqueCountry)
fmt.Printf(" 真实破解尝试:%d 次;已拦截:%d 次\n", sum.CredHits, sum.BannedHits)
if sum.UnlocatedHits > 0 {
fmt.Printf(" 未能定位:%d 次命中(%s)\n", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits))
}
if len(sum.Countries) > 0 {
fmt.Println(" 主要来源:")
for i, c := range sum.Countries {
if i >= 8 {
break
}
fmt.Printf(" %-4s %s %6d 次 %s\n", c.Code, padDisplay(truncateForCLI(c.Name, 12), 20), c.Hits, pctOf(c.Hits, sum.TotalHits))
}
}
fmt.Println()
}
// ---- 邮件正文片段 ----
// buildSummaryHTML 生成 KPI 卡片(邮件只能用内联样式,表格布局最稳)。
func buildSummaryHTML(sum *report.Summary) string {
kpis := [][2]string{
{"蜜罐命中", fmt.Sprintf("%d 次", sum.TotalHits)},
{"独立来源", fmt.Sprintf("%d 个", sum.UniqueSources)},
{"涉及国家/地区", fmt.Sprintf("%d 个", sum.UniqueCountry)},
{"真实破解尝试", fmt.Sprintf("%d 次", sum.CredHits)},
{"已拦截", fmt.Sprintf("%d 次", sum.BannedHits)},
{"云主机来源", fmt.Sprintf("%d 个", sum.CloudSources)},
}
var b strings.Builder
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">`)
for i := 0; i < len(kpis); i += 2 {
b.WriteString(`<tr>`)
for j := i; j < i+2 && j < len(kpis); j++ {
b.WriteString(`<td width="50%" style="padding:6px;">`)
b.WriteString(`<div style="border:1px solid #dadce0;border-radius:8px;padding:12px 14px;">`)
fmt.Fprintf(&b, `<div style="font-size:12px;color:rgba(0,0,0,0.54);">%s</div>`, kpis[j][0])
fmt.Fprintf(&b, `<div style="font-size:20px;font-weight:600;color:#202124;margin-top:2px;">%s</div>`, kpis[j][1])
b.WriteString(`</div></td>`)
}
if i+1 >= len(kpis) {
b.WriteString(`<td width="50%"></td>`)
}
b.WriteString(`</tr>`)
}
b.WriteString(`</table>`)
if sum.PrevHits > 0 {
delta := float64(sum.TotalHits-sum.PrevHits) / float64(sum.PrevHits) * 100
arrow := "上升"
if delta < 0 {
arrow = "下降"
}
fmt.Fprintf(&b, `<div style="font-size:13px;color:rgba(0,0,0,0.6);margin-top:10px;">较上一周期%s %.0f%%(上期 %d 次)。</div>`,
arrow, absFloat(delta), sum.PrevHits)
}
return b.String()
}
// buildCountriesHTML 生成国家排行表。
func buildCountriesHTML(sum *report.Summary) string {
if len(sum.Countries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<div style="font-size:13px;color:rgba(0,0,0,0.54);margin-bottom:8px;">来源国家 / 地区(前 10)</div>`)
b.WriteString(`<table width="100%" border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-size:13px;">`)
b.WriteString(`<tr>`)
for _, h := range []string{"国家/地区", "命中", "占比"} {
align := "left"
if h != "国家/地区" {
align = "right"
}
fmt.Fprintf(&b, `<th align="%s" style="text-align:right;padding:6px 8px;border-bottom:1px solid #dadce0;color:rgba(0,0,0,0.54);font-weight:600;text-align:%s;">%s</th>`,
align, align, h)
}
b.WriteString(`</tr>`)
for i, c := range sum.Countries {
if i >= 10 {
break
}
name := c.Name
if c.Code != "" && c.Code != "--" {
name = fmt.Sprintf("%s(%s)", c.Name, c.Code)
}
fmt.Fprintf(&b, `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f3f4;color:#202124;">%s</td>`,
html.EscapeString(name))
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:#202124;">%d</td>`, c.Hits)
fmt.Fprintf(&b, `<td align="right" style="padding:6px 8px;border-bottom:1px solid #f1f3f4;text-align:right;color:rgba(0,0,0,0.6);">%s</td></tr>`,
html.EscapeString(pctOf(c.Hits, sum.TotalHits)))
}
b.WriteString(`</table>`)
return b.String()
}
// buildNotesHTML 生成口径说明。
func buildNotesHTML(sum *report.Summary) string {
lines := []string{
fmt.Sprintf("地理定位数据源:%s。", sum.ResolverDesc),
fmt.Sprintf("未能定位的命中:%d 次(占 %s)。", sum.UnlocatedHits, pctOf(sum.UnlocatedHits, sum.TotalHits)),
"「真实破解尝试」指提交了账号密码字段的请求;捕获到的账号密码不写进报告,只在服务器取证目录中(TamaBox honeypot hits 查看)。",
"「网络归属」来自离线指纹,判定云厂商 / CDN / WAF,与地理位置是两个维度:显示「未识别(普通网络)」不代表没定位到位置。",
}
var b strings.Builder
for _, l := range lines {
fmt.Fprintf(&b, `%s<br/>`, html.EscapeString(l))
}
return b.String()
}
func pctOf(v, total int64) string {
if total <= 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(total)*100)
}
func absFloat(f float64) float64 {
if f < 0 {
return -f
}
return f
}
// ---- 后台调度 ----
// SecurityReportScheduler 周期性检查是否有到期的周报/月报需要发送。
//
// 为什么不直接用 cron:「这一周的周报发过没」需要幂等保证,而幂等只能靠
// 数据库里的台账(security_report_logs 的 period_key 唯一索引)。有了台账,
// 调度器只需要「每半小时看一眼现在该发哪一期、发过没」——
// 服务中途重启、周一维护窗口错过、机器睡了一觉再醒来,都能正确补发。
type SecurityReportScheduler struct {
database *gorm.DB
interval time.Duration
// now 可注入(测试用)。
now func() time.Time
}
// NewSecurityReportScheduler 构造调度器。
func NewSecurityReportScheduler(database *gorm.DB) *SecurityReportScheduler {
return &SecurityReportScheduler{
database: database,
// 30 分钟一轮:周报/月报的时效要求是「周一/月初能看到」,
// 半小时的粒度远够,而且这个检查只读一条台账,成本几乎为零。
interval: 30 * time.Minute,
now: time.Now,
}
}
// Start 启动后台循环,stop 关闭时退出。
func (s *SecurityReportScheduler) Start(stop <-chan struct{}) {
if s == nil || s.database == nil {
return
}
go func() {
// 启动后先等 2 分钟再跑第一轮:让站点先完成启动、DB 连接池预热,
// 也避免与保留期清理抢同一时刻的数据库资源。
select {
case <-stop:
return
case <-time.After(2 * time.Minute):
}
s.tick()
t := time.NewTicker(s.interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.tick()
}
}
}()
logrus.WithField("interval", s.interval).Info("安全汇总报告调度器已启动")
}
// tick 检查并发送到期报告。
func (s *SecurityReportScheduler) tick() {
now := s.now()
periods := make([]report.Period, 0, 2)
if conf.Security.HoneypotReportWeekly {
periods = append(periods, report.PreviousWeek(now))
}
if conf.Security.HoneypotReportMonthly {
periods = append(periods, report.PreviousMonth(now))
}
for _, p := range periods {
if !s.due(p, now) {
continue
}
if err := s.deliver(p, now); err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("安全汇总报告发送失败")
}
}
}
// due 判断某周期现在是否应该发送。
//
// 条件:周期已结束超过 1 小时(留出跨零点写入的余量),且尚未成功发送。
// 不限制「过期多久就不发」:一台停机两周的机器重新上线时,补发上周的周报
// 仍然有意义 —— 反倒是「因为晚了就永远不发」会造成数据黑洞。
func (s *SecurityReportScheduler) due(p report.Period, now time.Time) bool {
if now.Before(p.End) {
return false
}
rec, err := db.NewSecurityReportStore(s.database).Get(context.Background(), p.Key)
if err != nil {
logrus.WithError(err).WithField("period", p.Key).Warn("读取报告台账失败,本轮跳过")
return false
}
if rec == nil {
return true
}
if rec.Sent {
return false
}
// 失败重试上限:SMTP 配错时每次 tick 都失败,无限重试只会把日志和邮箱
// 配额消耗干净。5 次之后停下来,等人工介入(CLI 手动补发会重置计数)。
const maxAttempts = 5
return rec.Attempts < maxAttempts
}
// deliver 生成并发送一份报告,同时写台账。
func (s *SecurityReportScheduler) deliver(p report.Period, now time.Time) error {
store := db.NewSecurityReportStore(s.database)
// 先读一次台账(可能为 nil),用于累计尝试次数。
rec, err := store.Get(context.Background(), p.Key)
if err != nil {
return errors.Wrap(err, "读取台账")
}
if rec == nil {
rec = &db.SecurityReportLog{PeriodKey: p.Key, Kind: p.Kind}
}
rec.WindowStart, rec.WindowEnd = p.Start, p.End
rec.Attempts++
to := reportRecipient()
if to == "" {
rec.Error = "未配置收件人(honeypot_report_email / security_alert_email 均为空)"
_ = store.Save(context.Background(), rec)
return errors.New(rec.Error)
}
rec.Recipient = to
res, err := buildSecurityReport(context.Background(), s.database, p, conf.Security.HoneypotReportDir)
if err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Hits = res.Summary.TotalHits
rec.UniqueSources = int64(res.Summary.UniqueSources)
rec.Banned = res.Summary.BannedHits
rec.Countries = res.Summary.UniqueCountry
rec.FilePath = res.HTMLPath
if err := sendSecurityReport(res, to); err != nil {
rec.Error = truncateForCLI(err.Error(), 480)
_ = store.Save(context.Background(), rec)
return err
}
rec.Sent = true
rec.Error = ""
if err := store.Save(context.Background(), rec); err != nil {
// 台账写失败意味着下一轮会重复发送。这里必须大声报出来,
// 否则用户会收到同一周的周报两三次而不知道原因。
logrus.WithError(err).WithField("period", p.Key).
Error("报告已发送但台账写入失败:下一轮可能重复发送同一期报告")
return nil
}
logrus.WithFields(logrus.Fields{
"period": p.Key, "hits": rec.Hits, "to": to, "file": rec.FilePath,
}).Info("安全汇总报告已发送")
return nil
}
// padDisplay 按「显示列宽」右补空格,让 CLI 表格对齐。
//
// 不能用 %-20s:Go 的宽度按字符数算,一个汉字只算 1,终端里却占两格 ——
// 国家名改成中文之后,表格会歪得看不出哪一列是哪一列。
func padDisplay(s string, width int) string {
w := 0
for _, r := range s {
if r > 0x2E80 {
w += 2
} else {
w++
}
}
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/security/report"
)
// TestMergeKeysByHits 覆盖「按封禁口径合并后再定位」的键生成。
//
// 顺序很重要:腾讯精修有配额上限,只处理前 N 个 —— 必须优先给最活跃的来源,
// 否则配额会花在只命中一次的长尾地址上,而报告的主要来源反而定位不到。
func TestMergeKeysByHits(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "2408:8207:1234:5678::1", Hits: 5},
{IP: "2408:8207:1234:5678::2", Hits: 3}, // 同 /64 → 合成 8
{IP: "203.0.113.7", Hits: 6},
{IP: "198.51.100.1", Hits: 1},
}
got := mergeKeysByHits(rows)
require.Len(t, got, 3)
// 8 次(合并后的 /64)> 6 次 > 1 次
require.Equal(t, "2408:8207:1234:5678::/64", got[0])
require.Equal(t, "203.0.113.7", got[1])
require.Equal(t, "198.51.100.1", got[2])
}
// TestMergeKeysByHitsStable 覆盖同命中数时的稳定排序。
//
// 不稳定的话,每次跑报告被腾讯精修覆盖的那批 IP 会随机漂移,
// 两个周期的定位精度就不一致了,对比起来像是数据变了。
func TestMergeKeysByHitsStable(t *testing.T) {
rows := []*db.HoneypotSourceAgg{
{IP: "203.0.113.9", Hits: 2},
{IP: "203.0.113.2", Hits: 2},
{IP: "203.0.113.5", Hits: 2},
}
first := mergeKeysByHits(rows)
for i := 0; i < 20; i++ {
require.Equal(t, first, mergeKeysByHits(rows), "同命中数时顺序必须稳定")
}
}
// TestSchedulerDue 覆盖周报/月报的发送判定,这是「不重复发、不永远不发」的核心。
func TestSchedulerDue(t *testing.T) {
database, cleanup := dbutil.NewTestDB(t, db.AllTables...)
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
s := NewSecurityReportScheduler(database)
ctx := context.Background()
loc := time.Local
// now = 2026-10-26(周一)02:00,上一周是 10-19 ~ 10-25
now := time.Date(2026, 10, 26, 2, 0, 0, 0, loc)
period := report.PreviousWeek(now)
require.Equal(t, "week:2026-W43", period.Key)
// 1) 台账里没有记录 → 该发
require.True(t, s.due(period, now))
// 2) 已成功发送 → 不再发(哪怕重启一百次)
store := db.NewSecurityReportStore(database)
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End, Sent: true, Attempts: 1,
}))
require.False(t, s.due(period, now))
// 3) 发送失败但未超重试上限 → 继续重试
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 3, Error: "smtp auth failed",
}))
require.True(t, s.due(period, now))
// 4) 超过重试上限 → 停下来等人工介入,避免刷爆日志与邮箱配额
require.NoError(t, store.Save(ctx, &db.SecurityReportLog{
PeriodKey: period.Key, Kind: period.Kind,
WindowStart: period.Start, WindowEnd: period.End,
Sent: false, Attempts: 5, Error: "smtp auth failed",
}))
require.False(t, s.due(period, now))
// 5) 周期还没结束 → 不发(哪怕台账里没有记录)
future := report.PreviousWeek(now.AddDate(0, 0, 7))
require.False(t, s.due(future, now), "窗口未结束时不能发不完整的报告")
}
+119 -5
View File
@@ -5,8 +5,13 @@
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/pkg/errors"
@@ -17,11 +22,13 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
var Web = &cli.Command{
@@ -69,10 +76,18 @@ func runWeb(ctx *cli.Context) error {
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
gdb, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 退出前把连接池关掉,避免优雅关闭期间连接还在池里挂着。
if sqlDB, dbErr := gdb.DB(); dbErr == nil {
defer func() {
if err := sqlDB.Close(); err != nil {
logrus.WithError(err).Warn("关闭数据库连接池失败")
}
}()
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
@@ -81,7 +96,11 @@ func runWeb(ctx *cli.Context) error {
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
// 两个后台任务(保留期清理 / 周期报告)共用一个停止信号,
// defer 必须放在 if 外面 —— 否则「自动清理关掉、报告开着」时这个
// channel 永远不会被关闭,那个 goroutine 就一直挂着。
stopRetention := make(chan struct{})
defer close(stopRetention)
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
@@ -90,14 +109,57 @@ func runWeb(ctx *cli.Context) error {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
}
// 周期安全汇总(周报 / 月报):把命中按 IP 定位到城市,出地图报告并邮件送达。
// 幂等靠 security_report_logs 的 period_key 唯一索引,所以这里只管定期看一眼
// 「该发的那期发过没」—— 重启、错过时间窗都能正确补发,也不会重复发。
if conf.Security.HoneypotReportEnabled {
if reportRecipient() == "" {
logrus.Warn("已启用安全汇总报告,但 honeypot_report_email 与 security_alert_email 都为空,将无法送达")
}
NewSecurityReportScheduler(gdb).Start(stopRetention)
}
// Redis 配了就必须连得上:session 建在它上面,宕机等于全站 500(含匿名访客)。
// 启动期 fail fast,别让运维在「站点全 500」的现场去猜是不是 Redis。
if conf.Redis.Addr != "" {
if err := shared.PingRedis(conf.Redis.Addr, conf.Redis.Password); err != nil {
return errors.Wrapf(err,
"无法连接 Redis(%s)。session 依赖它,连不上会导致全站 500,启动已中止。"+
"请检查 [redis] addr/password,或清空 addr 改用进程内 session 与缓存",
conf.Redis.Addr)
}
logrus.WithField("addr", conf.Redis.Addr).Info("Redis 连接正常")
} else {
logrus.Info("未配置 [redis] addr:session 与缓存均使用进程内实现(单副本部署可用)")
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 箱主数据面板的访问记录器:异步批量写 box_views,不阻塞页面渲染。
// 密钥复用 server.xsrf_key —— 它已经是启动期强校验过的非空随机串,
// 而且与 CSRF token 的用途不同(这里只做 HMAC 的一次性密钥),不存在混用风险。
insightsStore := db.BoxViews
if insightsStore != nil {
insights.Views = insights.NewRecorder(insightsStore, conf.Server.XSRFKey)
defer insights.Views.Close()
insights.StartMaintenance(ctx.Context, insightsStore)
}
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
@@ -112,11 +174,20 @@ func runWeb(ctx *cli.Context) error {
}()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadHeaderTimeout 防止慢速客户端占用连接(slowloris 的防线在这里)
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
//
// ReadTimeout 从 30s 提到 120s:站内允许单图 8MB(storage.MaxUploadBytes),
// 请求体上限 12MB(form.MaxRequestBodySize)。30s 传完 8MB 需要 ≥273 KB/s 上行,
// 而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,且用户
// 已经写好的提问正文会一起丢掉(POST 失败不回填)。120s 对应约 68 KB/s,
// 偏远地区 3G 也能过。
//
// 放宽 ReadTimeout 不会让 slowloris 重新变得可行:那类攻击卡在「发不完 header」,
// 由 10s 的 ReadHeaderTimeout 拦住;ReadTimeout 只影响读完 header 之后传 body 的时长。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
@@ -125,14 +196,57 @@ func runWeb(ctx *cli.Context) error {
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
ReadTimeout: 120 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// 优雅关闭:收到 SIGTERM / SIGINT 后停止接受新连接,给在途请求最多 15 秒收尾。
//
// 改动前是直接 ListenAndServe:Go 对 SIGTERM 的处理就是立即退出进程,
// 于是每次部署 / 重启都会把正在写库的 POST 就地处死,留下
// 「提问已 INSERT、图片还没传完」「回答已写入、配额还没扣」这类半成品。
// systemd Restart=always 与 docker restart 都救不了这个。
runCtx, stop := signal.NotifyContext(ctx.Context, syscall.SIGTERM, os.Interrupt)
var r2WorkerWG sync.WaitGroup
defer func() {
// 先取消 worker 的 context,再等待当前一轮退出,避免无人等待的后台删除。
stop()
r2WorkerWG.Wait()
}()
// db.Init 已成功;R2 未完整配置时此函数直接返回,不影响 Web 启动。
startR2OutboxWorker(runCtx, &r2WorkerWG)
serveErr := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
// 正常的 Shutdown 路径,不算失败
err = nil
}
serveErr <- err
}()
select {
case err := <-serveErr:
// 端口被占 / 监听失败:没有在途请求要收尾,直接返回。
if err != nil {
return errors.Wrap(err, "start http server")
}
return nil
case <-runCtx.Done():
logrus.Info("收到退出信号,正在优雅关闭(最多 15 秒)")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logrus.WithError(err).Warn("优雅关闭超时,强制断开剩余连接")
_ = server.Close()
}
logrus.Info("HTTP 服务已关闭")
return nil
}
+4
View File
@@ -111,6 +111,10 @@ func Init() error {
return errors.Wrap(err, "map 'mail'")
}
if err := File.Section("notify").MapTo(&Notify); err != nil {
return errors.Wrap(err, "map 'notify'")
}
serviceSections := File.Section("service").ChildSections()
for _, serviceSection := range serviceSections {
serviceSection := serviceSection
+28
View File
@@ -43,6 +43,13 @@ const (
//
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
DefaultHoneypotCredDir = "data/honeypot-creds"
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
DefaultIPGeoProvider = "auto"
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
//
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
DefaultHoneypotReportDir = "data/reports"
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
@@ -109,4 +116,25 @@ func sanitizeHoneypot() {
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
Security.HoneypotCredDir = DefaultHoneypotCredDir
}
// 地理定位数据源:未配置 → auto。
// 显式写了不认识的值时保持原样,由 geoip 层解析并告警 ——
// 在配置层悄悄改成 auto 会让人以为配置生效了。
if strings.TrimSpace(Security.IPGeoProvider) == "" {
Security.IPGeoProvider = DefaultIPGeoProvider
}
// 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。
if !sec.HasKey("honeypot_report_enabled") {
Security.HoneypotReportEnabled = true
}
if !sec.HasKey("honeypot_report_weekly") {
Security.HoneypotReportWeekly = true
}
if !sec.HasKey("honeypot_report_monthly") {
Security.HoneypotReportMonthly = true
}
if strings.TrimSpace(Security.HoneypotReportDir) == "" {
Security.HoneypotReportDir = DefaultHoneypotReportDir
}
}
+12
View File
@@ -50,6 +50,18 @@ var keyAliases = map[string]string{
"honeypot_retentionday": "honeypot_retain_days",
"honeypot_keep_days": "honeypot_retain_days",
"honeypot_retain_max_days": "honeypot_retain_days",
// 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。
"ipgeo_provider": "ip_geo_provider",
"ip_geolocation_provider": "ip_geo_provider",
"geo_provider": "ip_geo_provider",
"ip_geo_key": "ip_geo_tencent_key",
"ip_geo_tencent_apikey": "ip_geo_tencent_key",
"tencent_map_key": "ip_geo_tencent_key",
"honeypot_report": "honeypot_report_enabled",
"honeypot_report_mail": "honeypot_report_email",
"honeypot_report_to": "honeypot_report_email",
"honeypot_reports_dir": "honeypot_report_dir",
"honeypot_report_path": "honeypot_report_dir",
}
// normalizeKeyName 归一化配置键名。
+74 -3
View File
@@ -57,11 +57,15 @@ var (
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr,
// 防止伪造 header 绕过速率限制与 IP 黑名单。
// ⚠️ 权威头只在 TCP 对端可信(回环,或命中 TrustedProxies)时才会被采信 ——
// 见 shared.ResolveClientIP:公网对端带来的权威头一律忽略(P1-3 修复)。
TrustProxy bool `ini:"trust_proxy"`
// TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任
// (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定,
// 从而免于维护上游 CDN 的庞大 CIDR 清单)。
// 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任。
// 回环地址(127.0.0.1 / ::1)无需登记即视为可信对端:cloudflared / 本机 nginx
// 回源的连接必然由本机进程发起,外部无法伪造。
// CDN 直连回源(非 Tunnel)的部署形态下,必须把该 CDN 的回源网段登记到这里,
// 否则它的权威头会被忽略、客户端 IP 退化为 CDN 边缘 IP。
TrustedProxies string `ini:"trusted_proxies"`
EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP
// BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台
@@ -135,6 +139,61 @@ var (
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
// ⚠️ security_alert_email 也是蜜罐告警邮件的收件人,而蜜罐邮件正文
// **含明文账号密码**(这是功能核心价值:运维立刻拿到攻击者正在用的字典)。
// 因此这里必须是**个人邮箱**,禁止填群组邮箱 / 分发列表 / 团队共用邮箱——
// 否则等于把攻击者的破解字典广播给无关人员,且经 SMTP 中转后明文密码
// 会留在第三方邮件系统的日志/归档里,超出本地 0600 取证文件的控制范围。
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
//
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
// (照常加载、只记违规),用于临时观察。默认拦截而不是观察,原因是:
// 没人会盯着 console,且不给 csp_report_uri 时 Report-Only 等于什么都没做,
// 留个不生效的开关反而制造「已防护」的错觉。默认值的安全前提是白名单已经
// 用真浏览器逐页巡检过(csp_sweep.py:20 个页面零违规)。
// 上线后发现误伤,把 csp 改成 report 或 off 再重启即可恢复,不用改代码。
CSP string `ini:"csp"`
// CSPReportURI 违规上报地址(可选)。留空时浏览器只把违规打到 console 不上报;
// 用 report 模式观察时应当配上,否则「观察一周」就只是空谈。
CSPReportURI string `ini:"csp_report_uri"`
// IPGeoProvider 蜜罐/扫描来源 IP 的地理定位数据源。
//
// ⚠️ 与 ipinfo.Lookup(cdncheck 离线指纹)是两件事:那套只认云厂商/CDN/WAF
// 的 ASN,肉鸡(家宽、企业宽带、移动网络)一律显示「未识别(普通网络)」。
// 这里回答的是「这个 IP 在哪儿」,靠的是外部 IP 库。
//
// auto(缺省):ip-api.com 批量打底(全球覆盖、无需 key、一次 100 个),
// 配了 ip_geo_tencent_key 时再用腾讯位置服务精修国内 IP
// (省市 + GCJ-02 坐标)。腾讯的 IP 定位是一 IP 一请求,
// 拿它做批量主干会把 N 个 IP 变成 N 次 HTTP,故只做精修。
// ipapi:只用 ip-api.com
// tencent:只用腾讯位置服务(要求配 key,否则降级为 ipapi)
// none:不查询,报告里只给 IP 不提地理信息(攻击者 IP 一个字节都不外发)
//
// 隐私边界:auto/ipapi 会把攻击者 IP 发给 ip-api.com,tencent 会发给腾讯。
// 本报告只处理「攻击者」地址,不涉及任何用户 IP;不接受这一点请用 none。
IPGeoProvider string `ini:"ip_geo_provider"`
// IPGeoTencentKey 腾讯位置服务 key(lbs.qq.com 申请)。留空则跳过腾讯精修。
// 仅用于服务端 HTTP 请求,不下发到前端 —— 与地图 JS 用的 key 不是一回事。
IPGeoTencentKey string `ini:"ip_geo_tencent_key"`
// HoneypotReportEnabled 是否启用周期性安全状况汇总(周报 / 月报)邮件。
// 缺省 true:报告本身是运维最需要的「有没有人在打、从哪儿打」,
// 而收件人缺失时会 WARN 跳过,不会静默。
HoneypotReportEnabled bool `ini:"honeypot_report_enabled"`
// HoneypotReportWeekly / Monthly 分别控制周报与月报,缺省都开。
HoneypotReportWeekly bool `ini:"honeypot_report_weekly"`
HoneypotReportMonthly bool `ini:"honeypot_report_monthly"`
// HoneypotReportEmail 报告收件人。留空回退 security_alert_email。
// 单独开一个键是为了能把「随时可能发生的封禁告警」与「定期汇总」
// 分投到不同邮箱(例如告警进手机推送邮箱、汇总进归档邮箱)。
HoneypotReportEmail string `ini:"honeypot_report_email"`
// HoneypotReportDir 报告文件(HTML + PNG)的存放目录,缺省 data/reports。
// 报告里有攻击者 IP 明细,目录权限按 0700 创建。
HoneypotReportDir string `ini:"honeypot_report_dir"`
}
Server struct {
@@ -198,6 +257,18 @@ var (
SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要
}
// Notify 站级通知通道配置([notify] 段)。与 [mail] 的「邮件给具体收件人」不同,
// 这两个是「广播式」通道:
// - WebhookURL:通用 Webhook(Discord / Slack / 企业微信群机器人等),站级事件
// (新提问 / 新回答 / 新追问 / 新私聊 / 新投票 / 安全告警 / 举报)统一推送。
// - TelegramBotToken:Telegram Bot token,配合每个箱主自填的 chat_id 实现
// 「新提问」点对点通知。
// 两者都可留空:留空的通道对应发送静默跳过(不影响邮件等其他通道)。
Notify struct {
WebhookURL string `ini:"webhook_url"`
TelegramBotToken string `ini:"telegram_bot_token"`
}
Service struct {
Backends []struct {
Prefix string `ini:"prefix"`
+15 -3
View File
@@ -66,7 +66,10 @@ func (c *Context) HasError() bool {
if !ok {
return false
}
return hasErr.(bool)
// 带 ok 的断言:Data 是 map[string]interface{},任何写入方都可能塞进非 bool 值,
// 裸断言遇到那种情况会直接 panic 掉整个请求。非 bool 一律视为「无错误」。
v, isBool := hasErr.(bool)
return isBool && v
}
func (c *Context) SetError(err error, f ...interface{}) {
@@ -135,6 +138,11 @@ func (c *Context) Refresh() {
}
func (c *Context) JSON(data interface{}) error {
// 显式声明内容类型:之前靠 Go 的 sniff 兜底,实测返回的是 text/plain。
// 有 nosniff 所以不会被当 HTML 渲染,但按 Content-Type 分派的客户端
// (fetch 的 .json()、axios、部分 SDK)会因此走错分支。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]interface{}{
"code": 0,
"data": data,
@@ -149,6 +157,9 @@ func (c *Context) ServerError() error {
}
func (c *Context) JSONError(errorCode int, message string) error {
// 必须在 WriteHeader 之前设置:一旦写出状态码,头就锁定了。
c.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
span := trace.SpanFromContext(c.Request().Context())
resp := map[string]interface{}{
@@ -367,9 +378,10 @@ func pageScope(path string) string {
path == "/register" || strings.HasPrefix(path, "/register/"):
return "auth"
case path == "/user" || strings.HasPrefix(path, "/user/"):
// 题目/私聊管理列表是「翻找内容」的页面,和「设置表单」的信息密度差很多,
// 题目/私聊管理列表、数据面板都是「读数据」的页面,和「设置表单」的信息密度差很多,
// 装饰要分开配:admin 更该退到背景,profile 可以保留一点氛围。
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") {
if strings.HasPrefix(path, "/user/questions") || strings.HasPrefix(path, "/user/votes") ||
strings.HasPrefix(path, "/user/insights") {
return "admin"
}
return "profile"
+1
View File
@@ -42,6 +42,7 @@ func TestPageScope(t *testing.T) {
"/user/profile": "profile",
"/user/questions": "admin", // 题目列表:信息密,装饰要退
"/user/votes/1/results": "admin",
"/user/insights": "admin", // 数据面板同属「读数据」
"/_/admin6": "box",
"/_/admin6/": "box",
"/_/admin6/tag/杂谈": "box",
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"gorm.io/gorm"
)
// BanRecord 是一次自动封禁的落库记录。
//
// 为什么需要它:自动封禁的生效状态是**进程内存**里的(见 internal/security/autoban),
// 重启即清零,外部完全看不见。线上出现过「邮件说封了、实际还能访问」的排查困境 ——
// 当时没有任何东西能说明封的是哪个键、因为什么、到什么时候解除,只能靠翻日志猜。
//
// 这张表是「封禁事实」的账本,不参与判定:IsBanned 仍然只读内存,
// 表只用于事后解释与取证。写入失败绝不能影响封禁动作本身。
//
// 增长:每个键在封禁期内不会重复建封(autoban 命中已封禁直接拒绝),
// 所以行数量级 = 封禁次数,不需要自动清理。要清可手动 DELETE。
type BanRecord struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
// BanKey 与 autoban.IsBanned 的判断口径完全一致(shared.LimitKey,
// IPv6 折成 /64),否则账本与判定会各说一套。
BanKey string `gorm:"size:64;index" json:"ban_key"`
// Reason 是给人看的原因文案(敏感路径探测 / 蜜罐提交凭据)。
Reason string `gorm:"size:64" json:"reason"`
// DurationMinutes 与 ExpiresAt 一起回答「封多久、什么时候自动解除」。
DurationMinutes int `gorm:"not null;default:0" json:"duration_minutes"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
// TableName 固定表名,避免 GORM 复数化差异。
func (BanRecord) TableName() string { return "ban_records" }
// BanRecords 自动封禁记录的全局 store,由 db.Init 装配。
var BanRecords BanRecordStore
// BanRecordStore 封禁记录的读写。
type BanRecordStore interface {
// Create 追加一条封禁记录。
Create(ctx context.Context, record *BanRecord) error
// ListSince 返回 since 之后的记录,按时间倒序,最多 limit 条。
ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error)
}
type banRecords struct {
db *gorm.DB
}
// NewBanRecordsStore 创建封禁记录 store。
func NewBanRecordsStore(db *gorm.DB) BanRecordStore {
return &banRecords{db: db}
}
func (s *banRecords) Create(ctx context.Context, record *BanRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
func (s *banRecords) ListSince(ctx context.Context, since time.Time, limit int) ([]*BanRecord, error) {
var records []*BanRecord
err := s.db.WithContext(ctx).
Where("created_at >= ?", since).
Order("created_at DESC").
Limit(limit).
Find(&records).Error
return records, err
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestBanRecordStore 覆盖自动封禁账本的写入与检索。
//
// 这张表不参与判定(IsBanned 只读内存),所以测试只锁两件事:
// ① 记录能落库并按时间倒序取回;② 到期时间与时长原样保留 ——
// 事后排查全依赖这两个字段回答「封了多久、什么时候解」。
func TestBanRecordStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ban_records"); err != nil {
t.Fatal(err)
}
})
store := NewBanRecordsStore(database)
now := time.Now()
rows := []*BanRecord{
{BanKey: "1.2.3.4", Reason: "敏感路径探测", DurationMinutes: 60, ExpiresAt: now.Add(time.Hour), CreatedAt: now.Add(-2 * time.Hour)},
{BanKey: "2001:db8::/64", Reason: "蜜罐提交凭据", DurationMinutes: 1440, ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now.Add(-10 * time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
got, err := store.ListSince(ctx, now.Add(-3*time.Hour), 10)
require.NoError(t, err)
require.Len(t, got, 2)
// 时间倒序:最新的一条在最前
require.Equal(t, "2001:db8::/64", got[0].BanKey)
require.Equal(t, "蜜罐提交凭据", got[0].Reason)
require.Equal(t, 1440, got[0].DurationMinutes)
require.False(t, got[0].ExpiresAt.IsZero(), "到期时间必须保留,否则无法回答「什么时候解」")
// 时间窗过滤生效
recent, err := store.ListSince(ctx, now.Add(-30*time.Minute), 10)
require.NoError(t, err)
require.Len(t, recent, 1)
require.Equal(t, "2001:db8::/64", recent[0].BanKey)
}
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// BoxAnalyticsStore 是「箱主数据面板」里提问侧的聚合查询(访问侧见 BoxViewsStore)。
//
// 与 SiteAnalyticsStore 分开:那个是站方看的全站数据(不含任何单箱维度),
// 这个是箱主看自己的。两边的 SQL 都按 user_id 走,混在一个接口里会让
// 「全站聚合整天缓存」和「箱主实时看自己的」两种缓存策略纠缠在一起。
type BoxAnalyticsStore interface {
// BoxTrend 返回 [from, to] 内每天的提问数与回答数。只返回有数据的天,缺日由上层补零。
BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error)
// BoxHours 返回 since 之后该箱收到提问的 24 小时分布(下标 0..23)。
BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error)
// BoxFunnel 返回该箱的提问处理漏斗。
BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error)
}
// BoxDayCount 某一天的提问 / 回答计数。
type BoxDayCount struct {
Day time.Time `gorm:"column:day"`
Questions int64 `gorm:"column:questions"`
Answers int64 `gorm:"column:answers"`
}
// BoxFunnel 箱主的提问处理漏斗与响应速度。
type BoxFunnel struct {
Total int64 // 全部提问
Answered int64 // 已回答
ReadOnly int64 // 已读未答
Unread int64 // 未读未答
Private int64 // 私密提问
Anonymous int64 // 匿名(未登录)提问
Pinned int64 // 置顶
// 响应速度只统计已回答且 answered_at 有值的行(历史行退回 updated_at,
// 会有偏高,见 answered_at 的注释)。
RepliedIn24h int64
AvgReplyHour float64
// LastQuestionAt / LastAnswerAt 用于「你多久没看提问箱了」这类提示。
LastQuestionAt *time.Time
LastAnswerAt *time.Time
}
var BoxAnalytics BoxAnalyticsStore
var _ BoxAnalyticsStore = (*boxAnalytics)(nil)
func NewBoxAnalyticsStore(db *gorm.DB) BoxAnalyticsStore {
return &boxAnalytics{db}
}
type boxAnalytics struct {
*gorm.DB
}
func (db *boxAnalytics) BoxTrend(ctx context.Context, userID uint, from, to time.Time) ([]BoxDayCount, error) {
d := analyticsDialectFor(db.DB, from)
from, to = dayOnly(from), dayOnly(to).AddDate(0, 0, 1)
// 提问按 created_at 分组;回答必须按 answered_at(空则退回 updated_at)分组。
// 两条 SQL 的 day 表达式不同:d.day 是 created_at 的日期,回答那条要基于
// COALESCE(answered_at, updated_at),否则「40 天前创建、昨天才回答」的提问
// 会被排除在窗口外,「今天创建、明天回答」的会被记到今天 —— 趋势图里的
// 「每日回答数」就不是「某天被回答了多少」。
answerDay := analyticsLocalize("COALESCE(answered_at, updated_at)", from)
if db.Dialector.Name() == "mysql" {
answerDay = fmt.Sprintf("DATE_FORMAT(CONVERT_TZ(COALESCE(answered_at, updated_at), '+00:00', '%s'), '%%Y-%%m-%%d')", analyticsTZOffset(from))
} else {
answerDay = fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", answerDay)
}
// 提问与回答分两条 SQL 再在内存里合并:一条 SQL 里同时按 created_at 和
// answered_at 分组要写成两条子查询再 FULL JOIN,MySQL 没有 FULL JOIN,
// 而两张结果集都很小,合并成本可以忽略。
type row struct {
Key string
N int64
}
var qs, as []row
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, from, to).
Group(d.day).
Scan(&qs).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box questions by day")
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(answerDay+" AS key, COUNT(*) AS n").
Where("user_id = ? AND answer <> '' AND COALESCE(answered_at, updated_at) >= ? AND COALESCE(answered_at, updated_at) < ?",
userID, from, to).
Group(answerDay).
Scan(&as).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box answers by day")
}
// 用 map 合并,最后仍交给上层按日期补零(上层最清楚要展示哪些天)。
byDay := make(map[string]*BoxDayCount, len(qs))
order := make([]string, 0, len(qs)+len(as))
get := func(key string) *BoxDayCount {
if v, ok := byDay[key]; ok {
return v
}
t, err := time.ParseInLocation("2006-01-02", key, from.Location())
if err != nil {
t = from
}
v := &BoxDayCount{Day: t}
byDay[key] = v
order = append(order, key)
return v
}
for _, r := range qs {
get(r.Key).Questions = r.N
}
for _, r := range as {
get(r.Key).Answers = r.N
}
out := make([]BoxDayCount, 0, len(order))
for _, key := range order {
out = append(out, *byDay[key])
}
return out, nil
}
func (db *boxAnalytics) BoxHours(ctx context.Context, userID uint, since time.Time) ([24]int64, error) {
var out [24]int64
d := analyticsDialectFor(db.DB, since)
var rows []struct {
Hr int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.hour+" AS hr, COUNT(*) AS n").
Where("user_id = ? AND created_at >= ?", userID, since).
Group(d.hour).
Scan(&rows).Error; err != nil {
return out, errors.Wrap(err, "aggregate box question hours")
}
for _, r := range rows {
if r.Hr >= 0 && r.Hr < 24 {
out[r.Hr] = r.N
}
}
return out, nil
}
func (db *boxAnalytics) BoxFunnel(ctx context.Context, userID uint) (*BoxFunnel, error) {
out := &BoxFunnel{}
var totals struct {
Total int64 `gorm:"column:total"`
Answered int64 `gorm:"column:answered"`
ReadOnly int64 `gorm:"column:read_only"`
Unread int64 `gorm:"column:unread"`
Private int64 `gorm:"column:private"`
Anonymous int64 `gorm:"column:anonymous"`
Pinned int64 `gorm:"column:pinned"`
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(`COUNT(*) AS total,
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
COALESCE(SUM(CASE WHEN answer = '' AND is_read THEN 1 ELSE 0 END), 0) AS read_only,
COALESCE(SUM(CASE WHEN answer = '' AND NOT is_read THEN 1 ELSE 0 END), 0) AS unread,
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
COALESCE(SUM(CASE WHEN is_pinned THEN 1 ELSE 0 END), 0) AS pinned,
MAX(created_at) AS last_q,
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
Where("user_id = ?", userID).
Scan(&totals).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box funnel")
}
out.Total = totals.Total
out.Answered = totals.Answered
out.ReadOnly = totals.ReadOnly
out.Unread = totals.Unread
out.Private = totals.Private
out.Anonymous = totals.Anonymous
out.Pinned = totals.Pinned
out.LastQuestionAt = totals.LastQ
out.LastAnswerAt = totals.LastA
// 响应速度:answered_at 为空的历史行退回 updated_at(口径偏大,只用于展示大致量级)。
replyMinutes := boxReplyMinutesExpr(db.DB, out.LastAnswerAt)
if replyMinutes == "" {
return out, nil
}
var speed struct {
In24h int64 `gorm:"column:in_24h"`
Avg *float64 `gorm:"column:avg_min"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(fmt.Sprintf(`COALESCE(SUM(CASE WHEN %[1]s <= 1440 THEN 1 ELSE 0 END), 0) AS in_24h,
AVG(%[1]s) AS avg_min`, replyMinutes)).
Where("user_id = ? AND answer <> ''", userID).
Scan(&speed).Error; err != nil {
return nil, errors.Wrap(err, "aggregate box reply speed")
}
out.RepliedIn24h = speed.In24h
if speed.Avg != nil && *speed.Avg > 0 {
out.AvgReplyHour = *speed.Avg / 60
}
return out, nil
}
// boxReplyMinutesExpr 返回「回答耗时(分钟)」的 SQL 表达式。
//
// 与 analyticsDialectFor 里的 replyMin 口径一致,但这里必须能固定到箱主自己那张表:
// 表达式里出现的列名都来自 questions 表,不需要额外限定。
func boxReplyMinutesExpr(db *gorm.DB, at *time.Time) string {
now := time.Now()
if at != nil {
now = *at
}
d := analyticsDialectFor(db, now)
return d.replyMin
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestBoxTrendAnswerByAnsweredDate 验证「每日回答数」按 answered_at 分组,
// 而不是按 created_at。
//
// 背景:BoxTrend 一度两条 SQL 都复用 created_at 的日期表达式,导致「40 天前创建、
// 昨天才回答」的提问被排除在窗口外(created_at < from),昨天/今天的回答数里
// 根本不出现;「今天创建、明天回答」的又会被记到今天。本测试构造一条
// 「创建日远早于窗口、回答日落在窗口内」的提问,断言它出现在回答日那天。
func TestBoxTrendAnswerByAnsweredDate(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewBoxAnalyticsStore(db)
t.Cleanup(func() {
_ = cleanup("questions")
})
now := time.Now()
// 窗口 = 最近 7 天
from := now.AddDate(0, 0, -6)
to := now
// 一条 40 天前创建、昨天才回答的提问
answeredAt := now.AddDate(0, 0, -1)
createdAt := now.AddDate(0, 0, -40)
q := &Question{
UserID: 9,
Content: "很久以前问的,昨天才回答",
Answer: "这是回答",
AnsweredAt: &answeredAt,
}
// 直接写 created_at / answered_at,绕过 Create 的自动时间戳
require.NoError(t, db.Model(&Question{}).Create(q).Error)
require.NoError(t, db.Model(&Question{}).Where("id = ?", q.ID).
Updates(map[string]interface{}{
"created_at": createdAt,
"answered_at": answeredAt,
"updated_at": answeredAt,
}).Error)
trend, err := store.BoxTrend(ctx, 9, from, to)
require.NoError(t, err)
// 找到回答日那天(昨天)的计数,答案数应 >= 1
var found bool
for _, d := range trend {
if d.Day.Year() == answeredAt.Year() &&
d.Day.Month() == answeredAt.Month() &&
d.Day.Day() == answeredAt.Day() {
require.GreaterOrEqual(t, d.Answers, int64(1),
"回答应计入 answered_at 那一天,而不是按 created_at 被排除")
found = true
}
// 创建日(40 天前)不应出现在窗口内
if d.Day.Year() == createdAt.Year() &&
d.Day.Month() == createdAt.Month() &&
d.Day.Day() == createdAt.Day() {
t.Errorf("创建日在窗口外,不应出现在趋势里")
}
}
require.True(t, found, "回答日应出现在趋势结果里")
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// BoxViewsStore 记录「提问箱主页被访问了多少次」。
//
// 为什么要自带一张表:全站数据页(internal/stats)只统计提问/回答/用户,
// 完全没有「有人来看过我的箱」这个维度 —— 箱主看不到自己的任何流量。
// 曾经用过外部计数服务(busuanzi),但它把统计放在第三方、且只能显示两个数,
// 既不可自控也无法按天看趋势,已移除。
//
// 口径与隐私:
// - 一行 = 一个访客在某一天访问了某个箱(唯一索引强制),`views` 是当天的次数;
// - PV = SUM(views),UV = 行数(因为每行已按访客去重);
// - 访客标识是 **带日盐的 IP HMAC**,不是 IP 本身,也无法跨天关联同一人。
// 代价是「近 7 天 UV」= 各日 UV 之和,同一人多天来访会被重复计入 —— 这是
// 隐私与准确性的取舍,页面上按「按日去重的独立访客」表述。
type BoxViewsStore interface {
// Record 累加一次访问。实现为单条 UPSERT,并发安全。
Record(ctx context.Context, opts RecordBoxViewOptions) error
// RecordBatch 批量累加(记录器的 flush 用)。单条 SQL,避免 N 次往返。
RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error
// Daily 返回 [from, to] 内每天的 PV / UV,日期按天升序,缺日不补(由上层补零)。
Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error)
// TopDays 取历史上 PV 最高的若干天,供「你的最好一天」这类结论使用。
TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error)
// FirstDay 返回最早有记录的一天;无记录时返回零值时间。
FirstDay(ctx context.Context, userID uint) (time.Time, error)
// Totals 返回全期累计访问量(PV / UV)。
Totals(ctx context.Context, userID uint) (pv, uv int64, err error)
// Purge 删除 before 之前的明细,控制表体积。统计窗口最长 30 天,
// 但「历史最好一天」要看全部,默认保留期由调用方决定(见 insights.Retention)。
Purge(ctx context.Context, before time.Time) (int64, error)
}
// BoxView 一次「访客 × 天 × 箱」的访问记录。
//
// 刻意不内嵌 dbutil.Model:统计流水不需要软删除,而软删除会让唯一索引
// (user_id, viewed_on, visitor) 失效 —— 被「删掉」的行仍然占着槽位,
// 同一访客当天再也插不进去(GORM 软删除 + 唯一索引的经典坑)。
type BoxView struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"uniqueIndex:idx_box_views_day_visitor,priority:1;index:idx_box_views_user_day,priority:1"`
ViewedOn time.Time `gorm:"type:date;uniqueIndex:idx_box_views_day_visitor,priority:2;index:idx_box_views_user_day,priority:2"`
Visitor string `gorm:"size:32;uniqueIndex:idx_box_views_day_visitor,priority:3"`
Views int64 `gorm:"not null;default:1"`
CreatedAt time.Time
UpdatedAt time.Time
}
// BoxViewDay 某一天聚合后的访问量。
type BoxViewDay struct {
// Day 当天 0 点(调用方传入的时区)。扫描出来是 date 列,loc 由数据库会话决定,
// 上层只取 Year/Month/Day 拼 key,不依赖时分秒。
Day time.Time `gorm:"column:viewed_on"`
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
type RecordBoxViewOptions struct {
UserID uint
ViewedOn time.Time // 只有年月日有意义,写入前会被截断到当天 0 点
Visitor string
}
var BoxViews BoxViewsStore
var _ BoxViewsStore = (*boxViews)(nil)
func NewBoxViewsStore(db *gorm.DB) BoxViewsStore {
return &boxViews{db}
}
type boxViews struct {
*gorm.DB
}
// dayOnly 把时间截断到当天 0 点。用传入时间的时区,不做 UTC 归一 ——
// 站点按本地时间切「今天」,跨时区归一会让日报表偏一天。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func (db *boxViews) Record(ctx context.Context, opts RecordBoxViewOptions) error {
return db.RecordBatch(ctx, []RecordBoxViewOptions{opts})
}
// RecordBatch 单条 INSERT ... ON CONFLICT DO UPDATE 累加。
//
// 冲突时只把 views 加一,visited_at 之外的列不动:同一访客当天第二次访问
// 只是「同一行还来了」,UV 不变、PV +1。
func (db *boxViews) RecordBatch(ctx context.Context, opts []RecordBoxViewOptions) error {
if len(opts) == 0 {
return nil
}
now := time.Now()
rows := make([]BoxView, 0, len(opts))
for _, o := range opts {
if o.UserID == 0 || o.Visitor == "" {
// 缺用户或访客标识的行没有统计价值,直接丢弃:
// 空 Visitor 会让同一箱所有匿名访问挤进同一行,把 UV 压成 1。
continue
}
rows = append(rows, BoxView{
UserID: o.UserID,
ViewedOn: dayOnly(o.ViewedOn),
Visitor: o.Visitor,
Views: 1,
CreatedAt: now,
UpdatedAt: now,
})
}
if len(rows) == 0 {
return nil
}
// 同一批里可能出现同一个 (user, day, visitor) 多次(并发请求攒到一次 flush):
// PostgreSQL 的 ON CONFLICT 不支持在同一条 INSERT 里两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time")。
// 所以先在内存里按唯一键合并,把次数累加到 Views 上。
merged := make([]BoxView, 0, len(rows))
index := make(map[[3]interface{}]int, len(rows))
for _, r := range rows {
key := [3]interface{}{r.UserID, r.ViewedOn.Format("2006-01-02"), r.Visitor}
if i, ok := index[key]; ok {
merged[i].Views += r.Views
continue
}
index[key] = len(merged)
merged = append(merged, r)
}
err := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "viewed_on"}, {Name: "visitor"}},
DoUpdates: clause.Assignments(map[string]interface{}{
// 表名限定:PG 里裸 views 在 INSERT ... ON CONFLICT 的 SET 子句里
// 可能被当成目标列的别名解析,加上表名前缀最稳。
"views": gorm.Expr("box_views.views + excluded.views"),
"updated_at": now,
}),
}).Create(&merged).Error
if err != nil {
return errors.Wrap(err, "upsert box views")
}
return nil
}
func (db *boxViews) Daily(ctx context.Context, userID uint, from, to time.Time) ([]BoxViewDay, error) {
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ? AND viewed_on >= ? AND viewed_on <= ?", userID, dayOnly(from), dayOnly(to)).
Group("viewed_on").
Order("viewed_on").
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "aggregate box view days")
}
return days, nil
}
func (db *boxViews) TopDays(ctx context.Context, userID uint, limit int) ([]BoxViewDay, error) {
if limit <= 0 {
limit = 1
}
var days []BoxViewDay
err := db.WithContext(ctx).Model(&BoxView{}).
Select("viewed_on, SUM(views) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Group("viewed_on").
Order("pv DESC").
Limit(limit).
Scan(&days).Error
if err != nil {
return nil, errors.Wrap(err, "top box view days")
}
return days, nil
}
func (db *boxViews) FirstDay(ctx context.Context, userID uint) (time.Time, error) {
var day *time.Time
err := db.WithContext(ctx).Model(&BoxView{}).
Select("MIN(viewed_on)").
Where("user_id = ?", userID).
Scan(&day).Error
if err != nil {
return time.Time{}, errors.Wrap(err, "first box view day")
}
if day == nil {
return time.Time{}, nil
}
return *day, nil
}
func (db *boxViews) Totals(ctx context.Context, userID uint) (int64, int64, error) {
var row struct {
PV int64 `gorm:"column:pv"`
UV int64 `gorm:"column:uv"`
}
err := db.WithContext(ctx).Model(&BoxView{}).
Select("COALESCE(SUM(views), 0) AS pv, COUNT(*) AS uv").
Where("user_id = ?", userID).
Scan(&row).Error
if err != nil {
return 0, 0, errors.Wrap(err, "total box views")
}
return row.PV, row.UV, nil
}
// Purge 删除 before 之前的访问明细。返回删除行数。
//
// 用 Delete 而非 Unscoped 也没关系 —— BoxView 没有 DeletedAt 字段,
// GORM 看不到软删除标记,会直接发 DELETE。
func (db *boxViews) Purge(ctx context.Context, before time.Time) (int64, error) {
res := db.WithContext(ctx).
Where("viewed_on < ?", dayOnly(before)).
Delete(&BoxView{})
if res.Error != nil {
return 0, errors.Wrap(res.Error, "purge box views")
}
return res.RowsAffected, nil
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestBoxViews(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
store := NewBoxViewsStore(db)
for _, tc := range []struct {
name string
test func(t *testing.T, ctx context.Context, store BoxViewsStore)
}{
{"BatchMergesSameKey", testBoxViewsBatchMergesSameKey},
{"AccumulatesAcrossCalls", testBoxViewsAccumulatesAcrossCalls},
{"DailyAndTotals", testBoxViewsDailyAndTotals},
{"PurgeKeepsRecent", testBoxViewsPurgeKeepsRecent},
} {
t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() {
if err := cleanup("box_views"); err != nil {
t.Fatal(err)
}
})
tc.test(t, ctx, store)
})
}
}
// 同一批里同 (user, day, visitor) 出现多次时,必须先在内存合并再写。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE 不允许同一条 INSERT 两次命中同一行
// ("ON CONFLICT DO UPDATE command cannot affect row a second time"),
// 不合并的话记录器一次 flush 就会整批失败 —— 而 flush 里恰好可能包含
// 同一访客在 2 秒内刷新三次的记录。
func testBoxViewsBatchMergesSameKey(t *testing.T, ctx context.Context, store BoxViewsStore) {
now := time.Date(2026, 10, 29, 15, 4, 5, 0, time.Local)
// 同一访客三次 + 另一个访客一次,全在同一批里
err := store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "aaaa"},
{UserID: 7, ViewedOn: now, Visitor: "bbbb"},
})
require.NoError(t, err)
pv, uv, err := store.Totals(ctx, 7)
require.NoError(t, err)
require.Equal(t, int64(4), pv, "三次重复访问应累加进 views")
require.Equal(t, int64(2), uv, "同一访客当天只算一个 UV")
}
func testBoxViewsAccumulatesAcrossCalls(t *testing.T, ctx context.Context, store BoxViewsStore) {
day := time.Date(2026, 10, 28, 9, 0, 0, 0, time.Local)
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "x"}))
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{UserID: 3, ViewedOn: day, Visitor: "y"}))
pv, uv, err := store.Totals(ctx, 3)
require.NoError(t, err)
require.Equal(t, int64(3), pv)
require.Equal(t, int64(2), uv)
}
func testBoxViewsDailyAndTotals(t *testing.T, ctx context.Context, store BoxViewsStore) {
base := time.Date(2026, 10, 20, 12, 0, 0, 0, time.Local)
// 20 日:2 访客共 3 次;21 日:1 访客 1 次;22 日:无记录
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 11, ViewedOn: base, Visitor: "a"},
{UserID: 11, ViewedOn: base, Visitor: "a"},
{UserID: 11, ViewedOn: base, Visitor: "b"},
{UserID: 11, ViewedOn: base.AddDate(0, 0, 1), Visitor: "a"},
}))
// 另一个箱主的数据不能串进来
require.NoError(t, store.Record(ctx, RecordBoxViewOptions{
UserID: 12, ViewedOn: base, Visitor: "a",
}))
days, err := store.Daily(ctx, 11, base, base.AddDate(0, 0, 2))
require.NoError(t, err)
require.Len(t, days, 2, "没有记录的那天不返回,由上层补零")
require.Equal(t, "2026-10-20", days[0].Day.Format("2006-01-02"))
require.Equal(t, int64(3), days[0].PV)
require.Equal(t, int64(2), days[0].UV)
require.Equal(t, "2026-10-21", days[1].Day.Format("2006-01-02"))
require.Equal(t, int64(1), days[1].PV)
require.Equal(t, int64(1), days[1].UV)
pv, uv, err := store.Totals(ctx, 11)
require.NoError(t, err)
require.Equal(t, int64(4), pv)
require.Equal(t, int64(3), uv)
first, err := store.FirstDay(ctx, 11)
require.NoError(t, err)
require.Equal(t, "2026-10-20", first.Format("2006-01-02"))
}
func testBoxViewsPurgeKeepsRecent(t *testing.T, ctx context.Context, store BoxViewsStore) {
old := time.Now().AddDate(0, 0, -30)
recent := time.Now()
require.NoError(t, store.RecordBatch(ctx, []RecordBoxViewOptions{
{UserID: 5, ViewedOn: old, Visitor: "a"},
{UserID: 5, ViewedOn: recent, Visitor: "b"},
}))
n, err := store.Purge(ctx, time.Now().AddDate(0, 0, -7))
require.NoError(t, err)
require.Equal(t, int64(1), n)
pv, uv, err := store.Totals(ctx, 5)
require.NoError(t, err)
require.Equal(t, int64(1), pv, "只保留保留期内的行")
require.Equal(t, int64(1), uv)
}
+170 -2
View File
@@ -6,19 +6,30 @@ package db
import (
"context"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Conversations ConversationStore
var (
ErrDailyMessageLimit = errors.New("已达到每日私聊上限")
ErrConversationLimit = errors.New("会话已达到消息上限或已关闭")
)
type ConversationStore interface {
Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error)
// CreateWithFirstMessage 事务化地建会话 + 首条消息 + 消息计数。
// 单独写首条消息(用于「发第一条私聊」这类一次性动作),避免失败时留下空会话。
CreateWithFirstMessage(ctx context.Context, opts CreateConversationOptions, senderID uint, content string) (*Conversation, *Message, error)
SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error)
GetByID(ctx context.Context, id uint) (*Conversation, error)
GetByToken(ctx context.Context, token string) (*Conversation, error)
GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error)
@@ -71,6 +82,39 @@ type CreateConversationOptions struct {
MaxMessages int
}
type SendConversationMessageOptions struct {
ConversationID uint
SenderID uint
Content string
Image *CreateMessageImageOptions
}
func reserveDailyMessageTx(tx *gorm.DB, ownerID, senderID uint) error {
var sender User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, senderID).Error; err != nil {
return errors.Wrap(err, "lock message sender")
}
var owner User
if err := tx.Select("daily_message_limit").First(&owner, ownerID).Error; err != nil {
return errors.Wrap(err, "get owner daily message limit")
}
if owner.DailyMessageLimit <= 0 {
return nil
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var count int64
if err := tx.Table("messages").Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("conversations.owner_id = ? AND messages.sender_id = ? AND messages.created_at >= ?", ownerID, senderID, start).
Count(&count).Error; err != nil {
return errors.Wrap(err, "count today messages")
}
if count >= int64(owner.DailyMessageLimit) {
return ErrDailyMessageLimit
}
return nil
}
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
conversation := &Conversation{
OwnerID: opts.OwnerID,
@@ -83,6 +127,117 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
return conversation, nil
}
// CreateWithFirstMessage 在一个事务里完成「建会话 + 写首条消息 + 消息计数」。
//
// 原来这三步是三次独立写入:第二步失败时会话已经提交,于是留下一条
// message_count=0、没有任何消息的「空会话」——箱主的私聊列表里能看到它,
// 点进去什么都没有;更麻烦的是 Conversations.Create 的 BeforeCreate 已经
// 生成并占用了一个 6 字符 token(唯一索引),孤儿会话会永久占着这个 token。
func (db *conversations) CreateWithFirstMessage(
ctx context.Context, opts CreateConversationOptions, senderID uint, content string,
) (*Conversation, *Message, error) {
var conv *Conversation
var msg *Message
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if senderID == opts.AskerID {
if err := reserveDailyMessageTx(tx, opts.OwnerID, senderID); err != nil {
return err
}
}
conv = &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
// 走 tx 而不是 db:BeforeCreate 里生成 token 的 hook 同样会被触发。
if err := tx.Create(conv).Error; err != nil {
return errors.Wrap(err, "create conversation")
}
msg = &Message{
ConversationID: conv.ID,
SenderID: senderID,
Content: content,
}
if err := tx.Create(msg).Error; err != nil {
return errors.Wrap(err, "create first message")
}
// 原子自增,与 IncrementMessageCount 保持同一写法
if err := tx.Model(&Conversation{}).
Where("id = ?", conv.ID).
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
return errors.Wrap(err, "increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)。
// 放在事务里而不是 handler 里:原来 handler 在事务之后调 IncrementQuota 且
// 失败只打 Warn,会话建成了但计数没加,箱主的「已收集」偏少、上限被绕过。
if err := incrementQuotaTx(tx, opts.OwnerID, FeatureChat, 1); err != nil {
return errors.Wrap(err, "increment chat quota")
}
return nil
})
if err != nil {
return nil, nil, err
}
return conv, msg, nil
}
func (db *conversations) SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error) {
msg := &Message{ConversationID: opts.ConversationID, SenderID: opts.SenderID, Content: opts.Content}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先锁发送者,再锁会话,保证与 CreateWithFirstMessage 的锁顺序一致,
// 避免并发下两条路径互相等待造成配额检查窗口。
var sender User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, opts.SenderID).Error; err != nil {
return errors.Wrap(err, "lock message sender")
}
var conv Conversation
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conv, opts.ConversationID).Error; err != nil {
return errors.Wrap(err, "lock conversation")
}
// 发送者必须是会话双方之一
if conv.OwnerID != opts.SenderID && conv.AskerID != opts.SenderID {
return errors.New("sender is not part of the conversation")
}
// 只有 asker 发送才消耗 owner 的每日配额
if conv.AskerID == opts.SenderID {
if err := reserveDailyMessageTx(tx, conv.OwnerID, opts.SenderID); err != nil {
return err
}
}
res := tx.Model(&Conversation{}).Where("id = ? AND message_count < max_messages AND is_closed = ?", conv.ID, false).
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
if res.Error != nil {
return errors.Wrap(res.Error, "increment message count")
}
if res.RowsAffected == 0 {
return ErrConversationLimit
}
if err := tx.Create(msg).Error; err != nil {
return errors.Wrap(err, "create message")
}
if opts.Image != nil {
image := *opts.Image
image.MessageID = msg.ID
if err := tx.Create(&MessageImage{MessageID: image.MessageID, URL: image.URL, ObjectKey: image.ObjectKey, FileName: image.FileName, FileSize: image.FileSize}).Error; err != nil {
return errors.Wrap(err, "create message image")
}
}
return nil
})
if err != nil {
return nil, err
}
return msg, nil
}
func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
@@ -216,8 +371,21 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
return nil
}
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
return errors.Wrap(err, "mark conversation deleted by owner")
// 用「条件更新 + RowsAffected」代替纯 Update:
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
// 而箱主重置计数也校准不回来。
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND is_deleted_by_owner = ?", id, false).
Update("is_deleted_by_owner", true)
if res.Error != nil {
return errors.Wrap(res.Error, "mark conversation deleted by owner")
}
if res.RowsAffected == 0 {
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
return nil
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
+41 -38
View File
@@ -15,9 +15,28 @@ import (
"github.com/tamakyi/TamaBox/internal/conf"
)
// handle 保存 Init 建立的连接,供「不需要具体 store」的场景使用(如就绪探针)。
// 与下面那些业务 store 的包级变量区分开:那些是接口,这个是连接本身。
var handle *gorm.DB
// Ping 检查数据库连接是否可用。供 /readyz 就绪探针调用。
//
// 为什么需要它:原来的「探活」只能 curl /,而首页只渲染模板、不碰数据库 ——
// DB 挂了首页照样 200,反过来 Redis 挂了首页又可能 500。于是探针既会漏报也会误报。
func Ping(ctx context.Context) error {
if handle == nil {
return errors.New("database not initialized")
}
sqlDB, err := handle.DB()
if err != nil {
return errors.Wrap(err, "get sql.DB")
}
return sqlDB.PingContext(ctx)
}
var AllTables = []interface{}{
&User{}, &Question{}, &FollowUp{}, &CensorLog{}, &UploadImage{}, &UploadImageQuestion{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{},
&Conversation{}, &Message{}, &MessageImage{}, &UserBlock{}, &R2DeleteTask{},
// 匿名投票
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
@@ -26,6 +45,14 @@ var AllTables = []interface{}{
&UserRestriction{}, &ContentReport{},
// 蜜罐命中记录(审计证据,只追加不软删)
&HoneypotHit{},
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
&IPGeoRecord{},
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
&SecurityReportLog{},
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
&BanRecord{},
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
&BoxView{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -41,11 +68,13 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newSafeLogger(),
SkipDefaultTransaction: true,
})
if err != nil {
return nil, errors.Wrap(err, "connect to database")
}
handle = db
sqlDB, err := db.DB()
if err != nil {
@@ -66,47 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
}
}
// questions.answered_at 是 2026-10 新增的列(首次回答时间,用于算「回答耗时」)。
// 加列之前已回答的提问没有这个值,只能事后拿 updated_at 近似。
// 必须在 AutoMigrate 之前判断列是否存在 —— 迁移之后大家都有列了,就分不出谁是历史行。
hasAnsweredAt := db.Migrator().HasColumn(&Question{}, "answered_at")
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
if err := db.AutoMigrate(AllTables...); err != nil {
return nil, errors.Wrap(err, "auto migrate")
}
// 回填只在「这次刚加上列」时做一次。
// 为什么必须回填:这些行的 updated_at 之后还会被已读/置顶继续往前推,
// 不回填的话历史「回答耗时」会一天天变大,看起来像统计出错。
// 失败不阻断启动,页面会退回 updated_at 计算。
if !hasAnsweredAt {
if err := backfillAnsweredAt(db); err != nil {
logrus.WithError(err).Warn("回填 questions.answered_at 时异常(历史回答耗时将退回 updated_at)")
}
}
// 功能开关初始化:为历史账号补齐 feature_controls 行(私聊沿用 users.allow_chat)。
// 幂等,已初始化的账号不会重复写入;失败不阻断启动,仅告警,避免依赖表抖动导致服务起不来。
if err := backfillFeatureControls(db); err != nil {
logrus.WithError(err).Warn("初始化功能开关配置时异常(可在功能管理页手动保存一次以生成配置)")
}
// 私聊开关纠偏:旧版 backfill 曾把未开启私聊的账号写为开启(GORM 零值陷阱),
// 启动期幂等复位;同样不阻断启动,失败仅告警。
if err := reconcileLegacyChatEnabled(db); err != nil {
logrus.WithError(err).Warn("私聊开关历史数据纠偏时异常(可在功能管理页手动调整私聊开关)")
}
// 私聊收集上限下线:私聊不再支持收集上限(UI 已隐藏,保存时强制清零),
// 启动期把历史残留的上限/计数一并清零,避免私聊「莫名关闭」。幂等,失败仅告警。
if err := normalizeChatQuota(db); err != nil {
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
}
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
if err := normalizeHarassmentSetting(db); err != nil {
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
// 版本化迁移:AutoMigrate 做不了的破坏性变更(改列名/删列/改约束)与一次性数据回填
// 都收编进这里,靠 schema_migrations 账本保证「只跑一次、顺序固定、可审计」。
// 迁移失败会阻断启动 —— 半途而废的迁移比静默跳过更危险(见 runMigrations 注释)。
if err := runMigrations(db); err != nil {
return nil, errors.Wrap(err, "run versioned migrations")
}
// 启动期补充高频查询复合索引(幂等,适合既有库)。
@@ -134,6 +132,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Conversations = NewConversationStore(db)
Messages = NewMessageStore(db)
MessageImages = NewMessageImageStore(db)
R2DeleteTasks = NewR2DeleteTaskStore(db)
Votes = NewVotesStore(db)
VoteOptions = NewVoteOptionsStore(db)
VoteFormFields = NewVoteFormFieldsStore(db)
@@ -142,7 +141,10 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db)
BanRecords = NewBanRecordsStore(db)
Analytics = NewSiteAnalyticsStore(db)
BoxViews = NewBoxViewsStore(db)
BoxAnalytics = NewBoxAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
@@ -155,6 +157,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
otelgorm.WithoutQueryVariables(),
)); err != nil {
return nil, errors.Wrap(err, "register otelgorm plugin")
}
+51 -3
View File
@@ -20,6 +20,8 @@ import (
var FeatureControls FeatureControlsStore
var ErrFeatureQuotaReached = errors.New("功能收集配额已满")
var _ FeatureControlsStore = (*featureControls)(nil)
// 受控功能标识(feature_controls.feature 取值)。
@@ -360,6 +362,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now())
}
// FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。
//
// 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让
// 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的
// 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败
// 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。
// 与审核链路「不可用即拒绝」同一口径。
func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) {
if FeatureControls == nil {
// 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。
return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return FeatureState{}, errors.Wrap(err, "get feature control")
}
return fc.Evaluate(time.Now()), nil
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +509,37 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta)
}
// incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。
//
// 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。
// 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn ——
// 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收,
// 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。
// 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。
func reserveFeatureQuotaTx(tx *gorm.DB, userID uint, feature string) error {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(DefaultFeatureControl(userID, feature)).Error; err != nil {
return errors.Wrap(err, "initialize feature quota")
}
var fc FeatureControl
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error; err != nil {
return errors.Wrap(err, "lock feature quota")
}
if fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
return ErrFeatureQuotaReached
}
return nil
}
func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
@@ -513,7 +561,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
created := tx.
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
@@ -523,7 +571,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),
+52 -14
View File
@@ -18,6 +18,9 @@ var (
ErrFollowUpLimitReached = errors.New("follow up limit reached")
ErrFollowUpNotFound = errors.New("follow up not found")
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
// ErrFollowUpAwaitingReply 上一条追问尚未被箱主回复,不允许继续追问。
// 由 CreateIfBelowLimit 在事务内判定(见该方法的注释)。
ErrFollowUpAwaitingReply = errors.New("previous follow up awaiting reply")
)
var FollowUps FollowUpsStore
@@ -35,10 +38,15 @@ type FollowUpsStore interface {
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
// DeleteByQuestionID 删除某条提问时级联清理其全部追问。
//
// 用 Unscoped 真删:追问正文是提问者的个人内容,随提问一并真删(与 questions.DeleteByID
// 对主表的真删口径一致)。软删会留下带 deleted_at 的追问文本,且这些孤儿行的
// question_id 指向一个已不存在的提问 —— 与「删除即真删、个人数据不残留」的意图相悖。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -84,6 +92,23 @@ func (db *followUps) CreateIfBelowLimit(ctx context.Context, opts CreateFollowUp
return ErrFollowUpLimitReached
}
// 「上一条追问必须先被回复」也必须在事务内判断。
//
// 这段校验原来只在 handler 里做(事务外):双击提交时两个请求都读到
// 「最新一条还没回复」,随后被这行 FOR UPDATE 串行化,各自 count 都 < 3,
// 于是两条内容相同的追问都创建成功 —— 吃掉 2/3 名额,箱主还会收两封邮件。
// 行锁只保护「条数」,不保护「业务前置条件」,所以判断必须挪进来。
var latest FollowUp
if err := tx.Where("question_id = ?", opts.QuestionID).
Order("id DESC"). // 用 id 而非 created_at:同毫秒写入时时间排序不稳定
Limit(1).
Find(&latest).Error; err != nil {
return errors.Wrap(err, "get latest follow up for reply check")
}
if latest.ID != 0 && latest.Answer == "" {
return ErrFollowUpAwaitingReply
}
f := &FollowUp{
QuestionID: opts.QuestionID,
Content: opts.Content,
@@ -188,23 +213,36 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
res := db.WithContext(ctx).
Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer)
if res.Error != nil {
return errors.Wrap(res.Error, "update follow up answer")
}
if res.RowsAffected == 0 {
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
var wasFirst bool
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
var fu FollowUp
if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFollowUpNotFound
}
return errors.Wrap(err, "get follow up")
}
wasFirst = fu.Answer == ""
if err := tx.Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer).Error; err != nil {
return errors.Wrap(err, "update follow up answer")
}
return nil
})
if err != nil {
return false, err
}
return wasFirst, nil
}
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(真删,与提问保持一致)。
func (db *followUps) DeleteByQuestionID(ctx context.Context, questionID uint) error {
if err := db.WithContext(ctx).Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
if err := db.WithContext(ctx).Unscoped().Where("question_id = ?", questionID).Delete(&FollowUp{}).Error; err != nil {
return errors.Wrap(err, "delete follow ups by question id")
}
return nil
+77
View File
@@ -97,6 +97,20 @@ type HoneypotStore interface {
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
Size(ctx context.Context) (HoneypotSize, error)
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
// 聚合能把它压到「独立 IP 数」的量级。
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
//
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
LegacyCredentialColumns(ctx context.Context) ([]string, error)
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
@@ -121,6 +135,20 @@ type HoneypotSourceStat struct {
Provider string
}
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
//
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
type HoneypotSourceAgg struct {
IP string
Hits int64
Banned int64
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
Creds int64
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
Provider string
}
// HoneypotPathStat 路径聚合行。
type HoneypotPathStat struct {
Path string
@@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
return rows, err
}
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
//
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
var rows []*HoneypotSourceAgg
err := h.db.WithContext(ctx).
Table("honeypot_hits").
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
// 不能直接 sum,转 1/0 才跨方言)。
Select("ip AS ip, count(*) AS hits, "+
"sum(case when banned then 1 else 0 end) AS banned, "+
"sum(case when had_fields then 1 else 0 end) AS creds, "+
"max(provider) AS provider").
Where("created_at >= ? AND created_at < ?", since, until).
Group("ip").
Order("hits DESC").
Scan(&rows).Error
return rows, err
}
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
if limit <= 0 || limit > 500 {
limit = 20
}
var rows []*HoneypotPathStat
err := h.db.WithContext(ctx).
Table("honeypot_hits").
Select("path AS path, count(*) AS count").
Where("created_at >= ? AND created_at < ?", since, until).
Group("path").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
var ts []time.Time
err := h.db.WithContext(ctx).
Model(&HoneypotHit{}).
Where("created_at >= ? AND created_at < ?", since, until).
Order("created_at ASC").
Pluck("created_at", &ts).Error
return ts, err
}
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
return res.RowsAffected, res.Error
+70
View File
@@ -159,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), info2.Rows)
}
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
//
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
// - [since, until) 左闭右开,相邻两期不能重叠;
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
func TestHoneypotAggregateRange(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("honeypot_hits"); err != nil {
t.Fatal(err)
}
})
store := NewHoneypotStore(database)
loc := time.Local
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 7)
rows := []*HoneypotHit{
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
// 窗口外(右侧开区间边界):不能算进本期
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
// 窗口外(早于起点)
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
}
for _, r := range rows {
require.NoError(t, store.Create(ctx, r))
}
sources, err := store.AggregateSources(ctx, start, end)
require.NoError(t, err)
require.Len(t, sources, 2)
// 按命中数降序:203.0.113.1 在前
require.Equal(t, "203.0.113.1", sources[0].IP)
require.Equal(t, int64(3), sources[0].Hits)
require.Equal(t, int64(3), sources[0].Banned)
require.Equal(t, int64(1), sources[0].Creds)
require.Equal(t, "Azure", sources[0].Provider)
require.Equal(t, "198.51.100.2", sources[1].IP)
require.Equal(t, int64(1), sources[1].Hits)
require.Equal(t, int64(0), sources[1].Banned)
paths, err := store.AggregatePaths(ctx, start, end, 10)
require.NoError(t, err)
require.Len(t, paths, 3)
require.Equal(t, "/wp-login.php", paths[0].Path)
require.Equal(t, int64(2), paths[0].Count)
stamps, err := store.TimestampsBetween(ctx, start, end)
require.NoError(t, err)
require.Len(t, stamps, 4, "右边界那条不能算进来")
for _, ts := range stamps {
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
}
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
require.NoError(t, err)
require.Empty(t, empty)
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestSetPinnedIfCurrentOnlyAppliesOnce 覆盖「双击置顶」这个场景。
//
// 原来的 handler 是 read-modify-write:SetPinned(id, !question.IsPinned)。
// 双击时两个请求都读到 is_pinned=false,都写 true,于是用户点了两次、期望
// 「置顶后取消」,结果仍是「置顶」。现在加 current 条件,第二次会因为状态
// 已变而影响 0 行 —— 语义变成「只切换一次」,符合用户本意(手抖点两下)。
func TestSetPinnedIfCurrentOnlyAppliesOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("questions"); err != nil {
t.Fatal(err)
}
})
store := NewQuestionsStore(database)
q, err := store.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "试试置顶",
})
require.NoError(t, err)
require.False(t, q.IsPinned)
// 第一次切换:false -> true,生效
applied, err := store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.True(t, applied, "第一次切换应生效")
// 第二次切换:请求方仍然以为当前是 false(它读到的旧值),写入 true
// 此时数据库已经是 true,条件不成立 -> 0 行
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.False(t, applied, "状态已变,第二次切换不应再生效")
got, err := store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.True(t, got.IsPinned, "最终状态应仍是置顶,而不是被双击反转")
// 正常取消置顶(条件匹配)
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, true, false)
require.NoError(t, err)
require.True(t, applied)
got, err = store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.False(t, got.IsPinned)
}
// TestFollowUpRejectsSecondWhilePreviousUnanswered 覆盖「追问必须等主人回复」。
//
// 这条规则原来只在 handler 里判断(事务外)。双击提交时两个请求都能通过那次检查,
// 随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条内容相同的
// 追问都创建成功,吃掉 2/3 名额,箱主还会收到两封邮件。
// 现在判定挪进事务,第二个请求会拿到 ErrFollowUpAwaitingReply。
func TestFollowUpRejectsSecondWhilePreviousUnanswered(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("follow_ups", "questions"); err != nil {
t.Fatal(err)
}
})
qStore := NewQuestionsStore(database)
q, err := qStore.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "允许我追问吗",
})
require.NoError(t, err)
fStore := NewFollowUpsStore(database)
created, err := fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问",
})
require.NoError(t, err)
require.True(t, created)
// 第一条还没被回复:第二次追问必须被拒
_, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问", // 内容相同,模拟双击
})
require.ErrorIs(t, err, ErrFollowUpAwaitingReply)
// 确认库里只有一条(没有因为双击写入两条)
list, err := fStore.GetByQuestionID(ctx, q.ID)
require.NoError(t, err)
require.Len(t, list, 1, "双击不应产生两条追问")
// 主人回复后,允许继续追问
require.NoError(t, fStore.AnswerByID(ctx, q.ID, list[0].ID, "答:可以"))
created, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第二次追问",
})
require.NoError(t, err)
require.True(t, created, "上一条已回复后应允许继续追问")
}
// TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce 覆盖并发删除会话时的配额回退。
//
// 原来是 check-then-act:两次并发请求都读到 is_deleted_by_owner=false,
// 都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉
// 其他会话的额度,表现为定量收集提前关闭。
func TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("conversations", "feature_controls"); err != nil {
t.Fatal(err)
}
})
const ownerID = 7
// 先把私聊配额计数做到 5
fcStore := NewFeatureControlsStore(database)
require.NoError(t, fcStore.IncrementQuota(ctx, ownerID, FeatureChat, 5))
before, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 5, before.QuotaCount)
convStore := NewConversationStore(database)
conv, err := convStore.Create(ctx, CreateConversationOptions{OwnerID: ownerID})
require.NoError(t, err)
// 并发两次删除(模拟双击 / 前端重复提交)
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errs[idx] = convStore.MarkDeletedByOwner(ctx, conv.ID)
}(i)
}
wg.Wait()
require.NoError(t, errs[0])
require.NoError(t, errs[1])
after, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 4, after.QuotaCount,
"两个并发删除请求只应回退一次配额(5 -> 4),而不是两次(5 -> 3)")
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
//
// 为什么必须落库而不是每次现查:
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
//
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
// 既浪费配额又得到一模一样的结果。
type IPGeoRecord struct {
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
IPKey string `gorm:"size:64;primarykey"`
Country string `gorm:"size:64"`
CountryCode string `gorm:"size:8"`
Region string `gorm:"size:128"`
City string `gorm:"size:128"`
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
ISP string `gorm:"size:160"`
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
ASN string `gorm:"size:64"`
// Lat / Lon 为定位坐标。坐标系随来源不同:
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
// ipapi → WGS-84
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
Lat float64
Lon float64
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
HasCoords bool
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
Source string `gorm:"size:16"`
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
// (保留地址、库未收录),反复重试只会白白消耗配额。
Failed bool
ResolvedAt time.Time `gorm:"index"`
}
// TableName 固定表名。
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
// IPGeos 地理定位缓存全局 store。
var IPGeos IPGeoStore
// IPGeoStore 地理定位缓存的读写。
type IPGeoStore interface {
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
// UpsertMany 批量写入或覆盖。
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
//
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
// (对方 API 超时),留太久等于永久放弃这个 IP。
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
// Count 返回缓存条目数。
Count(ctx context.Context) (int64, error)
}
type ipGeos struct {
db *gorm.DB
}
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
return &ipGeos{db: db}
}
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
out := make(map[string]*IPGeoRecord, len(keys))
if len(keys) == 0 {
return out, nil
}
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
const batch = 500
for start := 0; start < len(keys); start += batch {
end := start + batch
if end > len(keys) {
end = len(keys)
}
var rows []*IPGeoRecord
if err := s.db.WithContext(ctx).
Where("ip_key IN ?", keys[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.IPKey] = r
}
}
return out, nil
}
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
if len(records) == 0 {
return nil
}
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
cols := []string{
"country", "country_code", "region", "city", "isp", "asn",
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
}
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ip_key"}},
DoUpdates: clause.AssignmentColumns(cols),
}).
CreateInBatches(records, 100).Error
}
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
if onlyFailed {
q = q.Where("failed = ?", true)
} else {
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
q = q.Where("failed = ?", false)
}
res := q.Delete(&IPGeoRecord{})
return res.RowsAffected, res.Error
}
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
var n int64
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
return n, err
}
+174
View File
@@ -0,0 +1,174 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
//
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
func TestIPGeoStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
records := []*IPGeoRecord{
{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
},
{
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
HasCoords: true, Source: "tencent", ResolvedAt: now,
},
{
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
},
}
require.NoError(t, store.UpsertMany(ctx, records))
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
require.NoError(t, err)
require.Len(t, got, 3, "不存在的键不应出现在结果里")
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
require.True(t, got["203.0.113.1"].HasCoords)
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
require.True(t, got["198.51.100.9"].Failed)
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
Source: "tencent", ResolvedAt: now.Add(time.Hour),
}}))
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
require.NoError(t, err)
require.Equal(t, "Reston", got2["203.0.113.1"].City)
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
n, err := store.Count(ctx)
require.NoError(t, err)
require.Equal(t, int64(3), n)
}
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
func TestIPGeoStorePurge(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("ip_geo_cache"); err != nil {
t.Fatal(err)
}
})
store := NewIPGeoStore(database)
now := time.Now()
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
}))
// 清失败的旧记录:只删 1.0.0.3
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
require.NoError(t, err)
require.Equal(t, int64(1), n)
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
require.NoError(t, err)
require.Equal(t, int64(1), n)
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
require.NoError(t, err)
require.Len(t, left, 2)
require.Contains(t, left, "1.0.0.2")
require.Contains(t, left, "1.0.0.4")
}
// TestSecurityReportStore 覆盖报告台账的幂等语义。
//
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
func TestSecurityReportStore(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("security_report_logs"); err != nil {
t.Fatal(err)
}
})
store := NewSecurityReportStore(database)
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
end := start.AddDate(0, 0, 7)
// 首次:不存在
rec, err := store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.Nil(t, rec)
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.NotNil(t, rec)
require.False(t, rec.Sent)
require.Equal(t, 1, rec.Attempts)
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
// 否则「这一期发过没」会被多行记录搞乱。
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "week:2026-W43", Kind: "week",
WindowStart: start, WindowEnd: end,
Hits: 42, UniqueSources: 7, Countries: 3,
Recipient: "[email protected]", Attempts: 2, Sent: true,
}))
rec, err = store.Get(ctx, "week:2026-W43")
require.NoError(t, err)
require.True(t, rec.Sent)
require.Equal(t, 2, rec.Attempts)
recent, err := store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 1, "同一期只能有一行台账")
// 不同周期应各自一行
require.NoError(t, store.Save(ctx, &SecurityReportLog{
PeriodKey: "month:2026-10", Kind: "month",
WindowStart: start, WindowEnd: end, Sent: true,
}))
recent, err = store.Recent(ctx, 10)
require.NoError(t, err)
require.Len(t, recent, 2)
}
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm/logger"
)
type safeLogger struct {
logger.Config
}
func newSafeLogger() logger.Interface {
return &safeLogger{Config: logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
}}
}
func (l *safeLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *l
newLogger.LogLevel = level
return &newLogger
}
func (l *safeLogger) Info(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Info {
logrus.WithContext(ctx).WithField("component", "gorm").Info("数据库信息事件")
}
}
func (l *safeLogger) Warn(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Warn {
logrus.WithContext(ctx).WithField("component", "gorm").Warn("数据库警告事件")
}
}
func (l *safeLogger) Error(ctx context.Context, _ string, _ ...interface{}) {
if l.LogLevel >= logger.Error {
logrus.WithContext(ctx).WithField("component", "gorm").Error("数据库错误事件")
}
}
func (l *safeLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= logger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && l.LogLevel >= logger.Error && (!errors.Is(err, logger.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).WithFields(logrus.Fields{
"error_type": fmt.Sprintf("%T", err),
"record_not_found": errors.Is(err, logger.ErrRecordNotFound),
}).Error("数据库操作失败")
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= logger.Warn:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).
WithField("slow_threshold", l.SlowThreshold.String()).
Warn("数据库慢查询")
case l.LogLevel >= logger.Info:
_, rows := fc()
l.traceEntry(ctx, elapsed, rows).Info("数据库操作完成")
}
}
func (l *safeLogger) traceEntry(ctx context.Context, elapsed time.Duration, rows int64) *logrus.Entry {
return logrus.WithContext(ctx).WithFields(logrus.Fields{
"component": "gorm",
"elapsed_ms": float64(elapsed.Nanoseconds()) / 1e6,
"rows_affected": rows,
})
}
+32 -2
View File
@@ -7,9 +7,17 @@ import (
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// minChatMessagesPerPage 是会话消息单次加载的兜底条数。
//
// 正常情况下取 conf.Limits.ChatMaxMessages(默认 200,即一次把整个会话读满)。
// 这个常量只在「配置未初始化」(零值,测试里直接构造 store 时会遇到)时生效,
// 不会覆盖箱主显式配置的更大或更小的值。
const minChatMessagesPerPage = 50
var Messages MessageStore
type MessageStore interface {
@@ -60,20 +68,42 @@ func (db *messages) GetByConversationID(ctx context.Context, conversationID uint
var messages []*Message
q := db.WithContext(ctx).Where("conversation_id = ?", conversationID)
limit := 50
// 默认一次取满整个会话,而不是固定 50 条。
//
// 原先写死 limit=50 且按 created_at ASC 取,取到的是「最早的 50 条」;
// 而会话上限是 conf.Limits.ChatMaxMessages(默认 200)。结果是第 51 条起
// 写库成功、message_count 在涨、页面显示 51/200,但列表里永远没有它 ——
// 用户无论等多久、换设备、重新登录都看不到(实测 55 条时只能渲染 #1–#50)。
limit := conf.Limits.ChatMaxMessages
if limit <= 0 {
// 仅兜底「配置未初始化」(零值)这一种情况。
// 不能写成 limit < 50 → 50:那样会把箱主显式配的小上限抬大,
// 属于替用户改配置,而不是补默认值。
limit = minChatMessagesPerPage
}
if cursor != nil {
if cursor.Value != nil && fmt.Sprintf("%v", cursor.Value) != "" {
// 游标语义是「加载更早的消息」,与下面的 ORDER BY id DESC 配套;
// 若沿用旧的 ASC 排序,id < cursor 每次都会命中同一批最老的消息。
q = q.Where("id < ?", cursor.Value)
}
limit = cursor.Limit()
}
q = q.Order("created_at ASC")
// 按 id 倒序取最新 limit 条,再反转成时间正序交给视图层
// (气泡从上到下 = 旧 → 新)。
//
// 排序键用 id 而非 created_at:同一毫秒内写入的多条消息 created_at 完全相同,
// 排序不稳定会导致同一页里顺序来回变;id 自增与写入顺序严格一致。
q = q.Order("id DESC")
q = q.Limit(limit)
if err := q.Find(&messages).Error; err != nil {
return nil, errors.Wrap(err, "get messages")
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return messages, nil
}
+3
View File
@@ -26,6 +26,7 @@ type MessageImage struct {
gorm.Model
MessageID uint `gorm:"index" json:"message_id"`
URL string `gorm:"size:500" json:"url"`
ObjectKey string `gorm:"type:text" json:"-"`
FileName string `gorm:"size:200" json:"file_name"`
FileSize int64 `json:"file_size"`
}
@@ -33,6 +34,7 @@ type MessageImage struct {
type CreateMessageImageOptions struct {
MessageID uint
URL string
ObjectKey string
FileName string
FileSize int64
}
@@ -41,6 +43,7 @@ func (db *messageImages) Create(ctx context.Context, opts CreateMessageImageOpti
img := &MessageImage{
MessageID: opts.MessageID,
URL: opts.URL,
ObjectKey: opts.ObjectKey,
FileName: opts.FileName,
FileSize: opts.FileSize,
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestMessageImageCreatePersistsObjectKey(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewMessageImageStore(conn)
created, err := store.Create(ctx, CreateMessageImageOptions{
MessageID: 42,
URL: "https://cdn.example.test/assets/2026/11/private.webp",
ObjectKey: "2026/11/private.webp",
FileName: "private.webp",
FileSize: 1234,
})
require.NoError(t, err)
require.Equal(t, "2026/11/private.webp", created.ObjectKey)
var persisted MessageImage
require.NoError(t, conn.WithContext(ctx).First(&persisted, created.ID).Error)
require.Equal(t, created.ObjectKey, persisted.ObjectKey)
byMessage, err := store.GetByMessageIDs(ctx, []uint{42})
require.NoError(t, err)
require.Len(t, byMessage[42], 1)
require.Equal(t, created.ObjectKey, byMessage[42][0].ObjectKey)
encoded, err := json.Marshal(&persisted)
require.NoError(t, err)
require.NotContains(t, string(encoded), "ObjectKey")
require.NotContains(t, string(encoded), "object_key")
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// TestGetByConversationIDReturnsLatest 是本轮修复的回归测试。
//
// 修复前的行为:Order("created_at ASC") + Limit(50),取的是「最早的 50 条」。
// 会话上限是 200,所以第 51 条起写库成功、计数在涨,但列表里永远看不到 ——
// 实测 55 条会话只能渲染 #1–#50,双方都以为对方没回。
//
// 现在要求:默认一次取满会话上限,且返回的是「最新」的那一批。
func TestGetByConversationIDReturnsLatest(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 55
for i := 1; i <= total; i++ {
require.NoError(t, database.Create(&Message{
ConversationID: conv.ID,
SenderID: 0,
Content: fmt.Sprintf("msg #%d", i),
}).Error)
}
store := NewMessageStore(database)
got, err := store.GetByConversationID(ctx, conv.ID, nil)
require.NoError(t, err)
// ① 不再被截断在 50 条
require.Len(t, got, total, "整个会话都应可见,而不是只取最早的 50 条")
// ② 顺序是时间正序(视图层气泡从上到下=旧→新)
require.Equal(t, "msg #1", got[0].Content)
require.Equal(t, fmt.Sprintf("msg #%d", total), got[len(got)-1].Content)
for i := 1; i < len(got); i++ {
require.Less(t, got[i-1].ID, got[i].ID, "返回结果必须按 id 升序,否则气泡顺序会乱")
}
}
// TestGetByConversationIDKeepsNewestWhenOverLimit 覆盖「消息数超过单次加载上限」的场景。
//
// 此时必须丢最老的、保最新的 —— 这正是修复前反过来的地方。
func TestGetByConversationIDKeepsNewestWhenOverLimit(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
// 临时把上限压到 10,避免造 200+ 条拖慢测试
original := conf.Limits.ChatMaxMessages
conf.Limits.ChatMaxMessages = 10
t.Cleanup(func() { conf.Limits.ChatMaxMessages = original })
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 15
for i := 1; i <= total; i++ {
require.NoError(t, database.Create(&Message{
ConversationID: conv.ID,
SenderID: 0,
Content: fmt.Sprintf("msg #%d", i),
}).Error)
}
store := NewMessageStore(database)
got, err := store.GetByConversationID(ctx, conv.ID, nil)
require.NoError(t, err)
require.Len(t, got, 10, "超过上限时应只取上限条数")
// 保最新:应拿到 #6–#15,#1–#5 被丢弃
require.Equal(t, "msg #6", got[0].Content)
require.Equal(t, "msg #15", got[len(got)-1].Content)
}
// TestGetByConversationIDCursorLoadsOlder 覆盖「加载更早消息」的游标语义。
//
// 游标必须与 ORDER BY id DESC 配套;修复前 cursor 走 id < 但排序是 ASC,
// 接上「加载更多」后会反复返回同一批最老的消息。
func TestGetByConversationIDCursorLoadsOlder(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("messages", "conversations"); err != nil {
t.Fatal(err)
}
})
conv := &Conversation{OwnerID: 1}
require.NoError(t, database.Create(conv).Error)
const total = 12
ids := make([]uint, 0, total)
for i := 1; i <= total; i++ {
m := &Message{ConversationID: conv.ID, SenderID: 0, Content: fmt.Sprintf("msg #%d", i)}
require.NoError(t, database.Create(m).Error)
ids = append(ids, m.ID)
}
store := NewMessageStore(database)
// 以第 7 条的 id 为界,往前取 3 条 → 应得到 #4–#6
got, err := store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[6], PageSize: 3})
require.NoError(t, err)
require.Len(t, got, 3)
require.Equal(t, "msg #4", got[0].Content)
require.Equal(t, "msg #6", got[2].Content)
// 再往前翻一页:以第 4 条的 id 为界取 3 条 → #1–#3
got, err = store.GetByConversationID(ctx, conv.ID, &dbutil.Cursor{Value: ids[3], PageSize: 3})
require.NoError(t, err)
require.Len(t, got, 3)
require.Equal(t, "msg #1", got[0].Content)
require.Equal(t, "msg #3", got[2].Content)
}
+149
View File
@@ -0,0 +1,149 @@
package db
import (
"sort"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。
// 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。
//
// 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 /
// 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁
// 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何
// 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来,
// 让迁移可解释、可回看、可审计。
type SchemaMigration struct {
// Version 单调递增的版本号,迁移按它升序执行。
Version int `gorm:"primaryKey"`
// Name 迁移的简短描述,用于日志与人工排查。
Name string `gorm:"type:varchar(128)"`
// AppliedAt 应用时间,仅供审计。
AppliedAt time.Time
}
// Migration 一个数据库迁移步骤。
type Migration struct {
Version int
Name string
// Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里
// 跑过一次;靠版本账本保证「收编后只跑一次」。
Up func(db *gorm.DB) error
}
// migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。
//
// 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑,
// 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次,
// 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」
// 时对历史数据的重复改写风险。
var migrations = []Migration{
{
Version: 1,
Name: "backfill questions.answered_at",
Up: backfillAnsweredAt,
},
{
Version: 2,
Name: "backfill feature_controls",
Up: backfillFeatureControls,
},
{
Version: 3,
Name: "reconcile legacy chat enabled",
Up: reconcileLegacyChatEnabled,
},
{
Version: 4,
Name: "normalize chat quota",
Up: normalizeChatQuota,
},
{
Version: 5,
Name: "normalize harassment setting",
Up: normalizeHarassmentSetting,
},
{
// 新增列 AutoMigrate 只补 0,而 0 与「无授权」的零值撞车 —— 必须回填,
// 否则升级后存量用户的页面密码会被静默绕过。
Version: 6,
Name: "backfill page password version",
Up: backfillPagePasswordVer,
},
}
// runMigrations 按版本号升序执行尚未应用的迁移。
//
// 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移,
// 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的,
// 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。
//
// 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、
// 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate
// 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。
func runMigrations(db *gorm.DB) error {
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
return errors.Wrap(err, "create schema_migrations table")
}
var applied []SchemaMigration
if err := db.Order("version ASC").Find(&applied).Error; err != nil {
return errors.Wrap(err, "load applied migrations")
}
appliedSet := make(map[int]bool, len(applied))
for _, m := range applied {
appliedSet[m.Version] = true
}
// 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。
ordered := make([]Migration, len(migrations))
copy(ordered, migrations)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
for i := 1; i < len(ordered); i++ {
if ordered[i].Version == ordered[i-1].Version {
return errors.Errorf("duplicate migration version %d", ordered[i].Version)
}
}
for _, m := range ordered {
if appliedSet[m.Version] {
continue
}
logrus.WithField("version", m.Version).WithField("name", m.Name).
Info("应用数据库迁移")
// 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。
// 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。
err := db.Transaction(func(tx *gorm.DB) error {
if err := m.Up(tx); err != nil {
return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name)
}
rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}
if err := tx.Create(&rec).Error; err != nil {
return errors.Wrapf(err, "record migration %d", m.Version)
}
return nil
})
if err != nil {
return err
}
}
return nil
}
// AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。
func AppliedMigrations(db *gorm.DB) (map[int]bool, error) {
var rows []SchemaMigration
if err := db.Order("version ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(err, "load applied migrations")
}
set := make(map[int]bool, len(rows))
for _, r := range rows {
set[r.Version] = true
}
return set, nil
}
+71
View File
@@ -0,0 +1,71 @@
package db
import (
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestRunMigrationsIdempotent 迁移账本幂等:同一组迁移跑两遍,第二遍不重复执行。
func TestRunMigrationsIdempotent(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 第一遍:全部迁移都未应用,应执行并落账本。
require.NoError(t, runMigrations(conn))
applied, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Len(t, applied, len(migrations), "第一遍后所有版本都应已记录")
// 第二遍:账本齐全,runMigrations 应立即返回且不报错。
// 若已应用版本被重复执行,迁移事务会因主键冲突报错 —— 无错跑完即证明跳过了。
require.NoError(t, runMigrations(conn))
applied2, err := AppliedMigrations(conn)
require.NoError(t, err)
require.Equal(t, applied, applied2, "重复执行不应改变账本")
}
// TestRunMigrationsStopsOnFailure 迁移失败必须阻断:失败版本不落账本、后续版本不执行。
func TestRunMigrationsStopsOnFailure(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("schema_migrations", "users", "feature_controls")
// 先建账本表(runMigrations 内部会建,但这里手动跑一次无迁移场景需要显式建表)。
require.NoError(t, conn.AutoMigrate(&SchemaMigration{}))
// 用一套独立的迁移清单模拟「第二个迁移失败」:
// v100 成功,v101 失败。预期:v100 落账本,v101 不落,且整体返回错误。
saved := migrations
defer func() { migrations = saved }()
migrations = []Migration{
{Version: 100, Name: "ok", Up: func(*gorm.DB) error { return nil }},
{Version: 101, Name: "boom", Up: func(*gorm.DB) error { return errors.New("intentional") }},
{Version: 102, Name: "never-runs", Up: func(*gorm.DB) error {
t.Error("v102 不应执行,因为 v101 已失败")
return nil
}},
}
err := runMigrations(conn)
require.Error(t, err, "迁移失败必须返回错误")
applied, aErr := AppliedMigrations(conn)
require.NoError(t, aErr)
require.True(t, applied[100], "成功的 v100 应已落账本")
require.False(t, applied[101], "失败的 v101 不应落账本")
require.False(t, applied[102], "被阻断的 v102 不应落账本")
}
// TestMigrationsOrdered 迁移清单必须按版本号严格升序且无重复。
func TestMigrationsOrdered(t *testing.T) {
for i := 1; i < len(migrations); i++ {
require.Less(t, migrations[i-1].Version, migrations[i].Version,
"迁移清单必须按版本号严格升序,第 %d 个版本 %d 不小于第 %d 个版本 %d",
i, migrations[i].Version, i-1, migrations[i-1].Version)
}
}
@@ -0,0 +1,58 @@
package db
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestBackfillPagePasswordVer 覆盖 2026-11-05 那个 P0 的数据面:
// 新增列 AutoMigrate 只补 0,而 0 恰好等于「session 里没有授权记录」的零值,
// 不回填的话存量页面密码在升级瞬间全部失效。
func TestBackfillPagePasswordVer(t *testing.T) {
conn, clean := newTestDB(t)
defer clean("users")
mustCreate := func(domain string, pagePassword string, pageVer uint, askPassword string, askVer uint) *User {
u := &User{
Name: domain,
Email: domain + "@example.com",
Password: "x",
Domain: domain,
PagePassword: pagePassword,
PagePasswordVer: pageVer,
AskPassword: askPassword,
AskPasswordVer: askVer,
}
require.NoError(t, conn.Create(u).Error)
return u
}
// 存量:有密码但版本号仍为默认 0 —— 必须被回填。
legacy := mustCreate("legacy", "hashed-page", 0, "hashed-ask", 0)
// 已轮换过密码:版本号非 0,不能被重置。
rotated := mustCreate("rotated", "hashed-page", 3, "hashed-ask", 5)
// 没有密码:版本号保持 0,回填不应凭空造出授权。
noPassword := mustCreate("nopassword", "", 0, "", 0)
require.NoError(t, backfillPagePasswordVer(conn))
var gotLegacy, gotRotated, gotNoPassword User
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.NoError(t, conn.First(&gotRotated, rotated.ID).Error)
require.NoError(t, conn.First(&gotNoPassword, noPassword.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "存量页面密码应回填为 1")
require.EqualValues(t, 1, gotLegacy.AskPasswordVer, "存量提问密码应回填为 1")
require.EqualValues(t, 3, gotRotated.PagePasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 5, gotRotated.AskPasswordVer, "已轮换的版本号不能被覆盖")
require.EqualValues(t, 0, gotNoPassword.PagePasswordVer, "没设密码的账号不应被写版本号")
require.EqualValues(t, 0, gotNoPassword.AskPasswordVer, "没设密码的账号不应被写版本号")
// 幂等:再跑一次不应把已回填的行推到 2。
require.NoError(t, backfillPagePasswordVer(conn))
require.NoError(t, conn.First(&gotLegacy, legacy.ID).Error)
require.EqualValues(t, 1, gotLegacy.PagePasswordVer, "迁移必须幂等")
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// TestQuestionDeleteCascadesHardDelete 验证删除提问时,追问与上传图片也被**真删**,
// 而不是软删残留。
//
// 背景:questions.DeleteByID 对主表用 Unscoped 真删,但历史上一度只有主表真删、
// 追问/图片仍软删,导致提问正文删干净了、追问文本与图片元数据却带着 deleted_at
// 永久留在库里(这正是「真删以免个人数据删不干净」想消灭的问题,却在子表漏改)。
// 本测试用 Unscoped 计数兜底:软删的行在普通查询里不可见,只有 Unscoped 才能看到,
// 因此断言「删除后 Unscoped 计数 = 0」才能证明是真删。
func TestQuestionDeleteCascadesHardDelete(t *testing.T) {
db, cleanup := newTestDB(t)
ctx := context.Background()
questions := NewQuestionsStore(db)
followUps := NewFollowUpsStore(db)
uploadImages := NewUploadImagesStore(db)
t.Cleanup(func() {
// 三张表都要清:follow_ups / upload_images(含链接表)在删除后应该已经空了,
// 但其他子测试的 data 可能残留,用 cleanup 统一兜底。
_ = cleanup("questions", "follow_ups", "upload_images", "upload_image_questions")
})
// 建一条提问
q, err := questions.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "需要被彻底删除的提问",
AskerUserID: 1,
})
require.NoError(t, err)
// 给它加一条追问
created, err := followUps.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "追问内容,也应被真删",
})
require.NoError(t, err)
require.True(t, created)
// 给它加一张上传图片
img, err := uploadImages.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAsk,
QuestionID: q.ID,
UploaderUserID: 1,
Name: "secret.png",
FileSize: 1024,
Md5: "deadbeef",
Key: "2026/10/29/secret.png",
})
require.NoError(t, err)
require.NotZero(t, img.ID)
// 删除提问
require.NoError(t, questions.DeleteByID(ctx, q.ID))
// 主表:真删
require.Equal(t, int64(0), hardCount(t, db, &Question{}, "id = ?", q.ID))
// 追问:真删(软删的话这里会是 1)
require.Equal(t, int64(0), hardCount(t, db, &FollowUp{}, "question_id = ?", q.ID))
// 上传图片主体:真删
require.Equal(t, int64(0), hardCount(t, db, &UploadImage{}, "id = ?", img.ID))
// 关联行:真删
require.Equal(t, int64(0), hardCount(t, db, &UploadImageQuestion{}, "question_id = ?", q.ID))
}
// hardCount 用 Unscoped 统计满足条件的行数,能穿透软删除过滤,
// 用于断言「物理上也不存在」。
func hardCount(t *testing.T, db *gorm.DB, model interface{}, where string, args ...interface{}) int64 {
t.Helper()
var n int64
err := db.WithContext(context.Background()).Unscoped().
Model(model).
Where(where, args...).
Count(&n).Error
require.NoError(t, err)
return n
}
+82 -12
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -27,6 +26,8 @@ var Questions QuestionsStore
type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*Question, error)
// ListByIDsAndUser 按 ID 批量取某箱主名下的提问(一次查询,避免 N+1)。
ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error)
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
@@ -42,6 +43,13 @@ type QuestionsStore interface {
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
// SetPinnedIfCurrent 仅在「当前置顶状态 == current」时才写成 pinned,返回是否真的写入。
//
// 用于「切换」这类 read-modify-write:handler 拿到的 is_pinned 是本次请求早期读的,
// 双击或并发提交时两个请求都会算出「取反」并写同一个值,状态机不是原子的
// (用户点两次期望「置顶后又取消」,实际仍是「置顶」)。
// 加上 current 条件后,第二次会因为状态已变而 0 行 —— 语义上「只切换一次」。
SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error)
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
// NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。
@@ -103,6 +111,12 @@ type CreateQuestionOptions struct {
IsPrivate bool
}
// Create 新建提问,并在**同一个事务**里累加箱主的提问收集计数。
//
// 为什么配额要和提问同事务:原来是 handler 里「先 Create、再 IncrementQuota」
// 两次独立写入,后者失败只打一条 Warn。于是 quota_count 比真实提问数少,
// 箱主看到的「已收集 7/10」永远偏低;而达到上限后因为计数偏小,实际还能继续收,
// 等于定量收集被静默绕过。重置计数也校准不回来(重置是把计数清零,更脱节)。
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{
FromIP: opts.FromIP,
@@ -113,7 +127,23 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate,
}
return &question, db.WithContext(ctx).Create(&question).Error
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := reserveFeatureQuotaTx(tx, opts.UserID, FeatureQuestion); err != nil {
return err
}
if err := tx.Create(&question).Error; err != nil {
return errors.Wrap(err, "create question")
}
// 追问不计入配额,只算「一条提问」—— 与界面上「已收集」的口径一致。
if err := incrementQuotaTx(tx, opts.UserID, FeatureQuestion, 1); err != nil {
return errors.Wrap(err, "increment question quota")
}
return nil
})
if err != nil {
return nil, err
}
return &question, nil
}
type UpdateQuestionCensorOptions struct {
@@ -162,6 +192,25 @@ func checkTextCensorResponseValid(raw json.RawMessage) bool {
var ErrQuestionNotExist = errors.New("提问不存在")
// ListByIDsAndUser 按 ID 批量取提问,且必须属于指定箱主。
//
// 一次 IN 查询而不是循环 GetByID,供「提问者找回自己的提问」那个接口用:
// 浏览器里可能存着十几条记录,逐条查就是典型的 N+1。
// 只选择判定与摘要需要的列 —— 不取审核元数据、图片、追问等无关字段。
func (db *questions) ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error) {
if len(ids) == 0 {
return nil, nil
}
var questions []*Question
if err := db.WithContext(ctx).
Select("id", "user_id", "token", "answer", "content", "created_at").
Where("id IN ? AND user_id = ?", ids, userID).
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "list questions by ids and user")
}
return questions, nil
}
func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
@@ -435,37 +484,47 @@ func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[
return updates
}
// DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。
//
// 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、
// 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录,
// 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。
//
// 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见,
// 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if err := tx.First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Delete(&Question{}, id).Error; err != nil {
if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question")
}
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
// 直接用事务句柄构造子 store(NewXStore(tx)),子操作才会落在同一个事务里;
// 用包级全局单例在测试/部分初始化场景下还会是 nil。
if err := NewFollowUpsStore(tx).DeleteByQuestionID(ctx, id); err != nil {
return errors.Wrap(err, "delete follow ups of question")
}
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
if err := NewUploadImagesStore(tx).DeleteByQuestionID(ctx, id); err != nil {
return errors.Wrap(err, "delete upload images of question")
}
// 回退定量收集计数:删除提问等价于少一条已收集内容,
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
if question.UserID != 0 {
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
if err := incrementQuotaTx(tx, question.UserID, FeatureQuestion, -1); err != nil {
return errors.Wrap(err, "decrement question quota")
}
}
return nil
})
}
type GetQuestionsCountOptions struct {
@@ -512,6 +571,17 @@ func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
// SetPinnedIfCurrent 条件更新置顶状态(详见接口注释)。
func (db *questions) SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error) {
res := db.WithContext(ctx).Model(&Question{}).
Where("id = ? AND is_pinned = ?", id, current).
Update("is_pinned", pinned)
if res.Error != nil {
return false, errors.Wrap(res.Error, "set question pinned if current")
}
return res.RowsAffected > 0, nil
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}
+163
View File
@@ -0,0 +1,163 @@
package db
import (
"context"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func createQuotaTestUser(t *testing.T, ctx context.Context, conn *gorm.DB, name string, dailyLimit int) *User {
t.Helper()
user := &User{Name: name, Domain: name, Email: name + "@example.test", Password: "test-only", DailyMessageLimit: dailyLimit}
require.NoError(t, conn.WithContext(ctx).Create(user).Error)
return user
}
func setFeatureQuota(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, feature string, limit int64) {
t.Helper()
require.NoError(t, NewFeatureControlsStore(conn).Save(ctx, userID, feature, UpdateFeatureControlOptions{Enabled: true, QuotaLimit: limit}))
}
func runConcurrently(n int, fn func(int) error) []error {
start := make(chan struct{})
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs[i] = fn(i)
}()
}
close(start)
wg.Wait()
return errs
}
func requireOnlyQuotaErrors(t *testing.T, errs []error, quotaErr error) int {
t.Helper()
successes := 0
for _, err := range errs {
if err == nil {
successes++
continue
}
require.ErrorIs(t, err, quotaErr)
}
return successes
}
func TestQuestionQuotaConcurrentLastSlot(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-question-owner", 0)
setFeatureQuota(t, ctx, conn, owner.ID, FeatureQuestion, 1)
questions := NewQuestionsStore(conn)
errs := runConcurrently(12, func(i int) error {
_, err := questions.Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: fmt.Sprintf("question-%d", i)})
return err
})
require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrFeatureQuotaReached))
var count int64
require.NoError(t, conn.Model(&Question{}).Where("user_id = ?", owner.ID).Count(&count).Error)
require.Equal(t, int64(1), count)
fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureQuestion)
require.NoError(t, err)
require.Equal(t, int64(1), fc.QuotaCount)
}
func TestVoteQuotasConcurrentLastSlot(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-vote-owner", 0)
setFeatureQuota(t, ctx, conn, owner.ID, FeatureVote, 1)
votes := NewVotesStore(conn)
options := NewVoteOptionsStore(conn)
submissions := NewVoteSubmissionsStore(conn)
ids := make([]uint, 2)
for i := range 2 {
vote, err := votes.Create(ctx, CreateVoteOptions{UserID: owner.ID, Title: fmt.Sprintf("vote-%d", i), Type: VoteTypeSingle, QuotaLimit: 1})
require.NoError(t, err)
require.NoError(t, votes.UpdateStatus(ctx, vote.ID, VoteStatusPublished))
option, err := options.Create(ctx, CreateVoteOptionOptions{VoteID: vote.ID, Content: "option"})
require.NoError(t, err)
ids[i] = option.ID
}
errs := runConcurrently(12, func(i int) error {
token := fmt.Sprintf("voter-%d", i)
_, err := submissions.Create(ctx, CreateVoteSubmissionOptions{VoteID: uint(i%2 + 1), OptionID: ids[i%2], VoterToken: &token, QuotaUserID: owner.ID})
return err
})
successes := 0
for _, err := range errs {
if err == nil {
successes++
} else {
require.Error(t, err)
}
}
require.Equal(t, 1, successes)
var count int64
require.NoError(t, conn.Model(&VoteSubmission{}).Count(&count).Error)
require.Equal(t, int64(1), count)
fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureVote)
require.NoError(t, err)
require.Equal(t, int64(1), fc.QuotaCount)
var ended int64
require.NoError(t, conn.Model(&Vote{}).Where("status = ?", VoteStatusEnded).Count(&ended).Error)
require.Equal(t, int64(1), ended)
}
func TestDailyMessageLimitAcrossConversationPaths(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-chat-owner", 1)
asker := createQuotaTestUser(t, ctx, conn, "quota-chat-asker", 0)
conversations := NewConversationStore(conn)
existing, err := conversations.Create(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20})
require.NoError(t, err)
var wg sync.WaitGroup
start := make(chan struct{})
errs := make([]error, 2)
wg.Add(2)
go func() {
defer wg.Done()
<-start
_, _, errs[0] = conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first")
}()
go func() {
defer wg.Done()
<-start
_, errs[1] = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: existing.ID, SenderID: asker.ID, Content: "existing"})
}()
close(start)
wg.Wait()
require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrDailyMessageLimit))
var messages, convCount int64
require.NoError(t, conn.Model(&Message{}).Count(&messages).Error)
require.NoError(t, conn.Model(&Conversation{}).Count(&convCount).Error)
require.Equal(t, int64(1), messages)
require.Equal(t, int64(1), convCount)
}
func TestOwnerReplyDoesNotConsumeAskerDailyQuota(t *testing.T) {
conn, _ := newTestDB(t)
ctx := context.Background()
owner := createQuotaTestUser(t, ctx, conn, "quota-reply-owner", 1)
asker := createQuotaTestUser(t, ctx, conn, "quota-reply-asker", 0)
conversations := NewConversationStore(conn)
conv, _, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first")
require.NoError(t, err)
_, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: owner.ID, Content: "reply"})
require.NoError(t, err)
_, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: asker.ID, Content: "over"})
require.ErrorIs(t, err, ErrDailyMessageLimit)
var count int64
require.NoError(t, conn.Model(&Message{}).Count(&count).Error)
require.Equal(t, int64(2), count)
}
+277
View File
@@ -0,0 +1,277 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/storage"
)
var R2DeleteTasks R2DeleteTaskStore
var _ R2DeleteTaskStore = (*r2DeleteTasks)(nil)
// R2DeleteTask 是 R2 对象删除的持久化 outbox 任务。
// 数据库事务只负责登记任务,实际对象删除由 worker 在事务外执行。
type R2DeleteTask struct {
ID uint `gorm:"primaryKey"`
Bucket string `gorm:"size:255;not null;uniqueIndex:uk_r2_delete_tasks_bucket_key_hash,priority:1"`
Key string `gorm:"type:text;not null"`
KeyHash string `gorm:"size:64;not null;uniqueIndex:uk_r2_delete_tasks_bucket_key_hash,priority:2"`
Attempts int `gorm:"not null;default:0"`
NextRetryAt time.Time `gorm:"not null;index:idx_r2_delete_tasks_next_retry_at"`
LastError string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
type R2DeleteTaskStore interface {
Enqueue(ctx context.Context, bucket string, keys []string) error
EnqueueValues(ctx context.Context, bucket string, values []string) error
ListDue(ctx context.Context, limit int) ([]*R2DeleteTask, error)
Delete(id uint) error
MarkFailed(id uint, nextRetry time.Time, lastError string) error
// HasReferences 仅供 worker 对当前配置的 bucket 调用。
// 它只查询数据库,不执行网络请求;查询或旧 URL 解析失败时必须返回错误。
HasReferences(ctx context.Context, bucket, key string) (bool, error)
}
func NewR2DeleteTaskStore(db *gorm.DB) R2DeleteTaskStore {
return &r2DeleteTasks{DB: db}
}
type r2DeleteTasks struct {
*gorm.DB
}
// EnqueueR2DeleteTasks 在调用方事务中登记对象删除任务。
// 相同 (bucket,key) 重复入队会重置重试状态,使新事务对应的删除请求立即可见。
func EnqueueR2DeleteTasks(tx *gorm.DB, bucket string, keys []string) error {
if bucket == "" || len(keys) == 0 {
return nil
}
now := time.Now()
tasks := make([]*R2DeleteTask, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sum := sha256.Sum256([]byte(key))
tasks = append(tasks, &R2DeleteTask{
Bucket: bucket,
Key: key,
KeyHash: hex.EncodeToString(sum[:]),
NextRetryAt: now,
})
}
if len(tasks) == 0 {
return nil
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "bucket"}, {Name: "key_hash"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"attempts": 0,
"next_retry_at": now,
"last_error": "",
"updated_at": now,
}),
}).Create(&tasks).Error; err != nil {
return errors.Wrap(err, "enqueue R2 delete tasks")
}
return nil
}
func (db *r2DeleteTasks) Enqueue(ctx context.Context, bucket string, keys []string) error {
return EnqueueR2DeleteTasks(db.WithContext(ctx), bucket, keys)
}
func (db *r2DeleteTasks) EnqueueValues(ctx context.Context, bucket string, values []string) error {
return enqueueR2DeleteTasksForValues(db.WithContext(ctx), bucket, values)
}
func (db *r2DeleteTasks) ListDue(ctx context.Context, limit int) ([]*R2DeleteTask, error) {
tasks := make([]*R2DeleteTask, 0)
if limit <= 0 {
return tasks, nil
}
if err := db.WithContext(ctx).
Where("next_retry_at <= ?", time.Now()).
Order("next_retry_at ASC, id ASC").
Limit(limit).
Find(&tasks).Error; err != nil {
return nil, errors.Wrap(err, "list due R2 delete tasks")
}
return tasks, nil
}
func (db *r2DeleteTasks) Delete(id uint) error {
if err := db.DB.Delete(&R2DeleteTask{}, id).Error; err != nil {
return errors.Wrap(err, "delete R2 delete task")
}
return nil
}
func (db *r2DeleteTasks) MarkFailed(id uint, nextRetry time.Time, lastError string) error {
if err := db.Model(&R2DeleteTask{}).Where("id = ?", id).Updates(map[string]interface{}{
"attempts": gorm.Expr("attempts + 1"),
"next_retry_at": nextRetry,
"last_error": lastError,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(err, "mark R2 delete task failed")
}
return nil
}
func (db *r2DeleteTasks) HasReferences(ctx context.Context, bucket, key string) (bool, error) {
if bucket == "" || key == "" {
return false, nil
}
defaultKey, owned, err := r2ObjectKeyFromValue(key)
if err != nil {
return false, err
}
if owned && isDefaultProfileObject(defaultKey) {
return true, nil
}
quotedKey := dbutil.QuoteIdentifier(db.Dialector.Name(), "key")
var count int64
if err := db.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(quotedKey+" = ?", key).Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check upload image references")
}
if count > 0 {
return true, nil
}
count = 0
if err := db.WithContext(ctx).Unscoped().Model(&MessageImage{}).
Where("object_key = ? OR url = ?", key, key).Count(&count).Error; err != nil {
return false, errors.Wrap(err, "check message image references")
}
if count > 0 {
return true, nil
}
var uploadURLs []string
if err := db.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(quotedKey+" LIKE ? OR "+quotedKey+" LIKE ?", "%://%", "//%").
Pluck(quotedKey, &uploadURLs).Error; err != nil {
return false, errors.Wrap(err, "list legacy upload image URLs")
}
referenced, err := hasLegacyCDNReference(uploadURLs, key)
if err != nil || referenced {
return referenced, err
}
var messageURLs []string
if err := db.WithContext(ctx).Unscoped().Model(&MessageImage{}).
Where("url LIKE ? OR url LIKE ?", "%://%", "//%").
Pluck("url", &messageURLs).Error; err != nil {
return false, errors.Wrap(err, "list legacy message image URLs")
}
referenced, err = hasLegacyCDNReference(messageURLs, key)
if err != nil || referenced {
return referenced, err
}
var profileUsers []*User
if err := db.WithContext(ctx).Unscoped().Model(&User{}).
Select("avatar", "background", "background_image").
Where("avatar = ? OR background = ? OR background_image = ? OR avatar LIKE ? OR background LIKE ? OR background_image LIKE ?", key, key, key, "%://%", "%://%", "%://%").
Find(&profileUsers).Error; err != nil {
return false, errors.Wrap(err, "list profile image references")
}
for _, user := range profileUsers {
referenced, err = hasLegacyCDNReference([]string{user.Avatar, user.Background, user.BackgroundImage}, key)
if err != nil || referenced {
return referenced, err
}
}
return false, nil
}
func isDefaultProfileObject(key string) bool {
for _, value := range []string{conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL} {
defaultKey, owned, err := r2ObjectKeyFromValue(value)
if err == nil && owned && defaultKey == key {
return true
}
}
return false
}
func hasLegacyCDNReference(values []string, targetKey string) (bool, error) {
for _, value := range values {
key, owned, err := r2ObjectKeyFromValue(value)
if err != nil {
return false, err
}
if owned && key == targetKey {
return true, nil
}
}
return false, nil
}
// r2ObjectKeyFromValue 接受当前对象 key 或旧 CDN URL。
// 外部 URL 不属于当前 CDN,安全返回 owned=false;畸形 URL 返回错误以避免误判无引用。
func r2ObjectKeyFromValue(value string) (key string, owned bool, err error) {
if value == "" {
return "", false, nil
}
if !strings.Contains(value, "://") && !strings.HasPrefix(value, "//") {
return value, true, nil
}
key, owned, err = storage.ObjectKeyFromCDNURL(value, conf.Upload.ImageBucketCDNHost)
if err != nil {
return "", false, errors.Wrap(err, "parse legacy CDN URL")
}
if !owned || key == "" {
return "", false, nil
}
return key, true, nil
}
func enqueueR2DeleteTasksForValues(tx *gorm.DB, bucket string, values []string, excludedKeys ...string) error {
if bucket == "" || len(values) == 0 {
return nil
}
excluded := make(map[string]struct{}, len(excludedKeys))
for _, key := range excludedKeys {
excluded[key] = struct{}{}
}
keys := make([]string, 0, len(values))
for _, value := range values {
key, owned, err := r2ObjectKeyFromValue(value)
if err != nil {
return err
}
if !owned || isDefaultProfileObject(key) {
continue
}
if _, skip := excluded[key]; skip {
continue
}
keys = append(keys, key)
}
return EnqueueR2DeleteTasks(tx, bucket, keys)
}
+141
View File
@@ -0,0 +1,141 @@
package db
import (
"context"
"crypto/sha256"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
func setR2TestConfig(t *testing.T) {
t.Helper()
oldBucket := conf.Upload.ImageBucket
oldCDNHost := conf.Upload.ImageBucketCDNHost
conf.Upload.ImageBucket = "test-r2-bucket"
conf.Upload.ImageBucketCDNHost = "cdn.example.test/assets"
oldDefaults := []string{conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL}
conf.Upload.DefaultAvatarURL = "https://cdn.example.test/assets/default-avatar.webp"
conf.Upload.DefaultBackground = "https://cdn.example.test/assets/default-background.webp"
conf.Upload.DefaultBackgroundImageURL = "https://cdn.example.test/assets/default-qr.webp"
t.Cleanup(func() {
conf.Upload.DefaultAvatarURL, conf.Upload.DefaultBackground, conf.Upload.DefaultBackgroundImageURL = oldDefaults[0], oldDefaults[1], oldDefaults[2]
conf.Upload.ImageBucket = oldBucket
conf.Upload.ImageBucketCDNHost = oldCDNHost
})
}
func TestR2DeleteTaskStore(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewR2DeleteTaskStore(conn)
require.NoError(t, store.Enqueue(ctx, "", []string{"ignored.webp"}))
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, nil))
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, []string{
"2026/11/shared.webp", "2026/11/shared.webp", "2026/11/other.webp", "",
}))
tasks, err := store.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, tasks, 2)
byKey := make(map[string]*R2DeleteTask, len(tasks))
for _, task := range tasks {
byKey[task.Key] = task
require.Equal(t, conf.Upload.ImageBucket, task.Bucket)
require.Len(t, task.KeyHash, 64)
}
shared := byKey["2026/11/shared.webp"]
other := byKey["2026/11/other.webp"]
require.NotNil(t, shared)
require.NotNil(t, other)
require.Equal(t, fmt.Sprintf("%x", sha256.Sum256([]byte(shared.Key))), shared.KeyHash)
future := time.Now().Add(time.Hour).Truncate(time.Microsecond)
require.NoError(t, store.MarkFailed(shared.ID, future, "temporary failure"))
due, err := store.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1)
require.Equal(t, other.ID, due[0].ID)
require.NoError(t, store.Enqueue(ctx, conf.Upload.ImageBucket, []string{shared.Key, shared.Key}))
var requeued R2DeleteTask
require.NoError(t, conn.WithContext(ctx).First(&requeued, shared.ID).Error)
require.Equal(t, shared.ID, requeued.ID)
require.Zero(t, requeued.Attempts)
require.Empty(t, requeued.LastError)
require.WithinDuration(t, time.Now(), requeued.NextRetryAt, 5*time.Second)
// 同一个 key 可在不同 bucket 各有一条任务,但同一 bucket 内仍保持唯一。
require.NoError(t, store.Enqueue(ctx, "another-bucket", []string{shared.Key}))
var count int64
require.NoError(t, conn.WithContext(ctx).Model(&R2DeleteTask{}).Count(&count).Error)
require.Equal(t, int64(3), count)
require.NoError(t, store.Delete(other.ID))
require.NoError(t, conn.WithContext(ctx).Model(&R2DeleteTask{}).Count(&count).Error)
require.Equal(t, int64(2), count)
empty, err := store.ListDue(ctx, 0)
require.NoError(t, err)
require.NotNil(t, empty)
require.Empty(t, empty)
}
func TestR2DeleteTaskHasReferences(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
store := NewR2DeleteTaskStore(conn)
bucket := conf.Upload.ImageBucket
upload := &UploadImage{Key: "upload.webp"}
require.NoError(t, conn.WithContext(ctx).Create(upload).Error)
require.NoError(t, conn.WithContext(ctx).Delete(upload).Error)
referenced, err := store.HasReferences(ctx, bucket, upload.Key)
require.NoError(t, err)
require.True(t, referenced, "软删除的 upload_images 仍必须通过 Unscoped 阻止对象删除")
message := &MessageImage{MessageID: 1, ObjectKey: "message.webp", URL: "https://cdn.example.test/assets/message.webp"}
require.NoError(t, conn.WithContext(ctx).Create(message).Error)
require.NoError(t, conn.WithContext(ctx).Delete(message).Error)
referenced, err = store.HasReferences(ctx, bucket, message.ObjectKey)
require.NoError(t, err)
require.True(t, referenced, "软删除的 message_images 仍必须通过 Unscoped 阻止对象删除")
legacyMessage := &MessageImage{MessageID: 2, URL: "https://cdn.example.test/assets/legacy-message.webp"}
legacyUpload := &UploadImage{Key: "https://cdn.example.test/assets/legacy-upload.webp"}
external := &MessageImage{MessageID: 3, URL: "https://external.example.net/external.webp"}
require.NoError(t, conn.WithContext(ctx).Create(legacyMessage).Error)
require.NoError(t, conn.WithContext(ctx).Create(legacyUpload).Error)
require.NoError(t, conn.WithContext(ctx).Create(external).Error)
for _, key := range []string{"legacy-message.webp", "legacy-upload.webp"} {
referenced, err = store.HasReferences(ctx, bucket, key)
require.NoError(t, err)
require.True(t, referenced, key)
}
referenced, err = store.HasReferences(ctx, bucket, "external.webp")
require.NoError(t, err)
require.False(t, referenced)
referenced, err = store.HasReferences(ctx, bucket, "missing.webp")
require.NoError(t, err)
require.False(t, referenced)
profileUser := &User{Avatar: "https://cdn.example.test/assets/profile-avatar.webp", Background: "profile-background.webp", BackgroundImage: "https://external.example.net/external-profile.webp"}
require.NoError(t, conn.WithContext(ctx).Create(profileUser).Error)
for _, key := range []string{"profile-avatar.webp", "profile-background.webp", "default-avatar.webp", "default-background.webp", "default-qr.webp"} {
referenced, err = store.HasReferences(ctx, bucket, key)
require.NoError(t, err)
require.True(t, referenced, key)
}
// 第二个引用查询失败时必须显式返回错误,不能误报为无引用。
require.NoError(t, conn.Migrator().DropTable(&MessageImage{}))
_, err = store.HasReferences(ctx, bucket, "missing-again.webp")
require.Error(t, err)
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SecurityReportLog 是「周期安全汇总已发送」的台账。
//
// 为什么需要一张表而不是在内存里记一个时间戳:
// 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个
// 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍,
// 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。
//
// 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。
type SecurityReportLog struct {
ID uint `gorm:"primarykey"`
// PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。
// 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」,
// 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。
PeriodKey string `gorm:"size:32;uniqueIndex"`
// Kind 报告类型(week / month),便于按类型统计。
Kind string `gorm:"size:8"`
// WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。
WindowStart time.Time
WindowEnd time.Time
// 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」,
// 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。
Hits int64
UniqueSources int64
Banned int64
Countries int
Recipient string `gorm:"size:255"`
// FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。
FilePath string `gorm:"size:512"`
// Sent 是否成功发出。失败的行保留下来,既是审计线索,
// 也用来做「最多重试几次」的计数。
Sent bool
// Attempts 尝试次数。发送失败会重试,但必须设上限:
// 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。
Attempts int
// Error 最近一次失败的原因(成功时清空)。
Error string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
}
// TableName 固定表名。
func (SecurityReportLog) TableName() string { return "security_report_logs" }
// SecurityReports 报告台账全局 store。
var SecurityReports SecurityReportStore
// SecurityReportStore 报告台账的读写。
type SecurityReportStore interface {
// Get 按 period_key 取一条,未找到返回 (nil, nil)。
Get(ctx context.Context, periodKey string) (*SecurityReportLog, error)
// Save 按 period_key upsert。
Save(ctx context.Context, rec *SecurityReportLog) error
// Recent 返回最近 n 条,按窗口结束时间倒序。
Recent(ctx context.Context, n int) ([]*SecurityReportLog, error)
}
type securityReports struct {
db *gorm.DB
}
// NewSecurityReportStore 构造台账 store。
func NewSecurityReportStore(db *gorm.DB) SecurityReportStore {
return &securityReports{db: db}
}
func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) {
var rec SecurityReportLog
err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error {
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "period_key"}},
DoUpdates: clause.AssignmentColumns([]string{
"kind", "window_start", "window_end", "hits", "unique_sources",
"banned", "countries", "recipient", "file_path",
"sent", "attempts", "error", "updated_at",
}),
}).
Create(rec).Error
}
func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) {
if n <= 0 {
n = 12
}
var rows []*SecurityReportLog
err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error
return rows, err
}
+60 -10
View File
@@ -10,6 +10,8 @@ import (
"github.com/pkg/errors"
"github.com/samber/lo"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/conf"
)
var UploadImages UploadImagesStore
@@ -23,7 +25,8 @@ type UploadImagesStore interface {
// 用于账号导出等场景,将 N 次单题查询合并为少量 IN 查询,避免 N+1。
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*UploadImage, error)
GetByTypeQuestionID(ctx context.Context, typ UploadImageQuestionType, questionID uint) ([]*UploadImage, error)
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// DeleteByQuestionID 先删除当前题目的关联,再物理删除不再被任何题目引用的主体。
// 实际删除的主体的对象 key 与元数据删除在同一事务中登记 R2 outbox。
DeleteByQuestionID(ctx context.Context, questionID uint) error
}
@@ -82,9 +85,24 @@ func (db *uploadImages) Create(ctx context.Context, opts CreateUploadImageOption
}
if opts.IsDeletingPrevious {
if err := tx.WithContext(ctx).Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).Delete(&UploadImageQuestion{}).Error; err != nil {
var previousLinks []*UploadImageQuestion
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Find(&previousLinks).Error; err != nil {
return errors.Wrap(err, "get previous image question links")
}
previousImageIDs := lo.Map(previousLinks, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).
Where("type = ? AND question_id = ?", opts.Type, opts.QuestionID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete previous image question link")
}
// 新图已经创建但尚未关联;即使它与旧图使用相同 key,也绝不加入删除队列。
if err := deleteUnreferencedUploadImages(ctx, tx, previousImageIDs, opts.Key); err != nil {
return err
}
}
if err := tx.WithContext(ctx).Create(&UploadImageQuestion{
Type: opts.Type,
@@ -162,10 +180,44 @@ func (db *uploadImages) GetByTypeQuestionID(ctx context.Context, typ UploadImage
return db.getBy(ctx, "type = ? AND question_id = ?", typ, questionID)
}
// DeleteByQuestionID 删除某条提问时级联清理其全部上传图片记录(软删除,与提问保持一致)。
// 注意:question_id 列在关联表 upload_image_questions 上,不在 upload_images 表上,
// 需先查关联行拿到 imageID 列表,再软删图片主体并删除关联行(同一事务内完成)。
// 注:仅清理数据库记录,对象存储中的实际文件不在此处删除(避免误删复用资源)。
// unreferencedUploadImagePredicate 是所有 UploadImage 删除入口共用的共享保护谓词。
// 调用方必须先解除当前作用域的关联,再使用该谓词收集并删除真正的孤儿主体。
const unreferencedUploadImagePredicate = `NOT EXISTS (
SELECT 1 FROM upload_image_questions AS shared_links
WHERE shared_links.upload_image_id = upload_images.id
)`
func deleteUnreferencedUploadImages(ctx context.Context, tx *gorm.DB, imageIDs []uint, excludedKeys ...string) error {
if len(imageIDs) == 0 {
return nil
}
var images []*UploadImage
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", imageIDs).
Where(unreferencedUploadImagePredicate).
Find(&images).Error; err != nil {
return errors.Wrap(err, "get unreferenced upload images")
}
if len(images) == 0 {
return nil
}
ids := lo.Map(images, func(image *UploadImage, _ int) uint { return image.ID })
if err := tx.WithContext(ctx).Unscoped().
Where("id IN ?", ids).
Where(unreferencedUploadImagePredicate).
Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete unreferenced upload images")
}
values := lo.Map(images, func(image *UploadImage, _ int) string { return image.Key })
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, values, excludedKeys...); err != nil {
return errors.Wrap(err, "enqueue deleted upload images")
}
return nil
}
func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
var links []*UploadImageQuestion
@@ -179,12 +231,10 @@ func (db *uploadImages) DeleteByQuestionID(ctx context.Context, questionID uint)
imageIDs := lo.Map(links, func(item *UploadImageQuestion, _ int) uint {
return item.UploadImageID
})
if err := tx.WithContext(ctx).Where("id IN ?", imageIDs).Delete(&UploadImage{}).Error; err != nil {
return errors.Wrap(err, "delete upload images by question id")
}
// 关联表无 DeletedAt,Delete 本就是真删;先解除当前题目关联。
if err := tx.WithContext(ctx).Where("question_id = ?", questionID).Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete image question links")
}
return nil
return deleteUnreferencedUploadImages(ctx, tx, imageIDs)
})
}
+95
View File
@@ -0,0 +1,95 @@
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func uploadImageCount(t *testing.T, conn *gorm.DB, id uint) int64 {
t.Helper()
var count int64
require.NoError(t, conn.Unscoped().Model(&UploadImage{}).Where("id = ?", id).Count(&count).Error)
return count
}
func TestUploadImagesDeleteByQuestionIDOutbox(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
images := NewUploadImagesStore(conn)
tasks := NewR2DeleteTaskStore(conn)
first, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "unique.webp"})
require.NoError(t, err)
duplicate, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "unique.webp"})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: 1, Key: "shared.webp"})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: shared.ID,
QuestionID: 2,
}).Error)
require.NoError(t, images.DeleteByQuestionID(ctx, 1))
require.Zero(t, uploadImageCount(t, conn, first.ID))
require.Zero(t, uploadImageCount(t, conn, duplicate.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
due, err := tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1, "重复 key 必须去重,共享主体不得入队")
require.Equal(t, "unique.webp", due[0].Key)
require.NoError(t, images.DeleteByQuestionID(ctx, 2))
require.Zero(t, uploadImageCount(t, conn, shared.ID))
due, err = tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 2)
require.ElementsMatch(t, []string{"unique.webp", "shared.webp"}, []string{due[0].Key, due[1].Key})
}
func TestUploadImagesReplaceDeletesOnlyOrphanedPrevious(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
images := NewUploadImagesStore(conn)
tasks := NewR2DeleteTaskStore(conn)
old, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "old.webp",
})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "shared-old.webp",
})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
Type: UploadImageQuestionTypeAnswer,
UploadImageID: shared.ID,
QuestionID: 11,
}).Error)
newImage, err := images.Create(ctx, CreateUploadImageOptions{
Type: UploadImageQuestionTypeAnswer,
QuestionID: 10,
Key: "new.webp",
IsDeletingPrevious: true,
})
require.NoError(t, err)
require.Zero(t, uploadImageCount(t, conn, old.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, newImage.ID))
due, err := tasks.ListDue(ctx, 10)
require.NoError(t, err)
require.Len(t, due, 1)
require.Equal(t, "old.webp", due[0].Key)
require.NotEqual(t, newImage.Key, due[0].Key)
}
+143 -11
View File
@@ -111,6 +111,9 @@ type User struct {
// 取值合法性由 route/user 在写入前校验,读取侧由 internal/theme 回退。
Theme string `json:"theme" gorm:"column:theme;type:varchar(32);default:''"`
Notify NotifyType `json:"notify"`
// TelegramChatID 箱主自填的 Telegram chat_id,配合站级 [notify] telegram_bot_token
// 实现「新提问」点对点通知。空串 = 未开启 Telegram 通知。
TelegramChatID string `json:"-" gorm:"column:telegram_chat_id;type:varchar(64);default:''"`
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
BlockWords string `json:"-"`
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
@@ -130,6 +133,13 @@ type User struct {
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
// PagePasswordVer / AskPasswordVer 是页面访问密码 / 提问密码的版本号。
// 每次修改对应密码时 +1,session 中保存授权时的版本号;
// 校验时若 session 中的版本号 != 当前版本号,则视为授权已失效。
// 这让「修改页面密码」能立即撤销已泄露的旧授权(SEC-06)。
PagePasswordVer uint `gorm:"default:0" json:"-"`
AskPasswordVer uint `gorm:"default:0" json:"-"`
// 私聊相关
AllowChat bool `json:"allow_chat" gorm:"default:false"`
// 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底;
@@ -343,6 +353,7 @@ type UpdateUserOptions struct {
Bloglink string
UseCustomQRColor bool
Notify NotifyType
TelegramChatID string
Language string
}
@@ -378,13 +389,29 @@ func (db *users) Update(ctx context.Context, id uint, opts UpdateUserOptions) er
"Bloglink": opts.Bloglink,
"UseCustomQRColor": opts.UseCustomQRColor,
"Notify": opts.Notify,
"TelegramChatID": opts.TelegramChatID,
"Language": opts.Language,
}
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&current, id).Error; err != nil {
return errors.Wrap(err, "get current profile images")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return errors.Wrap(err, "update user")
}
oldValues := make([]string, 0, 3)
for _, pair := range [][2]string{{current.Avatar, opts.Avatar}, {current.Background, opts.Background}, {current.BackgroundImage, opts.BackgroundImage}} {
if pair[0] != pair[1] {
oldValues = append(oldValues, pair[0])
}
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, oldValues, opts.Avatar, opts.Background, opts.BackgroundImage); err != nil {
return errors.Wrap(err, "enqueue replaced profile images")
}
return nil
})
}
// UpdateHarassmentType 仅更新骚扰防护类型(none / register_only)。
@@ -858,13 +885,39 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
return hot, nil
}
// backfillPagePasswordVer 给「已设置密码但版本号仍为 0」的存量账号补一次版本号。
//
// 背景:PagePasswordVer / AskPasswordVer 是 2026-11-05 新增的列,AutoMigrate 只补默认值 0。
// 而 0 同时是「session 里没有授权记录」时类型断言的零值,两者相等会让页面密码判定恒为通过
// (见 route/question/page.go 的 pagePasswordAuthorized 注释)。
// 把存量行统一置 1:语义与「改过一次密码」一致,升级前发出的旧授权(布尔 true)
// 也会因类型不符而自然作废,用户重输一次密码即可。
//
// 幂等:只更新仍为 0 的行,重复执行不会把版本号越推越大。
func backfillPagePasswordVer(db *gorm.DB) error {
if err := db.Model(&User{}).
Where("page_password <> '' AND page_password_ver = 0").
Update("page_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill page password version")
}
if err := db.Model(&User{}).
Where("ask_password <> '' AND ask_password_ver = 0").
Update("ask_password_ver", 1).Error; err != nil {
return errors.Wrap(err, "backfill ask password version")
}
return nil
}
func (db *users) UpdatePagePassword(ctx context.Context, id uint, password string) error {
user, err := db.GetByID(ctx, id)
if err != nil {
return err
}
user.SetPagePassword(password)
return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error
return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{
"page_password": user.PagePassword,
"page_password_ver": gorm.Expr("page_password_ver + 1"),
}).Error
}
func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error {
@@ -873,7 +926,10 @@ func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string
return err
}
user.SetAskPassword(password)
return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error
return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{
"ask_password": user.AskPassword,
"ask_password_ver": gorm.Expr("ask_password_ver + 1"),
}).Error
}
// 新增私聊设置
@@ -1006,7 +1062,8 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
}
// purgeUserContent 物理删除某用户的全部内容数据:追问、消息与消息图片、私聊会话、
// 提问-图片关联、提问、拉黑关系、功能开关。**不含用户行本身**(由调用方决定是软删除还是物理删除)。
// 提问图片元数据及关联、提问、投票及其子表、访问统计、拉黑关系、功能开关。
// **不含用户行本身**(由调用方决定是软删除还是物理删除),也不删除审计/处置记录。
//
// 全部使用 Unscoped 物理删除而非软删除:这些表多为软删除模型,而部分表的唯一索引
// 不含 deleted_at(如 conversations.token),软删除会让被删行永久占据唯一槽位。
@@ -1015,6 +1072,19 @@ func (db *users) BanUserAndCleanup(ctx context.Context, userID uint) error {
// 直接复用会把上一条语句的 Where 条件串到下一条上(曾把 messages 的条件串进 follow_ups,
// 报「字段 conversation_id 不存在」)。
func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
var profile User
if err := tx.WithContext(ctx).Select("id", "avatar", "background", "background_image").First(&profile, userID).Error; err != nil {
return errors.Wrap(err, "get profile images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, []string{profile.Avatar, profile.Background, profile.BackgroundImage}); err != nil {
return errors.Wrap(err, "enqueue profile images of user")
}
if err := tx.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"avatar": "", "background": "", "background_image": "",
}).Error; err != nil {
return errors.Wrap(err, "clear profile images of user")
}
// 追问:属于该用户提问下的追问
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
@@ -1022,18 +1092,38 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete follow ups of user")
}
// 消息图片必须先于消息删除,否则消息子查询将无法再匹配待删图片。
// 新数据优先使用物化 ObjectKey,旧数据回退到 URL;外部 URL 不会进入 R2 outbox。
messageImageScope := "message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))"
var messageImages []*MessageImage
if err := tx.WithContext(ctx).Unscoped().
Where(messageImageScope, userID, userID).
Find(&messageImages).Error; err != nil {
return errors.Wrap(err, "get message images of user")
}
messageImageValues := make([]string, 0, len(messageImages))
for _, image := range messageImages {
value := image.ObjectKey
if value == "" {
value = image.URL
}
messageImageValues = append(messageImageValues, value)
}
if err := tx.WithContext(ctx).Unscoped().
Where(messageImageScope, userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
if err := enqueueR2DeleteTasksForValues(tx.WithContext(ctx), conf.Upload.ImageBucket, messageImageValues); err != nil {
return errors.Wrap(err, "enqueue deleted message images")
}
// 消息:属于该用户作为 owner 或 asker 的会话
if err := tx.WithContext(ctx).Unscoped().
Where("conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?)", userID, userID).
Delete(&Message{}).Error; err != nil {
return errors.Wrap(err, "delete messages of user")
}
// 消息图片
if err := tx.WithContext(ctx).Unscoped().
Where("message_id IN (SELECT id FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE owner_id = ? OR asker_id = ?))", userID, userID).
Delete(&MessageImage{}).Error; err != nil {
return errors.Wrap(err, "delete message images of user")
}
// 私聊会话
if err := tx.WithContext(ctx).Unscoped().
@@ -1042,12 +1132,25 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete conversations of user")
}
// 提问-图片关联(仅删除关联记录;UploadImage 本体为通用资源,保留)
// 先确定本用户上传或仅与当前作用域有关的候选主体,再解除本用户题目的关联。
// 最终是否可删除由与 UploadImages.DeleteByQuestionID 完全相同的共享保护谓词决定。
var uploadImageIDs []uint
if err := tx.WithContext(ctx).Unscoped().Model(&UploadImage{}).
Where(`uploader_user_id = ? OR upload_images.id IN (
SELECT upload_image_id FROM upload_image_questions
WHERE question_id IN (SELECT id FROM questions WHERE user_id = ?)
)`, userID, userID).
Pluck("upload_images.id", &uploadImageIDs).Error; err != nil {
return errors.Wrap(err, "get upload images of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("question_id IN (SELECT id FROM questions WHERE user_id = ?)", userID).
Delete(&UploadImageQuestion{}).Error; err != nil {
return errors.Wrap(err, "delete upload image links of user")
}
if err := deleteUnreferencedUploadImages(ctx, tx, uploadImageIDs); err != nil {
return errors.Wrap(err, "delete upload images of user")
}
// 提问
if err := tx.WithContext(ctx).Unscoped().
@@ -1056,6 +1159,35 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
return errors.Wrap(err, "delete questions of user")
}
// 投票子表必须先于投票主表删除;只处理该用户拥有的投票,不按参与者身份扩大范围。
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("vote_id IN (SELECT id FROM votes WHERE user_id = ?)", userID).
Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options of user")
}
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&Vote{}).Error; err != nil {
return errors.Wrap(err, "delete votes of user")
}
// 提问箱访问统计:user_id 明确指向箱主,不属于站方审计/处置记录。
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ?", userID).
Delete(&BoxView{}).Error; err != nil {
return errors.Wrap(err, "delete box views of user")
}
// 拉黑关系(该用户拉黑别人,或被别人拉黑)
if err := tx.WithContext(ctx).Unscoped().
Where("user_id = ? OR blocked_id = ?", userID, userID).
+142
View File
@@ -0,0 +1,142 @@
package db
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
func createOutboxTestUser(t *testing.T, ctx context.Context, store UsersStore, email, domain string) *User {
t.Helper()
require.NoError(t, store.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := store.GetByEmail(ctx, email)
require.NoError(t, err)
return user
}
func TestPurgeUserContentEnqueuesOwnedR2Objects(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
users := NewUsersStore(conn)
owner := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-owner")
other := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-other")
require.NoError(t, conn.Model(owner).Updates(map[string]interface{}{
"avatar": "https://cdn.example.test/assets/profile-owned.webp", "background": "https://cdn.example.test/assets/default-background.webp", "background_image": "https://cdn.example.test/assets/profile-shared.webp",
}).Error)
require.NoError(t, conn.Model(other).Update("background_image", "https://cdn.example.test/assets/profile-shared.webp").Error)
questions := NewQuestionsStore(conn)
question, err := questions.Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.NoError(t, err)
otherQuestion, err := questions.Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.NoError(t, err)
images := NewUploadImagesStore(conn)
owned, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp"})
require.NoError(t, err)
_, err = images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp"})
require.NoError(t, err)
legacy, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "https://cdn.example.test/assets/legacy.webp",
})
require.NoError(t, err)
external, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "https://external.example.net/external.webp",
})
require.NoError(t, err)
shared, err := images.Create(ctx, CreateUploadImageOptions{QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp"})
require.NoError(t, err)
require.NoError(t, conn.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: shared.ID, QuestionID: otherQuestion.ID,
}).Error)
conversation, message, err := NewConversationStore(conn).CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.NoError(t, err)
require.NotNil(t, conversation)
messageImages := NewMessageImageStore(conn)
for _, opts := range []CreateMessageImageOptions{
{MessageID: message.ID, ObjectKey: "message-key.webp", URL: "https://cdn.example.test/assets/message-key.webp"},
{MessageID: message.ID, ObjectKey: "owned.webp", URL: "https://cdn.example.test/assets/owned.webp"},
{MessageID: message.ID, URL: "https://cdn.example.test/assets/message-legacy.webp"},
{MessageID: message.ID, URL: "https://external.example.net/message-external.webp"},
} {
_, err = messageImages.Create(ctx, opts)
require.NoError(t, err)
}
require.NoError(t, users.Deactivate(ctx, owner.ID))
require.Zero(t, uploadImageCount(t, conn, owned.ID))
require.Zero(t, uploadImageCount(t, conn, legacy.ID))
require.Zero(t, uploadImageCount(t, conn, external.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, shared.ID))
var sharedLinks int64
require.NoError(t, conn.WithContext(ctx).Model(&UploadImageQuestion{}).
Where("upload_image_id = ? AND question_id = ?", shared.ID, otherQuestion.ID).Count(&sharedLinks).Error)
require.Equal(t, int64(1), sharedLinks)
due, err := NewR2DeleteTaskStore(conn).ListDue(ctx, 20)
require.NoError(t, err)
keys := make([]string, 0, len(due))
for _, task := range due {
keys = append(keys, task.Key)
}
require.ElementsMatch(t, []string{
"owned.webp", "legacy.webp", "message-key.webp", "message-legacy.webp", "profile-owned.webp", "profile-shared.webp",
}, keys)
require.NotContains(t, keys, "shared.webp")
require.NotContains(t, keys, "external.webp")
require.NotContains(t, keys, "message-external.webp")
require.NotContains(t, keys, "default-background.webp")
var deactivated User
require.NoError(t, conn.Unscoped().First(&deactivated, owner.ID).Error)
require.Empty(t, deactivated.Avatar)
require.Empty(t, deactivated.Background)
require.Empty(t, deactivated.BackgroundImage)
other, err = users.GetByID(ctx, other.ID)
require.NoError(t, err)
require.Equal(t, "https://cdn.example.test/assets/profile-shared.webp", other.BackgroundImage)
referenced, err := NewR2DeleteTaskStore(conn).HasReferences(ctx, conf.Upload.ImageBucket, "profile-shared.webp")
require.NoError(t, err)
require.True(t, referenced)
referenced, err = NewR2DeleteTaskStore(conn).HasReferences(ctx, conf.Upload.ImageBucket, "default-background.webp")
require.NoError(t, err)
require.True(t, referenced)
}
func TestPurgeUserContentOutboxFailureRollsBack(t *testing.T) {
setR2TestConfig(t)
conn, _ := newTestDB(t)
ctx := context.Background()
users := NewUsersStore(conn)
owner := createOutboxTestUser(t, ctx, users, "[email protected]", "outbox-rollback")
question, err := NewQuestionsStore(conn).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "rollback"})
require.NoError(t, err)
image, err := NewUploadImagesStore(conn).Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "rollback.webp",
})
require.NoError(t, err)
require.NoError(t, conn.Migrator().DropTable(&R2DeleteTask{}))
require.Error(t, users.Deactivate(ctx, owner.ID))
require.Equal(t, int64(1), uploadImageCount(t, conn, image.ID))
var links int64
require.NoError(t, conn.WithContext(ctx).Model(&UploadImageQuestion{}).
Where("upload_image_id = ? AND question_id = ?", image.ID, question.ID).Count(&links).Error)
require.Equal(t, int64(1), links)
var questions int64
require.NoError(t, conn.WithContext(ctx).Unscoped().Model(&Question{}).Where("id = ?", question.ID).Count(&questions).Error)
require.Equal(t, int64(1), questions)
var user User
require.NoError(t, conn.WithContext(ctx).First(&user, owner.ID).Error)
require.False(t, user.DeletedAt.Valid)
}
+99
View File
@@ -552,6 +552,105 @@ func TestDeactivatePurgesUser(t *testing.T) {
// TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除,
// 该邮箱/域名被彻底释放。
func TestDeactivatePurgesAllOwnedContent(t *testing.T) {
db, _ := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(db)
createUser := func(email, domain string) *User {
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
Name: domain,
Password: "test-only-password",
Email: email,
Domain: domain,
}))
user, err := usersStore.GetByEmail(ctx, email)
require.Nil(t, err)
return user
}
owner := createUser("[email protected]", "purge-all")
other := createUser("[email protected]", "purge-other")
question, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"})
require.Nil(t, err)
otherQuestion, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"})
require.Nil(t, err)
images := NewUploadImagesStore(db)
ownedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp",
})
require.Nil(t, err)
sharedImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp",
})
require.Nil(t, err)
require.Nil(t, db.WithContext(ctx).Create(&UploadImageQuestion{
UploadImageID: sharedImage.ID, QuestionID: otherQuestion.ID,
}).Error)
orphanImage := &UploadImage{UploaderUserID: owner.ID, Key: "orphan.webp"}
require.Nil(t, db.WithContext(ctx).Create(orphanImage).Error)
otherImage, err := images.Create(ctx, CreateUploadImageOptions{
QuestionID: otherQuestion.ID, UploaderUserID: other.ID, Key: "other.webp",
})
require.Nil(t, err)
conversations := NewConversationStore(db)
conversation, message, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{
OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20,
}, other.ID, "private")
require.Nil(t, err)
_, err = NewMessageImageStore(db).Create(ctx, CreateMessageImageOptions{
MessageID: message.ID, URL: "message.webp", FileName: "message.webp",
})
require.Nil(t, err)
vote, err := NewVotesStore(db).Create(ctx, CreateVoteOptions{
UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm,
})
require.Nil(t, err)
require.Nil(t, NewVotesStore(db).UpdateStatus(ctx, vote.ID, VoteStatusPublished))
option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.Nil(t, err)
_, err = NewVoteFormFieldsStore(db).Create(ctx, CreateVoteFormFieldOptions{
VoteID: vote.ID, FieldType: VoteFormFieldTypeText, Label: "field",
})
require.Nil(t, err)
_, err = NewVoteSubmissionsStore(db).Create(ctx, CreateVoteSubmissionOptions{
VoteID: vote.ID, OptionID: option.ID,
})
require.Nil(t, err)
require.Nil(t, NewBoxViewsStore(db).Record(ctx, RecordBoxViewOptions{
UserID: owner.ID, ViewedOn: time.Now(), Visitor: "visitor",
}))
require.Nil(t, usersStore.Deactivate(ctx, owner.ID))
assertCount := func(model interface{}, query string, args []interface{}, want int64) {
t.Helper()
var count int64
require.Nil(t, db.WithContext(ctx).Unscoped().Model(model).Where(query, args...).Count(&count).Error)
require.Equal(t, want, count)
}
assertCount(&MessageImage{}, "message_id = ?", []interface{}{message.ID}, 0)
assertCount(&Message{}, "conversation_id = ?", []interface{}{conversation.ID}, 0)
assertCount(&Conversation{}, "id = ?", []interface{}{conversation.ID}, 0)
assertCount(&VoteSubmission{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteFormField{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&VoteOption{}, "vote_id = ?", []interface{}{vote.ID}, 0)
assertCount(&Vote{}, "id = ?", []interface{}{vote.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{ownedImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{orphanImage.ID}, 0)
assertCount(&UploadImage{}, "id = ?", []interface{}{sharedImage.ID}, 1)
assertCount(&UploadImage{}, "id = ?", []interface{}{otherImage.ID}, 1)
assertCount(&UploadImageQuestion{}, "question_id = ?", []interface{}{question.ID}, 0)
assertCount(&UploadImageQuestion{}, "upload_image_id = ? AND question_id = ?", []interface{}{sharedImage.ID, otherQuestion.ID}, 1)
assertCount(&BoxView{}, "user_id = ?", []interface{}{owner.ID}, 0)
assertCount(&Question{}, "id = ?", []interface{}{otherQuestion.ID}, 1)
}
func TestBanUserAndCleanupRemovesRow(t *testing.T) {
db, cleanup := newTestDB(t)
defer func() { _ = cleanup("users", "questions", "feature_controls") }()
+11 -1
View File
@@ -34,11 +34,21 @@ type voteOptions struct {
*gorm.DB
}
// MaxVoteOptionLen 是选项内容在数据库中的最大字符数。
//
// 必须与 conf 的默认 vote_option_len 保持一致:配置曾经允许 300 而这里只有 200,
// 于是 201–300 字的选项「校验通过、写库失败」(MySQL 严格模式下报错,
// PostgreSQL 直接报 value too long)。改列宽而不是改配置,是因为配置本来
// 就是给箱主按需调小的,不该反过来限制它的上界。
//
// ⚠️ 改这个常量时必须同步改下面 Content 字段的 size 标签(结构体标签不能引用常量)。
const MaxVoteOptionLen = 300
// VoteOption 选项表:单选/多选/评分/排序共用。
type VoteOption struct {
dbutil.Model
VoteID uint `gorm:"index:idx_option_vote" json:"vote_id"`
Content string `gorm:"size:200" json:"content"`
Content string `gorm:"size:300" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
+89
View File
@@ -114,3 +114,92 @@ func testVoteOptionsCountByVoteID(t *testing.T, ctx context.Context, db *voteOpt
require.Nil(t, err)
require.Equal(t, int64(0), got)
}
// TestVoteOptionMaxLengthRoundTrip 保证「配置允许的长度」真的能存下。
//
// 曾经 conf 允许 300 字而列宽只有 200:201–300 字的选项通过校验后在写库时才失败
// (PostgreSQL 报 value too long,MySQL 严格模式同样报错),用户看到的是「内部错误」。
func TestVoteOptionMaxLengthRoundTrip(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
store := NewVoteOptionsStore(conn)
// 结构体标签不能引用常量,列宽与 MaxVoteOptionLen 是两处独立字面量,
// 这里显式比一次,防止只改了一边。
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
if length, ok := ct.Length(); ok {
require.EqualValues(t, MaxVoteOptionLen, length, "vote_options.content 列宽必须与 MaxVoteOptionLen 一致")
}
}
// 用多字节字符填满上限,顺便验证按字符而非字节计数。
content := ""
for len([]rune(content)) < MaxVoteOptionLen {
content += "選"
}
option, err := store.Create(ctx, CreateVoteOptionOptions{VoteID: 1, Content: content})
require.Nil(t, err)
got, err := store.ListByVoteID(ctx, 1)
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, option.ID, got[0].ID)
require.Equal(t, MaxVoteOptionLen, len([]rune(got[0].Content)), "存进去的字符数应与上限一致,未被截断")
}
// TestVoteOptionColumnWidens 验证老库升级路径:已经存在 varchar(200) 的列,
// 跑一次 AutoMigrate 后必须变成 MaxVoteOptionLen。
//
// 只改 model 标签是不够的 —— 如果 AutoMigrate 不扩列,老部署上线后
// 201–300 字的选项依旧写不进去,而新库(测试库)永远发现不了这个问题。
func TestVoteOptionColumnWidens(t *testing.T) {
conn, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("vote_options"); err != nil {
t.Fatal(err)
}
})
// 退回老库的列宽(两种方言语法不同)。
var alter string
switch conn.Dialector.Name() {
case "postgres":
alter = "ALTER TABLE vote_options ALTER COLUMN content TYPE varchar(200)"
case "mysql":
alter = "ALTER TABLE vote_options MODIFY COLUMN content varchar(200)"
default:
t.Skipf("未适配的方言:%s", conn.Dialector.Name())
}
require.Nil(t, conn.Exec(alter).Error)
contentLength := func() int64 {
columns, err := conn.Migrator().ColumnTypes(&VoteOption{})
require.Nil(t, err)
for _, ct := range columns {
if ct.Name() != "content" {
continue
}
length, ok := ct.Length()
require.True(t, ok, "content 列应有明确长度")
return length
}
t.Fatal("未找到 content 列")
return 0
}
require.EqualValues(t, 200, contentLength(), "前置条件:列宽应已被改回 200")
require.Nil(t, conn.AutoMigrate(&VoteOption{}))
require.EqualValues(t, MaxVoteOptionLen, contentLength(), "AutoMigrate 必须把列宽扩到 MaxVoteOptionLen")
}
+71 -4
View File
@@ -15,6 +15,7 @@ import (
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
@@ -25,6 +26,8 @@ type VoteSubmissionsStore interface {
Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error)
GetByID(ctx context.Context, id uint) (*VoteSubmission, error)
ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error)
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error)
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error)
@@ -68,12 +71,16 @@ type CreateVoteSubmissionOptions struct {
VoterIP string
FormData datatypes.JSON
CustomText string
// QuotaUserID 是投票的发起人(箱主)。非 0 时在同一事务里把票数计入
// 该用户「投票」功能的定量收集计数;为 0 表示不计数(测试/内部调用)。
QuotaUserID uint
}
var (
// ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。
ErrVoteAlreadyVoted = errors.New("你已参与过该投票")
ErrVoteSubmissionNotExist = errors.New("投票记录不存在")
ErrVoteNotAccepting = errors.New("投票未开放或已达到收集上限")
)
// Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交),
@@ -88,11 +95,51 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
FormData: opts.FormData,
CustomText: opts.CustomText,
}
if err := db.WithContext(ctx).Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return nil, ErrVoteAlreadyVoted
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if opts.QuotaUserID != 0 {
if err := reserveFeatureQuotaTx(tx, opts.QuotaUserID, FeatureVote); err != nil {
return err
}
return nil, errors.Wrap(err, "create vote submission")
}
var vote Vote
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&vote, opts.VoteID).Error; err != nil {
return errors.Wrap(err, "lock vote")
}
now := time.Now()
if vote.Status != VoteStatusPublished || (vote.StartAt != nil && now.Before(*vote.StartAt)) || (vote.EndAt != nil && !now.Before(*vote.EndAt)) {
return ErrVoteNotAccepting
}
var count int64
if err := tx.Model(&VoteSubmission{}).Where("vote_id = ?", opts.VoteID).Count(&count).Error; err != nil {
return errors.Wrap(err, "count vote submissions")
}
if vote.QuotaLimit > 0 && count >= int64(vote.QuotaLimit) {
return ErrVoteNotAccepting
}
if err := tx.Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return ErrVoteAlreadyVoted
}
return errors.Wrap(err, "create vote submission")
}
// 箱主级的「投票」定量收集按累计收到的票数计 —— 与创建投票无关,
// 所以这一步只能放在提交成功这里。放在事务内的理由同提问:
// 原来 handler 里是独立写入且失败只 Warn,跳过一票就永久少一票,
// 达上限时自动结束投票会晚一步,箱主的「已收集」也一直偏低。
if opts.QuotaUserID != 0 {
if err := incrementQuotaTx(tx, opts.QuotaUserID, FeatureVote, 1); err != nil {
return errors.Wrap(err, "increment vote quota")
}
}
if vote.QuotaLimit > 0 && count+1 >= int64(vote.QuotaLimit) {
if err := tx.Model(&Vote{}).Where("id = ?", vote.ID).Update("status", VoteStatusEnded).Error; err != nil {
return errors.Wrap(err, "end vote on quota")
}
}
return nil
})
if err != nil {
return nil, err
}
return submission, nil
}
@@ -130,6 +177,26 @@ func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor
return submissions, nil
}
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
// 返回 map[voteID][]*VoteSubmission;未出现在结果中的 voteID 视为无提交。
func (db *voteSubmissions) ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error) {
out := make(map[uint][]*VoteSubmission, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
var submissions []*VoteSubmission
if err := db.WithContext(ctx).
Where("vote_id IN ?", voteIDs).
Order("created_at ASC").
Find(&submissions).Error; err != nil {
return nil, errors.Wrap(err, "list vote submissions by vote ids")
}
for _, s := range submissions {
out[s.VoteID] = append(out[s.VoteID], s)
}
return out, nil
}
// CountByVoteID 统计某投票的总参与人数。
func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64
+109 -60
View File
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
@@ -53,10 +54,41 @@ func TestVoteSubmissions(t *testing.T) {
func userIDPtr(id uint) *uint { return &id }
func tokenPtr(s string) *string { return &s }
// seedPublishedVotes 创建 n 个已发布的投票,每个投票带两个选项。
// 返回 voteID 列表;调用方用 votes[i] 作为 VoteID、options[voteID][j] 作为 OptionID。
// VoteSubmissions.Create 现在要求投票必须 published 才接受提交,旧的测试夹具
// 直接用 VoteID=1 的虚拟行会被锁行失败拒绝,因此需要真实投票。
func seedPublishedVotes(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, n int) ([]uint, map[uint][]uint) {
t.Helper()
votesStore := NewVotesStore(conn)
optionsStore := NewVoteOptionsStore(conn)
voteIDs := make([]uint, 0, n)
optionIDs := make(map[uint][]uint, n)
for i := 0; i < n; i++ {
vote, err := votesStore.Create(ctx, CreateVoteOptions{
UserID: userID, Title: "seed vote", Type: VoteTypeSingle,
})
require.NoError(t, err)
require.NoError(t, votesStore.UpdateStatus(ctx, vote.ID, VoteStatusPublished))
voteIDs = append(voteIDs, vote.ID)
optionIDs[vote.ID] = make([]uint, 0, 2)
for j := 0; j < 2; j++ {
opt, err := optionsStore.Create(ctx, CreateVoteOptionOptions{
VoteID: vote.ID, Content: "option",
})
require.NoError(t, err)
optionIDs[vote.ID] = append(optionIDs[vote.ID], opt.ID)
}
}
return voteIDs, optionIDs
}
func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
got, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -67,9 +99,11 @@ func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmis
// 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。
func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -77,8 +111,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -87,8 +121,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
t.Run("different user allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(11),
VoterIP: "114.5.1.5",
})
@@ -98,9 +132,11 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
// 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。
func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
@@ -108,8 +144,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
t.Run("duplicate rejected", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
VoterToken: tokenPtr("fp-token-001"),
VoterIP: "114.5.1.4",
})
@@ -118,8 +154,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
t.Run("different token allowed", func(t *testing.T) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-token-002"),
VoterIP: "114.5.1.4",
})
@@ -129,17 +165,19 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
// 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。
func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 2,
VoteID: voteID,
OptionID: optionIDs[voteID][1],
VoterToken: tokenPtr("fp-anon-001"),
VoterIP: "114.5.1.6",
})
@@ -147,25 +185,26 @@ func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db
// 同一 user 换匿名 token 再来:user_id 相同仍应被拦截
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterToken: tokenPtr("fp-anon-002"),
VoterIP: "114.5.1.7",
})
require.Equal(t, ErrVoteAlreadyVoted, err)
count, err := db.CountByVoteID(ctx, 1)
count, err := db.CountByVoteID(ctx, voteID)
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
// 同一用户 / 同一 token 对不同投票不受约束。
func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) {
for voteID := uint(1); voteID <= 2; voteID++ {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
for _, voteID := range voteIDs {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: voteID,
OptionID: 1,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(10),
VoterIP: "114.5.1.4",
})
@@ -173,50 +212,50 @@ func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSub
}
// 同一 token 投两个不同投票也允许
for _, voteID := range voteIDs {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 2,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
VoterToken: tokenPtr("fp-x"),
VoterIP: "114.5.1.8",
})
require.Nil(t, err)
}
count, err := db.CountByVoteID(ctx, 1)
count, err := db.CountByVoteID(ctx, voteIDs[0])
require.Nil(t, err)
require.Equal(t, int64(2), count)
}
func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) {
// 投票 1:选项 1 两票,选项 2 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
vote1, vote2 := voteIDs[0], voteIDs[1]
opt1a, opt1b := optionIDs[vote1][0], optionIDs[vote1][1]
opt2a := optionIDs[vote2][0]
// 投票 1:选项 A 两票,选项 B 一票
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1b, UserID: userIDPtr(3), VoterIP: "c"})
require.Nil(t, err)
// 投票 2:一票
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: opt2a, UserID: userIDPtr(4), VoterIP: "d"})
require.Nil(t, err)
t.Run("total", func(t *testing.T) {
got, err := db.CountByVoteID(ctx, 1)
got, err := db.CountByVoteID(ctx, vote1)
require.Nil(t, err)
require.Equal(t, int64(3), got)
})
t.Run("by option", func(t *testing.T) {
got, err := db.CountByOptionID(ctx, 1, 1)
got, err := db.CountByOptionID(ctx, vote1, opt1a)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByOptionID(ctx, 1, 2)
got, err = db.CountByOptionID(ctx, vote1, opt1b)
require.Nil(t, err)
require.Equal(t, int64(1), got)
})
@@ -229,54 +268,62 @@ func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmiss
}
func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasUserVoted(ctx, 1, 10)
got, err := db.HasUserVoted(ctx, voteID, 10)
require.Nil(t, err)
require.True(t, got)
got, err = db.HasUserVoted(ctx, 1, 11)
got, err = db.HasUserVoted(ctx, voteID, 11)
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
require.Nil(t, err)
got, err := db.HasTokenVoted(ctx, 1, "fp-1")
got, err := db.HasTokenVoted(ctx, voteID, "fp-1")
require.Nil(t, err)
require.True(t, got)
got, err = db.HasTokenVoted(ctx, 1, "fp-2")
got, err = db.HasTokenVoted(ctx, voteID, "fp-2")
require.Nil(t, err)
require.False(t, got)
}
func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
since := time.Now().Add(-1 * time.Hour)
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
require.Nil(t, err)
got, err := db.CountByIP(ctx, 1, "1.2.3.4", since)
got, err := db.CountByIP(ctx, voteID, "1.2.3.4", since)
require.Nil(t, err)
require.Equal(t, int64(2), got)
got, err = db.CountByIP(ctx, 1, "5.6.7.8", since)
got, err = db.CountByIP(ctx, voteID, "5.6.7.8", since)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
voteID := voteIDs[0]
for i := 0; i < 3; i++ {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
VoteID: 1,
OptionID: 1,
VoteID: voteID,
OptionID: optionIDs[voteID][0],
UserID: userIDPtr(uint(100 + i)),
VoterIP: "x",
FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`),
@@ -284,25 +331,27 @@ func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *vote
require.Nil(t, err)
}
got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10})
got, err := db.ListByVoteID(ctx, voteID, &dbutil.Cursor{Value: 2, PageSize: 10})
require.Nil(t, err)
require.Len(t, got, 1)
require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录")
}
func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
vote1, vote2 := voteIDs[0], voteIDs[1]
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: optionIDs[vote1][0], UserID: userIDPtr(1), VoterIP: "a"})
require.Nil(t, err)
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: optionIDs[vote2][0], UserID: userIDPtr(2), VoterIP: "b"})
require.Nil(t, err)
require.Nil(t, db.DeleteByVoteID(ctx, 1))
require.Nil(t, db.DeleteByVoteID(ctx, vote1))
got, err := db.CountByVoteID(ctx, 1)
got, err := db.CountByVoteID(ctx, vote1)
require.Nil(t, err)
require.Equal(t, int64(0), got)
got, err = db.CountByVoteID(ctx, 2)
got, err = db.CountByVoteID(ctx, vote2)
require.Nil(t, err)
require.Equal(t, int64(1), got)
}
+188
View File
@@ -61,6 +61,9 @@ const (
type VotesStore interface {
Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error)
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error)
GetByID(ctx context.Context, id uint) (*Vote, error)
GetByToken(ctx context.Context, token string) (*Vote, error)
ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error)
@@ -68,8 +71,13 @@ type VotesStore interface {
ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error)
GetHot(ctx context.Context, limit int) ([]*Vote, error)
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
DeleteWithCascade(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, error)
}
@@ -158,6 +166,69 @@ func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, err
return vote, nil
}
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
func (db *votes) CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error) {
var vote *Vote
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
v := &Vote{
UserID: opts.UserID,
Title: opts.Title,
Description: opts.Description,
Type: opts.Type,
Visibility: opts.Visibility,
VoterScope: opts.VoterScope,
ShowResult: opts.ShowResult,
AllowCustom: opts.AllowCustom,
MaxChoices: opts.MaxChoices,
StartAt: opts.StartAt,
EndAt: opts.EndAt,
MaxVotesPerFingerprint: opts.MaxVotesPerFingerprint,
RequireCaptcha: opts.RequireCaptcha,
CensorMetadata: opts.CensorMetadata,
QuotaLimit: opts.QuotaLimit,
}
if err := tx.Create(v).Error; err != nil {
return errors.Wrap(err, "create vote")
}
vote = v
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: v.ID, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: v.ID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
if err != nil {
return nil, err
}
return vote, nil
}
var ErrVoteNotExist = errors.New("投票不存在")
func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) {
@@ -333,6 +404,79 @@ func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) er
return nil
}
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
// 任一步失败整体回滚,避免「元信息已更新、字段只更新一部分」的不一致草稿。
func (db *votes) UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updates := map[string]interface{}{
"title": opts.Title,
"description": opts.Description,
"visibility": opts.Visibility,
"voter_scope": opts.VoterScope,
"show_result": opts.ShowResult,
"allow_custom": opts.AllowCustom,
"start_at": opts.StartAt,
"end_at": opts.EndAt,
"max_votes_per_fingerprint": opts.MaxVotesPerFingerprint,
"require_captcha": opts.RequireCaptcha,
"censor_metadata": opts.CensorMetadata,
"quota_limit": opts.QuotaLimit,
}
if opts.Type != nil {
updates["type"] = *opts.Type
}
if opts.MaxChoices != nil {
updates["max_choices"] = *opts.MaxChoices
}
res := tx.Model(&Vote{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
// 替换选项(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete old vote options")
}
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: id, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
// 替换表单字段(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete old vote form fields")
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: id,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
}
// UpdateStatus 更新投票状态(draft → published → ended → archived)。
func (db *votes) UpdateStatus(ctx context.Context, id uint, status string) error {
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Update("status", status)
@@ -359,6 +503,50 @@ func (db *votes) DeleteByID(ctx context.Context, id uint) error {
return nil
}
// DeleteWithCascade 删除投票及其全部关联数据(选项、表单字段、提交记录)。
//
// 为什么必须是一个事务:原来 handler 里是 4 次独立写入,前 3 次失败只打 Warn,
// 于是可能留下「选项没了但票数还在」(结果页统计全 0 而提交记录占着库)、
// 「投票没了但提交记录成孤儿」这类无法通过界面修复的状态 —— 箱主只能重试删除,
// 而重试时 vote 已不存在,直接报 not exist,脏数据永远留着。
//
// 为什么是 Unscoped(真删)而不是软删:
// 1. 用户点「删除」的预期就是删掉,不是「我还能看到但数据库里还在」;
// 2. 提交记录里有参与者填写的文本、voter_ip、voter_token —— 软删等于把这些
// 个人数据永久留在库里,而界面上已经看不到了,等于删了但没删干净;
// 3. 软删还会占住 link_token 唯一索引,删过的链接永远无法复用。
func (db *votes) DeleteWithCascade(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var vote Vote
if err := tx.First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
// 顺序:先清子表再删主表。没有外键约束(项目不用 FK),
// 所以顺序只影响可读性;真正的保证来自事务本身。
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields")
}
if err := tx.Unscoped().Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
// 注意:这里**不**回退 quota_count。投票的定量收集口径是「累计收到的票数」
// (见 route/vote/vote.go 中提交成功后 +1),不是「创建了几个投票」——
// 删掉一个投票并不意味着那些票没发生过。箱主要腾额度靠界面上的「重置计数」。
return nil
})
}
// CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64
+11 -2
View File
@@ -17,6 +17,7 @@ type UpdateProfile struct {
Backgroundimagealpha string `valid:"required" label:"二维码背景透明度" msg:"form.profile.backgroundimagealpha"`
Qrcodepdpcolor string `valid:"required;minlen:7;maxlen:7" label:"二维码探测图形颜色" msg:"form.profile.colorHex"`
NotifyEmail string `label:"开启邮箱通知"`
TelegramChatID string `form:"telegram_chat_id" valid:"maxlen:64" label:"Telegram Chat ID" msg:"form.profile.telegramChatId"`
Acfunlink string `valid:"maxlen:30" label:"AcFun主页" msg:"form.profile.linkMax"`
Bililink string `valid:"maxlen:30" label:"Bili主页" msg:"form.profile.linkMax"`
Weibolink string `valid:"maxlen:30" label:"微博主页" msg:"form.profile.linkMax"`
@@ -56,12 +57,20 @@ type UpdateTags struct {
ShowInHome string `form:"show_in_home"`
}
// 页面访问密码 / 提问密码的最小长度。
//
// P1-2:这两个字段此前只有 maxlen,箱主可以设成 1 位或 1234 —— 而页面密码正是产品
// 对外宣传的访问控制,提问密码是箱主过滤骚扰的唯一门槛。6 位是最低可接受值
// (纯数字 10^6,配合失败锁定后穷举不可行);留空仍然合法,表示关闭该密码
// (govalid 对空值跳过 minlen / maxlen)。
const PasswordMinLen = 6
// 新增:页面访问密码表单
type UpdatePagePassword struct {
Password string `valid:"maxlen:30" label:"页面访问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"页面访问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
// 新增:提问密码表单
type UpdateAskPassword struct {
Password string `valid:"maxlen:30" label:"提问密码" msg:"form.profile.passwordMax"` // 留空表示关闭
Password string `valid:"minlen:6;maxlen:30" label:"提问密码" msg:"form.profile.passwordLen"` // 留空表示关闭
}
+73 -2
View File
@@ -21,6 +21,7 @@ var enDict = map[string]string{
"common.submit": "Submit",
"common.cancel": "Cancel",
"common.save": "Save",
"common.tooManyRequests": "Too many requests. Please try again later.",
"common.close": "Close",
"common.delete": "Delete",
"common.moveUp": "Move up",
@@ -112,6 +113,14 @@ var enDict = map[string]string{
"ask.chatDisabled": "Chat is disabled on this box",
"ask.chatLimit": "Message limit: %d",
"ask.history": "Previously answered by @%s",
"askHistory.banner": "You have {n} question(s) saved on this device",
"askHistory.answeredSuffix": ", {m} of them answered",
"askHistory.expand": "View",
"askHistory.collapse": "Hide",
"askHistory.answered": "Answered",
"askHistory.pending": "Awaiting reply",
"askHistory.open": "Open",
"askHistory.note": "Saved only in this browser. Switching devices or clearing browser data will lose them.",
"ask.loginRequired": "Log in required",
"ask.loginRequiredChat": "You need to log in to start a chat",
"ask.goLogin": "Go to login",
@@ -182,9 +191,9 @@ var enDict = map[string]string{
"admin.tagInputHint": "Type and press Enter or comma to add",
"admin.tagInputDuplicate": "Already exists, ignored",
"admin.pagePassword": "Page access password",
"admin.pagePasswordHint": "Set a page password (leave empty to disable)",
"admin.pagePasswordHint": "Set a page password (6–30 characters, leave empty to disable)",
"admin.askPassword": "Ask password",
"admin.askPasswordHint": "Set a password for asking (leave empty to disable)",
"admin.askPasswordHint": "Set a password for asking (6–30 characters, leave empty to disable)",
"admin.saveProfile": "Save changes",
"admin.updated": "Profile updated successfully",
"admin.logout": "Log out",
@@ -194,6 +203,9 @@ var enDict = map[string]string{
"admin.oldPasswordHint": "Only needed when changing password; leave empty to keep it unchanged",
"admin.newPasswordHint": "6-64 characters; leave empty to keep it unchanged",
"admin.notifyEmail": "Email",
"admin.notifyTelegram": "Telegram",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID (empty = disabled)",
"admin.notifyTelegramHint": "Send any message to @userinfobot to get your Chat ID. Requires a site-configured Telegram Bot.",
"admin.useCustomFg": "Use custom foreground color (otherwise auto-matched)",
"admin.customFgHint": "Only takes effect when enabled; otherwise the color is auto-generated",
"admin.avatarPlaceholder": "Upload avatar (non-1:1 images will be squished)",
@@ -298,6 +310,9 @@ var enDict = map[string]string{
"qlist.unpinShort": "Unpin",
"qlist.myVotes": "My votes",
"qlist.loadMore": "Load more",
"qlist.noMore": "No more posts",
"qlist.loading": "Loading...",
"qlist.viewAnswer": "View answer",
"qlist.newVote": "New vote",
"qlist.edit": "Edit",
"qlist.untitledVote": "Untitled vote",
@@ -619,6 +634,7 @@ var enDict = map[string]string{
"vote.page.submit": "Submit vote",
"vote.page.alreadyVoted": "You have already participated in this poll. Thanks!",
"vote.page.resultsTitle": "Poll Results",
"vote.page.anonymousNotice": "Anonymous poll — one vote per person, results are indicative only.",
"vote.page.totalVotes": "Total",
"vote.page.avgRating": "Average",
"vote.page.avgRank": "Avg rank",
@@ -628,6 +644,7 @@ var enDict = map[string]string{
"vote.page.rankHint": "Rank each option 1-N (smaller = higher, no duplicates)",
"vote.page.rankPlaceholder": "Rank",
"vote.page.multipleHint": "Multiple choice",
"vote.page.multipleHintWithMax": "Multiple choice (up to %d)",
"vote.page.customOption": "Custom option (optional)",
"vote.page.customPlaceholder": "Type your own option",
"vote.page.customOther": "Other",
@@ -757,6 +774,7 @@ var enDict = map[string]string{
// Question box page: visitor-visible (P0)
"question.errWrongPassword": "Wrong password",
"question.errPasswordLocked": "Too many wrong attempts. Please try again in %d minutes.",
"question.errAskPasswordWrong": "Wrong ask password",
"question.errLoginRequired": "The owner only allows registered users to ask questions. Please log in first.",
"question.errSelfChat": "You cannot start a chat with yourself",
@@ -838,6 +856,8 @@ var enDict = map[string]string{
"vote.err.fieldSerialize": "Failed to serialize field options",
"vote.err.editClosed": "Votes that are ended or archived cannot be edited",
"vote.err.publishDraftOnly": "Only draft polls can be published",
"vote.err.restrictedPublish": "Publishing is suspended while this account is under moderation",
"feature.stateUnavailable": "Unable to verify the status of this feature, please try again later",
"vote.err.closePublishedOnly": "Only active polls can be closed",
"vote.err.publishTitleRequired": "Please fill in the poll title before publishing",
"vote.err.publishFormFieldsRequired": "Form polls need at least 1 field",
@@ -903,6 +923,12 @@ var enDict = map[string]string{
"chat.flash.operated": "Operation successful",
"image.errTooLarge": "Image is too large, maximum supported size is %d×%d pixels, please compress and re-upload",
"internal.errFlash": "Internal server error, please try again later.",
"internal.errBadRequest": "Invalid request",
"error.notFoundTitle": "Page not found",
"error.notFoundDesc": "There is nothing at this address. The link may be mistyped, or the content has been removed.",
"error.internalTitle": "Something went wrong",
"error.internalDesc": "This request could not be completed. It is usually temporary — try again in a moment.",
"error.traceHint": "Please include this ID when reporting:",
"internal.errServer": "Internal server error",
"home.errStats": "Failed to retrieve statistics",
"pixel.errPath": "Invalid request path",
@@ -926,6 +952,7 @@ var enDict = map[string]string{
"form.profile.name": "Nickname is required and must be at most 20 characters",
"form.profile.newPassword": "New password must be at most 30 characters",
"form.profile.passwordMax": "Password must be at most 30 characters",
"form.profile.passwordLen": "Please set a password of 6–30 characters",
"form.question.content": "Question content is required",
"form.question.contentTooLong": "Question content must be within %d characters",
"form.question.receiveReplyEmail": "Please enter a valid email address to receive replies",
@@ -1030,6 +1057,50 @@ var enDict = map[string]string{
"stats.weekday.6": "Saturday",
"stats.weekday.7": "Sunday",
// Owner insights panel (/user/insights)
"insights.title": "Insights",
"insights.heading": "Your insights",
"insights.lead": "These numbers are yours alone: how many people opened your box, how many questions arrived, and how many are still waiting. Everything is aggregated — no visitor or asker is ever identified.",
"insights.back": "Back to settings",
"insights.entryTitle": "Insights",
"insights.entryDesc": "See how many people visit your box and how your questions are doing",
"insights.summary": "At a glance",
"insights.noViews": "No visits recorded yet. Numbers will appear once someone opens your box.",
"insights.today": "Today",
"insights.week": "Last 7 days",
"insights.month": "Last 30 days",
"insights.newQuestions": "new questions",
"insights.pv": "views",
"insights.uv": "visitors",
"insights.trendTitle": "Views over the last 30 days",
"insights.trendNote": "One bar per day: the light part is page views, the dark part is unique visitors that day. Hover a bar for its views / visitors / questions.",
"insights.legendPV": "Page views",
"insights.legendUV": "Unique visitors (per day)",
"insights.legendHover": "Hover for daily details",
"insights.hourTitle": "When people show up",
"insights.hourNote": "When questions arrived during the last 30 days. The brightest bar is your busiest hour.",
"insights.funnelTitle": "Question handling",
"insights.funnelNote": "All questions split into answered / read but unanswered / untouched.",
"insights.funnelTotal": "All questions",
"insights.funnelAnswered": "Answered",
"insights.funnelReadOnly": "Read, not answered",
"insights.funnelUnread": "Not read",
"insights.answerRate": "Answer rate: answered ÷ all questions",
"insights.privacyTitle": "About these numbers",
"insights.privacyNote": "Visit stats only record \"someone visited N times on a given day\". A visitor is an IP hash with a per-day salt — no IP, no user agent, no page path is stored, and the same person cannot be linked across days. The 7-day / 30-day visitor counts are therefore the sum of daily uniques: someone visiting on several days counts several times. Your own visits to your own box are excluded.",
"insights.insight.empty": "No data yet. This page comes alive once you get your first question or someone opens your box.",
"insights.insight.weekViews": "%s views from %s visitors in the last 7 days.",
"insights.insight.conversion": "About %s%% of visitors left a message in the last 30 days.",
"insights.insight.bestDay": "Your busiest day was %s with %s views.",
"insights.insight.peakHour": "Questions cluster around %d:00.",
"insights.insight.answerRate": "You have answered %s%% of all questions.",
"insights.insight.fastReply": "%s%% of your answers went out within 24 hours.",
"insights.insight.unread": "%s questions are still waiting.",
"insights.insight.trendUp": "Views this week are up %s%% on last week.",
"insights.insight.trendDown": "Views this week are down %s%% on last week.",
"insights.insight.trendFlat": "Views this week are about the same as last week.",
"stats.foot.updated": "Snapshot:",
"stats.foot.prevMonth": "Last question:",
"stats.foot.prevAnswer": "Last answer:",
+73 -2
View File
@@ -21,6 +21,7 @@ var jaDict = map[string]string{
"common.submit": "送信",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.tooManyRequests": "リクエストが多すぎます。しばらくしてから再度お試しください。",
"common.close": "閉じる",
"common.delete": "削除",
"common.moveUp": "上へ移動",
@@ -112,6 +113,14 @@ var jaDict = map[string]string{
"ask.chatDisabled": "この質問箱ではチャットが無効です",
"ask.chatLimit": "メッセージ上限:%d件",
"ask.history": "@%s が以前回答した質問",
"askHistory.banner": "この端末に {n} 件の質問履歴があります",
"askHistory.answeredSuffix": "(うち {m} 件は回答済み)",
"askHistory.expand": "表示",
"askHistory.collapse": "閉じる",
"askHistory.answered": "回答済み",
"askHistory.pending": "回答待ち",
"askHistory.open": "開く",
"askHistory.note": "履歴はこのブラウザにのみ保存されます。端末の変更やブラウザデータの削除で失われます。",
"ask.loginRequired": "ログインが必要",
"ask.loginRequiredChat": "チャットを開始するにはログインが必要です",
"ask.goLogin": "ログインへ",
@@ -182,9 +191,9 @@ var jaDict = map[string]string{
"admin.tagInputHint": "入力して Enter またはカンマで追加",
"admin.tagInputDuplicate": "既に存在するため無視しました",
"admin.pagePassword": "ページアクセスパスワード",
"admin.pagePasswordHint": "アクセスパスワードを設定(空欄で無効)",
"admin.pagePasswordHint": "アクセスパスワードを設定(6〜30文字、空欄で無効)",
"admin.askPassword": "質問パスワード",
"admin.askPasswordHint": "質問用パスワードを設定(空欄で無効)",
"admin.askPasswordHint": "質問用パスワードを設定(6〜30文字、空欄で無効)",
"admin.saveProfile": "変更を保存",
"admin.updated": "プロフィールを更新しました",
"admin.logout": "ログアウト",
@@ -194,6 +203,9 @@ var jaDict = map[string]string{
"admin.oldPasswordHint": "パスワード変更時のみ使用。空欄のままなら変更されません",
"admin.newPasswordHint": "6〜64文字。空欄のままなら変更されません",
"admin.notifyEmail": "メール",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(空欄なら送信しない)",
"admin.notifyTelegramHint": "@userinfobot に何か送ると Chat ID を取得できます。サイト側で Telegram Bot の設定が必要です。",
"admin.useCustomFg": "カスタム前景色を使用(それ以外は自動マッチ)",
"admin.customFgHint": "有効時のみ適用されます。それ以外は自動生成されます",
"admin.avatarPlaceholder": "アバターをアップロード(1:1 以外は縦横比が崩れます)",
@@ -298,6 +310,9 @@ var jaDict = map[string]string{
"qlist.unpinShort": "解除",
"qlist.myVotes": "作成した投票",
"qlist.loadMore": "もっと読み込む",
"qlist.noMore": "これ以上の投稿はありません",
"qlist.loading": "読み込み中...",
"qlist.viewAnswer": "回答を見る",
"qlist.newVote": "投票を作成",
"qlist.edit": "編集",
"qlist.untitledVote": "無題の投票",
@@ -618,6 +633,7 @@ var jaDict = map[string]string{
"vote.page.submit": "投票する",
"vote.page.alreadyVoted": "この投票にはすでに参加済みです。ありがとうございます!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票(お一人一票)のため、結果は参考値です。",
"vote.page.totalVotes": "総参加",
"vote.page.avgRating": "平均点",
"vote.page.avgRank": "平均順位",
@@ -627,6 +643,7 @@ var jaDict = map[string]string{
"vote.page.rankHint": "各選択肢に 1-N の順位を入力してください(小さいほど上位、重複不可)",
"vote.page.rankPlaceholder": "順位",
"vote.page.multipleHint": "複数選択可",
"vote.page.multipleHintWithMax": "複数選択可(最大 %d 件)",
"vote.page.customOption": "カスタム選択肢(任意)",
"vote.page.customPlaceholder": "あなたの選択肢を入力",
"vote.page.customOther": "その他",
@@ -756,6 +773,7 @@ var jaDict = map[string]string{
// 質問箱ページ:訪問者に表示(P0)
"question.errWrongPassword": "パスワードが正しくありません",
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
"question.errSelfChat": "自分自身とのチャットは開始できません",
@@ -837,6 +855,8 @@ var jaDict = map[string]string{
"vote.err.fieldSerialize": "項目の選択肢のシリアライズに失敗しました",
"vote.err.editClosed": "終了またはアーカイブされた投票は編集できません",
"vote.err.publishDraftOnly": "草稿状態の投票のみ公開できます",
"vote.err.restrictedPublish": "現在はプラットフォームによる措置期間中のため、投票の公開を停止しています",
"feature.stateUnavailable": "機能の状態を確認できませんでした。しばらくしてから再度お試しください",
"vote.err.closePublishedOnly": "進行中の投票のみ終了できます",
"vote.err.publishTitleRequired": "公開前に投票のタイトルを入力してください",
"vote.err.publishFormFieldsRequired": "フォーム型投票には最低 1 つの項目が必要です",
@@ -902,6 +922,12 @@ var jaDict = map[string]string{
"chat.flash.operated": "操作が完了しました",
"image.errTooLarge": "画像が大きすぎます。最大%d×%dピクセルまで対応しています。圧縮して再アップロードしてください",
"internal.errFlash": "サーバー内部エラーが発生しました。後でもう一度お試しください。",
"internal.errBadRequest": "リクエストの内容が正しくありません",
"error.notFoundTitle": "ページが見つかりません",
"error.notFoundDesc": "このアドレスにコンテンツはありません。リンクが間違っているか、削除された可能性があります。",
"error.internalTitle": "問題が発生しました",
"error.internalDesc": "このリクエストを完了できませんでした。多くは一時的なものです。しばらくしてから再度お試しください。",
"error.traceHint": "ご報告の際はこの番号を添えてください:",
"internal.errServer": "サーバー内部エラー",
"home.errStats": "統計情報の取得に失敗しました",
"pixel.errPath": "不正なリクエストパス",
@@ -925,6 +951,7 @@ var jaDict = map[string]string{
"form.profile.name": "ニックネームは必須で、20文字以内である必要があります",
"form.profile.newPassword": "新しいパスワードは30文字以内である必要があります",
"form.profile.passwordMax": "パスワードは30文字以内である必要があります",
"form.profile.passwordLen": "パスワードは6〜30文字で設定してください",
"form.question.content": "質問内容を入力してください",
"form.question.contentTooLong": "質問内容は%d文字以内にしてください",
"form.question.receiveReplyEmail": "返信を受け取るための有効なメールアドレスを入力してください",
@@ -1029,6 +1056,50 @@ var jaDict = map[string]string{
"stats.weekday.6": "土曜日",
"stats.weekday.7": "日曜日",
// 質問箱オーナーのインサイト(/user/insights)
"insights.title": "インサイト",
"insights.heading": "あなたのインサイト",
"insights.lead": "この数字はあなただけのものです。何人が質問箱を開いたか、いくつ質問が届いたか、まだ手つかずの質問がいくつあるか。すべて集計値で、訪問者や質問者の身元は一切含みません。",
"insights.back": "設定に戻る",
"insights.entryTitle": "インサイト",
"insights.entryDesc": "質問箱に何人来たか、質問の対応状況を見る",
"insights.summary": "ひと目で",
"insights.noViews": "まだ訪問の記録がありません。誰かが質問箱を開くと数字が出はじめます。",
"insights.today": "今日",
"insights.week": "過去 7 日間",
"insights.month": "過去 30 日間",
"insights.newQuestions": "新着の質問",
"insights.pv": "回表示",
"insights.uv": "人",
"insights.trendTitle": "過去 30 日間の閲覧推移",
"insights.trendNote": "1 本が 1 日分。淡い部分が閲覧数、濃い部分がその日の重複を除いた訪問者数です。棒にカーソルを合わせると当日の閲覧/訪問者/質問数が出ます。",
"insights.legendPV": "閲覧数(PV)",
"insights.legendUV": "ユニーク訪問者(日ごとに重複排除)",
"insights.legendHover": "カーソルで当日の内訳",
"insights.hourTitle": "いつ来ているか",
"insights.hourNote": "過去 30 日に届いた質問の時間帯分布。最も明るい棒が一番忙しい時間帯です。",
"insights.funnelTitle": "質問の対応状況",
"insights.funnelNote": "すべての質問を「回答済み/既読・未回答/未読」に分けたものです。",
"insights.funnelTotal": "すべての質問",
"insights.funnelAnswered": "回答済み",
"insights.funnelReadOnly": "既読・未回答",
"insights.funnelUnread": "未読",
"insights.answerRate": "回答率:回答済み ÷ 全質問",
"insights.privacyTitle": "この数字について",
"insights.privacyNote": "訪問統計は「ある日に何回来たか」だけを記録します。訪問者の識別子は日ごとに塩を変えた IP ハッシュで、IP・UA・閲覧ページは保存しません。そのため日をまたいで同一人物を紐付けることはできず、7 日/30 日の訪問者数は各日の重複排除後の合計です(同じ人が数日来るとその人数分カウントされます)。質問箱の持ち主自身のアクセスは含みません。",
"insights.insight.empty": "まだデータがありません。最初の質問が届くか、誰かが質問箱を開くと表示されはじめます。",
"insights.insight.weekViews": "過去 7 日間に %s 回の閲覧、%s 人の訪問者。",
"insights.insight.conversion": "過去 30 日間、訪問者の約 %s%% が質問を残しました。",
"insights.insight.bestDay": "一番にぎわったのは %s で、%s 回閲覧されました。",
"insights.insight.peakHour": "質問は %d 時前後に集中しています。",
"insights.insight.answerRate": "すべての質問のうち %s%% に回答済みです。",
"insights.insight.fastReply": "回答の %s%% は 24 時間以内に返しています。",
"insights.insight.unread": "未対応の質問が %s 件あります。",
"insights.insight.trendUp": "今週の閲覧は先週より %s%% 増えました。",
"insights.insight.trendDown": "今週の閲覧は先週より %s%% 減りました。",
"insights.insight.trendFlat": "今週の閲覧は先週とほぼ同じです。",
"stats.foot.updated": "集計時刻:",
"stats.foot.prevMonth": "最後の質問:",
"stats.foot.prevAnswer": "最後の回答:",
+73 -2
View File
@@ -21,6 +21,7 @@ var zhCNDict = map[string]string{
"common.submit": "提交",
"common.cancel": "取消",
"common.save": "保存",
"common.tooManyRequests": "请求过于频繁,请稍后再试。",
"common.close": "关闭",
"common.delete": "删除",
"common.moveUp": "上移",
@@ -112,6 +113,14 @@ var zhCNDict = map[string]string{
"ask.chatDisabled": "该提问箱未开启私聊",
"ask.chatLimit": "消息上限:%d条",
"ask.history": "@%s 以前回答过的问题",
"askHistory.banner": "这台设备上有 {n} 条提问记录",
"askHistory.answeredSuffix": ",其中 {m} 条已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收起",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "打开",
"askHistory.note": "记录只保存在这台设备的浏览器里,换设备或清除浏览器数据后会丢失。",
"ask.loginRequired": "需要登录",
"ask.loginRequiredChat": "发起私聊需要登录账号",
"ask.goLogin": "去登录",
@@ -182,9 +191,9 @@ var zhCNDict = map[string]string{
"admin.tagInputHint": "输入后按回车或逗号添加",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "页面访问密码",
"admin.pagePasswordHint": "设置访问密码(留空则关闭)",
"admin.pagePasswordHint": "设置访问密码(6–30 位,留空则关闭)",
"admin.askPassword": "提问密码",
"admin.askPasswordHint": "设置提问密码(留空则关闭)",
"admin.askPasswordHint": "设置提问密码(6–30 位,留空则关闭)",
"admin.saveProfile": "保存修改",
"admin.updated": "更新个人信息成功",
"admin.logout": "退出登录",
@@ -194,6 +203,9 @@ var zhCNDict = map[string]string{
"admin.oldPasswordHint": "修改密码使用,留空则不修改",
"admin.newPasswordHint": "6-64 位,留空则不修改",
"admin.notifyEmail": "邮件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空则不发送)",
"admin.notifyTelegramHint": "向 @userinfobot 发送任意消息即可获得你的 Chat ID。需站方配置了 Telegram Bot。",
"admin.useCustomFg": "启用自定义前景色(否则自动匹配)",
"admin.customFgHint": "仅在启用时生效,否则颜色由系统自动生成",
"admin.avatarPlaceholder": "上传个人头像(头像比例不为1:1就扁了你哦)",
@@ -298,6 +310,9 @@ var zhCNDict = map[string]string{
"qlist.unpinShort": "取消置顶",
"qlist.myVotes": "我创建的投票",
"qlist.loadMore": "加载更多",
"qlist.noMore": "无更多留言",
"qlist.loading": "加载中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票",
"qlist.edit": "编辑",
"qlist.untitledVote": "未命名投票",
@@ -619,6 +634,7 @@ var zhCNDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已参与过该投票,感谢参与!",
"vote.page.resultsTitle": "投票结果",
"vote.page.anonymousNotice": "匿名投票,每人一票,结果仅供参考。",
"vote.page.totalVotes": "总参与",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -628,6 +644,7 @@ var zhCNDict = map[string]string{
"vote.page.rankHint": "请为每个选项填写 1-N 的排名(数字越小越靠前,不可重复)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多选",
"vote.page.multipleHintWithMax": "可多选(最多 %d 项)",
"vote.page.customOption": "补充选项(可选)",
"vote.page.customPlaceholder": "填写你心中的其他选项",
"vote.page.customOther": "其他",
@@ -757,6 +774,7 @@ var zhCNDict = map[string]string{
// 提问箱页:访客可见(P0)
"question.errWrongPassword": "密码错误",
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
"question.errAskPasswordWrong": "提问密码错误",
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
"question.errSelfChat": "不能给自己发起私聊",
@@ -838,6 +856,8 @@ var zhCNDict = map[string]string{
"vote.err.fieldSerialize": "字段选项序列化失败",
"vote.err.editClosed": "已结束或归档的投票不允许编辑",
"vote.err.publishDraftOnly": "只有草稿状态的投票可以发布",
"vote.err.restrictedPublish": "当前处于平台处置期,已暂停发布投票",
"feature.stateUnavailable": "暂时无法确认该功能的状态,请稍后重试",
"vote.err.closePublishedOnly": "只有进行中的投票可以结束",
"vote.err.publishTitleRequired": "请先填写投票标题再发布",
"vote.err.publishFormFieldsRequired": "表单类型投票至少需要 1 个字段",
@@ -915,6 +935,12 @@ var zhCNDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "图片尺寸过大,最大支持 %d×%d 像素,请压缩后重新上传",
"internal.errFlash": "服务内部错误,请稍后重试。",
"internal.errBadRequest": "请求参数不正确",
"error.notFoundTitle": "页面不存在",
"error.notFoundDesc": "这个地址没有内容,可能是链接输错了,或者内容已经被删除。",
"error.internalTitle": "服务出了点问题",
"error.internalDesc": "这次请求没能完成,通常是临时的。稍后重试一般就好了。",
"error.traceHint": "反馈时请带上这个编号:",
"internal.errServer": "服务器内部错误",
"home.errStats": "获取统计信息失败",
"pixel.errPath": "非法的请求路径",
@@ -938,6 +964,7 @@ var zhCNDict = map[string]string{
"form.profile.name": "昵称不能为空,且不超过 20 个字符",
"form.profile.newPassword": "新密码长度不能超过 30 个字符",
"form.profile.passwordMax": "密码长度不能超过 30 个字符",
"form.profile.passwordLen": "请设置 6 至 30 位的密码",
"form.question.content": "问题内容不能为空",
"form.question.contentTooLong": "问题内容不能超过 %d 个字符",
"form.question.receiveReplyEmail": "请填写有效的电子邮箱地址以接收回复",
@@ -1030,6 +1057,50 @@ var zhCNDict = map[string]string{
"stats.weekday.6": "周六",
"stats.weekday.7": "周日",
// 箱主数据面板(/user/insights)
"insights.title": "数据面板",
"insights.heading": "你的数据面板",
"insights.lead": "这些数字只属于你自己:有多少人打开过你的提问箱、你收到了多少提问、还有多少没处理。全部是聚合结果,不含任何一位访客或提问者的身份。",
"insights.back": "返回设置",
"insights.entryTitle": "数据面板",
"insights.entryDesc": "看看有多少人来过你的提问箱、提问处理得怎么样",
"insights.summary": "一眼看懂",
"insights.noViews": "还没有访客记录。有人打开过你的提问箱之后,这里就会开始有数字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提问",
"insights.pv": "次浏览",
"insights.uv": "位访客",
"insights.trendTitle": "近 30 天浏览趋势",
"insights.trendNote": "每根柱子是一天,浅色是浏览量,深色是当天去重后的访客数。把鼠标停在柱子上能看到当天的浏览 / 访客 / 提问。",
"insights.legendPV": "浏览量(PV)",
"insights.legendUV": "独立访客(按日去重)",
"insights.legendHover": "悬停查看当天明细",
"insights.hourTitle": "大家什么时候来",
"insights.hourNote": "近 30 天收到的提问分布在一天里的哪个时段,最亮的一根是你最忙的时段。",
"insights.funnelTitle": "提问处理情况",
"insights.funnelNote": "全部提问按「已回答 / 已读未答 / 未读未答」三类拆开。",
"insights.funnelTotal": "全部提问",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已读未答",
"insights.funnelUnread": "未读未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提问",
"insights.privacyTitle": "关于这些数字",
"insights.privacyNote": "访客统计只记录「某天有人来过几次」。访客身份是每天更换盐值的 IP 哈希,不保存 IP、不保存 UA、也不保存访问了哪个页面。因此跨天的同一个人无法被关联,近 7 天 / 30 天的访客数是各天去重后相加 —— 同一个人连续几天来访会被记成几个人。箱主自己打开自己的提问箱不计入统计。",
"insights.insight.empty": "还没有任何数据。等你收到第一条提问,或者有人打开过你的提问箱,这里就会有变化。",
"insights.insight.weekViews": "近 7 天有 %s 次浏览,%s 位访客。",
"insights.insight.conversion": "近 30 天的访客中,约有 %s%% 留下了提问。",
"insights.insight.bestDay": "最热闹的一天是 %s,有 %s 次浏览。",
"insights.insight.peakHour": "提问最集中在 %d 点前后。",
"insights.insight.answerRate": "你已经回答了 %s%% 的提问。",
"insights.insight.fastReply": "已回答的提问里,%s%% 是在 24 小时内回复的。",
"insights.insight.unread": "还有 %s 条提问没处理。",
"insights.insight.trendUp": "本周浏览比上周多了 %s%%。",
"insights.insight.trendDown": "本周浏览比上周少了 %s%%。",
"insights.insight.trendFlat": "本周浏览与上周差不多。",
"stats.foot.updated": "快照时间:",
"stats.foot.prevMonth": "最后一条提问:",
"stats.foot.prevAnswer": "最后一条回答:",
+73 -2
View File
@@ -21,6 +21,7 @@ var zhTWDict = map[string]string{
"common.submit": "送出",
"common.cancel": "取消",
"common.save": "儲存",
"common.tooManyRequests": "請求過於頻繁,請稍後再試。",
"common.close": "關閉",
"common.delete": "刪除",
"common.moveUp": "上移",
@@ -112,6 +113,14 @@ var zhTWDict = map[string]string{
"ask.chatDisabled": "該提問箱未開啟私聊",
"ask.chatLimit": "訊息上限:%d則",
"ask.history": "@%s 以前回答過的問題",
"askHistory.banner": "這台裝置上有 {n} 筆提問紀錄",
"askHistory.answeredSuffix": ",其中 {m} 筆已有回答",
"askHistory.expand": "查看",
"askHistory.collapse": "收合",
"askHistory.answered": "已有回答",
"askHistory.pending": "等待回答",
"askHistory.open": "開啟",
"askHistory.note": "紀錄只保存在這台裝置的瀏覽器裡,換裝置或清除瀏覽器資料後會遺失。",
"ask.loginRequired": "需要登入",
"ask.loginRequiredChat": "發起私聊需要登入帳號",
"ask.goLogin": "去登入",
@@ -182,9 +191,9 @@ var zhTWDict = map[string]string{
"admin.tagInputHint": "輸入後按 Enter 或逗號新增",
"admin.tagInputDuplicate": "已存在,已忽略",
"admin.pagePassword": "頁面訪問密碼",
"admin.pagePasswordHint": "設定訪問密碼(留空則關閉)",
"admin.pagePasswordHint": "設定訪問密碼(6–30 位,留空則關閉)",
"admin.askPassword": "提問密碼",
"admin.askPasswordHint": "設定提問密碼(留空則關閉)",
"admin.askPasswordHint": "設定提問密碼(6–30 位,留空則關閉)",
"admin.saveProfile": "儲存修改",
"admin.updated": "更新個人資訊成功",
"admin.logout": "登出",
@@ -194,6 +203,9 @@ var zhTWDict = map[string]string{
"admin.oldPasswordHint": "修改密碼使用,留空則不修改",
"admin.newPasswordHint": "6-64 位,留空則不修改",
"admin.notifyEmail": "郵件",
"admin.notifyTelegram": "Telegram 通知",
"admin.notifyTelegramPlaceholder": "Telegram Chat ID(留空則不發送)",
"admin.notifyTelegramHint": "向 @userinfobot 發送任意訊息即可取得你的 Chat ID。需站方配置了 Telegram Bot。",
"admin.useCustomFg": "啟用自訂前景色(否則自動比對)",
"admin.customFgHint": "僅在啟用時生效,否則顏色由系統自動產生",
"admin.avatarPlaceholder": "上傳個人頭像(頭像比例不為1:1就扁了你哦)",
@@ -298,6 +310,9 @@ var zhTWDict = map[string]string{
"qlist.unpinShort": "取消置頂",
"qlist.myVotes": "我建立的投票",
"qlist.loadMore": "載入更多",
"qlist.noMore": "無更多留言",
"qlist.loading": "載入中...",
"qlist.viewAnswer": "查看回答",
"qlist.newVote": "新建投票",
"qlist.edit": "編輯",
"qlist.untitledVote": "未命名投票",
@@ -618,6 +633,7 @@ var zhTWDict = map[string]string{
"vote.page.submit": "提交投票",
"vote.page.alreadyVoted": "你已參與過該投票,感謝參與!",
"vote.page.resultsTitle": "投票結果",
"vote.page.anonymousNotice": "匿名投票,每人一票,結果僅供參考。",
"vote.page.totalVotes": "總參與",
"vote.page.avgRating": "平均分",
"vote.page.avgRank": "平均排名",
@@ -627,6 +643,7 @@ var zhTWDict = map[string]string{
"vote.page.rankHint": "請為每個選項填寫 1-N 的排名(數字越小越靠前,不可重複)",
"vote.page.rankPlaceholder": "排名",
"vote.page.multipleHint": "可多選",
"vote.page.multipleHintWithMax": "可多選(最多 %d 項)",
"vote.page.customOption": "補充選項(可選)",
"vote.page.customPlaceholder": "填寫你心中的其他選項",
"vote.page.customOther": "其他",
@@ -756,6 +773,7 @@ var zhTWDict = map[string]string{
// 提問箱頁:訪客可見(P0)
"question.errWrongPassword": "密碼錯誤",
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
"question.errAskPasswordWrong": "提問密碼錯誤",
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
"question.errSelfChat": "不能給自己發起私聊",
@@ -837,6 +855,8 @@ var zhTWDict = map[string]string{
"vote.err.fieldSerialize": "欄位選項序列化失敗",
"vote.err.editClosed": "已結束或歸檔的投票不允許編輯",
"vote.err.publishDraftOnly": "只有草稿狀態的投票可以發布",
"vote.err.restrictedPublish": "當前處於平台處置期,已暫停發布投票",
"feature.stateUnavailable": "暫時無法確認該功能的狀態,請稍後重試",
"vote.err.closePublishedOnly": "只有進行中的投票可以結束",
"vote.err.publishTitleRequired": "請先填寫投票標題再發布",
"vote.err.publishFormFieldsRequired": "表單類型投票至少需要 1 個欄位",
@@ -902,6 +922,12 @@ var zhTWDict = map[string]string{
"chat.flash.operated": "操作成功",
"image.errTooLarge": "圖片尺寸過大,最大支援 %d×%d 像素,請壓縮後重新上傳",
"internal.errFlash": "服務內部錯誤,請稍後重試。",
"internal.errBadRequest": "請求參數不正確",
"error.notFoundTitle": "頁面不存在",
"error.notFoundDesc": "這個位址沒有內容,可能是連結輸入錯誤,或者內容已經被刪除。",
"error.internalTitle": "服務出了點問題",
"error.internalDesc": "這次請求沒能完成,通常是暫時的。稍後重試一般就好了。",
"error.traceHint": "回報時請帶上這個編號:",
"internal.errServer": "伺服器內部錯誤",
"home.errStats": "獲取統計資訊失敗",
"pixel.errPath": "非法的請求路徑",
@@ -925,6 +951,7 @@ var zhTWDict = map[string]string{
"form.profile.name": "暱稱不能為空,且不超過 20 個字元",
"form.profile.newPassword": "新密碼長度不能超過 30 個字元",
"form.profile.passwordMax": "密碼長度不能超過 30 個字元",
"form.profile.passwordLen": "請設定 6 至 30 位的密碼",
"form.question.content": "問題內容不能為空",
"form.question.contentTooLong": "問題內容不能超過 %d 個字元",
"form.question.receiveReplyEmail": "請填寫有效的電子郵件地址以接收回覆",
@@ -1029,6 +1056,50 @@ var zhTWDict = map[string]string{
"stats.weekday.6": "週六",
"stats.weekday.7": "週日",
// 箱主數據面板(/user/insights)
"insights.title": "數據面板",
"insights.heading": "你的數據面板",
"insights.lead": "這些數字只屬於你自己:有多少人開啟過你的提問箱、你收到了多少提問、還有多少沒處理。全部是彙總結果,不含任何一位訪客或提問者的身分。",
"insights.back": "返回設定",
"insights.entryTitle": "數據面板",
"insights.entryDesc": "看看有多少人來過你的提問箱、提問處理得怎麼樣",
"insights.summary": "一眼看懂",
"insights.noViews": "還沒有訪客紀錄。有人開啟過你的提問箱之後,這裡就會開始有數字。",
"insights.today": "今天",
"insights.week": "近 7 天",
"insights.month": "近 30 天",
"insights.newQuestions": "新提問",
"insights.pv": "次瀏覽",
"insights.uv": "位訪客",
"insights.trendTitle": "近 30 天瀏覽趨勢",
"insights.trendNote": "每根柱子是一天,淺色是瀏覽量,深色是當天去重後的訪客數。把游標停在柱子上能看到當天的瀏覽/訪客/提問。",
"insights.legendPV": "瀏覽量(PV)",
"insights.legendUV": "獨立訪客(按日去重)",
"insights.legendHover": "停留查看當天明細",
"insights.hourTitle": "大家什麼時候來",
"insights.hourNote": "近 30 天收到的提問分布在一天裡的哪個時段,最亮的一根是你最忙的時段。",
"insights.funnelTitle": "提問處理情況",
"insights.funnelNote": "全部提問按「已回答/已讀未答/未讀未答」三類拆開。",
"insights.funnelTotal": "全部提問",
"insights.funnelAnswered": "已回答",
"insights.funnelReadOnly": "已讀未答",
"insights.funnelUnread": "未讀未答",
"insights.answerRate": "回答率:已回答 ÷ 全部提問",
"insights.privacyTitle": "關於這些數字",
"insights.privacyNote": "訪客統計只記錄「某天有人來過幾次」。訪客身分是每天更換鹽值的 IP 雜湊,不保存 IP、不保存 UA、也不保存造訪了哪個頁面。因此跨天的同一個人無法被關聯,近 7 天/30 天的訪客數是各天去重後相加 —— 同一個人連續幾天造訪會被記成幾個人。箱主自己開啟自己的提問箱不計入統計。",
"insights.insight.empty": "還沒有任何資料。等你收到第一條提問,或有人開啟過你的提問箱,這裡就會有變化。",
"insights.insight.weekViews": "近 7 天有 %s 次瀏覽,%s 位訪客。",
"insights.insight.conversion": "近 30 天的訪客中,約有 %s%% 留下了提問。",
"insights.insight.bestDay": "最熱鬧的一天是 %s,有 %s 次瀏覽。",
"insights.insight.peakHour": "提問最集中在 %d 點前後。",
"insights.insight.answerRate": "你已經回答了 %s%% 的提問。",
"insights.insight.fastReply": "已回答的提問裡,%s%% 是在 24 小時內回覆的。",
"insights.insight.unread": "還有 %s 條提問沒處理。",
"insights.insight.trendUp": "本週瀏覽比上週多了 %s%%。",
"insights.insight.trendDown": "本週瀏覽比上週少了 %s%%。",
"insights.insight.trendFlat": "本週瀏覽與上週差不多。",
"stats.foot.updated": "快照時間:",
"stats.foot.prevMonth": "最後一則提問:",
"stats.foot.prevAnswer": "最後一則回答:",
+438
View File
@@ -0,0 +1,438 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Package insights 为箱主生成「自己的数据面板」。
//
// 与 internal/stats 的分工:stats 是全站公开页(任何人都能看、不含单箱维度、
// 结果按自然日缓存);本包只看当前登录用户自己的箱,数据实时,只对箱主可见。
package insights
import (
"context"
"fmt"
"math"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
)
const (
// TrendDays 趋势窗口天数(含今天)。
TrendDays = 30
// WeekDays「本周」窗口与「上周」对比窗口的长度。
WeekDays = 7
)
// Page 是箱主数据面板的渲染模型。
type Page struct {
Lang string
// HasViews 表示是否已经有任何一条访问记录。新箱子的面板会显示引导文案,
// 而不是一排 0 —— 后者看起来像统计坏了。
HasViews bool
Since time.Time
Today Window
Week Window
Month Window
All Window
Days []Day
// Hours 24 小时来访分布(近 30 天的浏览)。
Hours [24]Hour
PeakHour int
PeakHourN int64
Funnel *db.BoxFunnel
// 派生指标(百分比,0~100)。
Conversion float64 // 近 30 天「提问数 / UV」
AnswerRate float64 // 全期回答率
FastReply float64 // 全期 24 小时内回答占比
BestDay *Day
// AnswerRateText 是 AnswerRate 的展示形式:整数不带小数点(100%,而不是 100.0%)。
AnswerRateText string
// Conclusions 自动生成的一两句话结论,已本地化。
Conclusions []string
}
// Window 一段时间内的汇总。
type Window struct {
PV int64
UV int64
Questions int64
Answers int64
// PrevPV / HasPrev 用于跟上一个等长周期比较(只有 Week 有值)。
PrevPV int64
HasPrev bool
// Delta 相对 PrevPV 的变化百分比。PrevPV 为 0 时无意义,用 HasDelta 标记。
Delta float64
HasDelta bool
}
// Day 趋势图里的一天。
type Day struct {
Key string // 2026-10-29
Label string // 10-29
Weekday string
PV int64
UV int64
Questions int64
Answers int64
// 高度百分比(0~100),直接当 CSS 高度用。
PVHeight float64
UVHeight float64
QHeight float64
IsToday bool
IsEmpty bool
}
// Hour 时段分布里的一个柱子。
type Hour struct {
Hour int
N int64
Height float64
}
// Build 装配箱主 userID 的数据面板。
//
// 任何一个子查询失败都只让对应区块缺数据,不返回错误:面板是「看个大概」的页面,
// 因为一条聚合 SQL 挂了就整页 500,对箱主没有任何帮助。
func Build(ctx context.Context, lang string, userID uint, now time.Time) *Page {
p := &Page{Lang: lang, Funnel: &db.BoxFunnel{}}
today := dayOnly(now)
monthFrom := today.AddDate(0, 0, -(TrendDays - 1))
weekFrom := today.AddDate(0, 0, -(WeekDays - 1))
prevWeekFrom := today.AddDate(0, 0, -(2*WeekDays - 1))
prevWeekTo := today.AddDate(0, 0, -WeekDays)
// 先无条件铺好 30 天空骨架:访问统计和提问统计任何一侧缺数据,
// 趋势图都还能画出坐标轴与空柱,而不是整块消失。
p.buildDays(nil, today)
if db.BoxViews != nil {
days, err := db.BoxViews.Daily(ctx, userID, monthFrom, today)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主访问趋势失败")
} else {
p.fillViews(days)
}
if pv, uv, err := db.BoxViews.Totals(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主累计访问失败")
} else {
p.All.PV, p.All.UV = pv, uv
p.HasViews = pv > 0
}
if since, err := db.BoxViews.FirstDay(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主首次访问时间失败")
} else {
p.Since = since
}
}
if db.BoxAnalytics != nil {
if trend, err := db.BoxAnalytics.BoxTrend(ctx, userID, monthFrom, today); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问趋势失败")
} else {
p.fillQuestions(trend)
}
if hours, err := db.BoxAnalytics.BoxHours(ctx, userID, monthFrom); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主时段分布失败")
} else {
p.buildHours(hours)
}
if funnel, err := db.BoxAnalytics.BoxFunnel(ctx, userID); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("加载箱主提问漏斗失败")
} else {
p.Funnel = funnel
}
}
p.aggregateWindows(today, weekFrom, prevWeekFrom, prevWeekTo)
p.computeRates()
return p
}
// dayOnly 截断到当天 0 点,保留传入时区。
func dayOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// buildDays 铺出完整的 30 天序列(不含任何计数)。
func (p *Page) buildDays(_ []db.BoxViewDay, today time.Time) {
from := today.AddDate(0, 0, -(TrendDays - 1))
p.Days = make([]Day, 0, TrendDays)
for i := 0; i < TrendDays; i++ {
d := from.AddDate(0, 0, i)
p.Days = append(p.Days, Day{
Key: d.Format("2006-01-02"),
Label: d.Format("01-02"),
Weekday: fmt.Sprintf("stats.wd.%d", int(d.Weekday())),
IsToday: sameDay(d, today),
IsEmpty: true,
})
}
}
// fillViews 把访问量填进已有骨架。
func (p *Page) fillViews(rows []db.BoxViewDay) {
index := make(map[string]int, len(p.Days))
for i, d := range p.Days {
index[d.Key] = i
}
for _, r := range rows {
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
p.Days[i].PV = r.PV
p.Days[i].UV = r.UV
p.Days[i].IsEmpty = r.PV == 0 && p.Days[i].Questions == 0
}
}
p.recomputeHeights()
}
// fillQuestions 把提问 / 回答数填进已有骨架。
func (p *Page) fillQuestions(rows []db.BoxDayCount) {
index := make(map[string]int, len(p.Days))
for i, d := range p.Days {
index[d.Key] = i
}
for _, r := range rows {
if i, ok := index[r.Day.Format("2006-01-02")]; ok {
p.Days[i].Questions = r.Questions
p.Days[i].Answers = r.Answers
p.Days[i].IsEmpty = p.Days[i].PV == 0 && r.Questions == 0
}
}
p.recomputeHeights()
}
// recomputeHeights 按窗口内最大值归一化成百分比高度。
//
// 最大值为 0 时全部置 0(而不是除零),模板会把柱高 0 渲染成一条淡淡的底线。
func (p *Page) recomputeHeights() {
var maxPV, maxQ int64
for _, d := range p.Days {
if d.PV > maxPV {
maxPV = d.PV
}
if d.UV > maxPV {
maxPV = d.UV
}
if d.Questions > maxQ {
maxQ = d.Questions
}
}
for i := range p.Days {
d := &p.Days[i]
if maxPV > 0 {
d.PVHeight = pct(d.PV, maxPV)
d.UVHeight = pct(d.UV, maxPV)
}
if maxQ > 0 {
d.QHeight = pct(d.Questions, maxQ)
}
}
}
func (p *Page) buildHours(counts [24]int64) {
var max int64
for _, n := range counts {
if n > max {
max = n
}
}
for h := 0; h < 24; h++ {
p.Hours[h] = Hour{Hour: h, N: counts[h]}
if max > 0 {
p.Hours[h].Height = pct(counts[h], max)
}
if counts[h] > p.PeakHourN {
p.PeakHourN = counts[h]
p.PeakHour = h
}
}
}
// aggregateWindows 从日序列切出今日 / 本周 / 本月窗口,并算上周对比。
func (p *Page) aggregateWindows(today, weekFrom, prevFrom, prevTo time.Time) {
for _, d := range p.Days {
t, err := time.ParseInLocation("2006-01-02", d.Key, today.Location())
if err != nil {
continue
}
p.Month.PV += d.PV
p.Month.UV += d.UV
p.Month.Questions += d.Questions
p.Month.Answers += d.Answers
if !t.Before(weekFrom) {
p.Week.PV += d.PV
p.Week.UV += d.UV
p.Week.Questions += d.Questions
p.Week.Answers += d.Answers
}
if !t.Before(prevFrom) && !t.After(prevTo) {
p.Week.PrevPV += d.PV
}
if sameDay(t, today) {
p.Today.PV = d.PV
p.Today.UV = d.UV
p.Today.Questions = d.Questions
p.Today.Answers = d.Answers
}
if p.BestDay == nil || d.PV > p.BestDay.PV {
cp := d
p.BestDay = &cp
}
}
// 全期提问 / 回答来自漏斗(它扫的是整张表,不受 30 天窗口限制)。
if p.Funnel != nil {
p.All.Questions = p.Funnel.Total
p.All.Answers = p.Funnel.Answered
}
p.Week.HasPrev = true
if p.Week.PrevPV > 0 {
p.Week.HasDelta = true
p.Week.Delta = float64(p.Week.PV-p.Week.PrevPV) / float64(p.Week.PrevPV) * 100
}
// 上周一次访问都没有时,「涨了 100%」这类结论毫无意义 —— 直接不显示对比。
}
func (p *Page) computeRates() {
if p.Month.UV > 0 {
p.Conversion = float64(p.Month.Questions) / float64(p.Month.UV) * 100
}
if p.Funnel != nil && p.Funnel.Total > 0 {
p.AnswerRate = float64(p.Funnel.Answered) / float64(p.Funnel.Total) * 100
if p.Funnel.Answered > 0 {
p.FastReply = float64(p.Funnel.RepliedIn24h) / float64(p.Funnel.Answered) * 100
}
}
p.AnswerRateText = formatPct(p.AnswerRate)
}
// formatPct 把百分比渲染成「100%」或「33.3%」——整数值不补 .0。
//
// 面板上「100.0%」这种写法会让人下意识去找那个小数位,而它并不携带信息。
func formatPct(v float64) string {
if v == math.Trunc(v) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%.1f", v)
}
// pct 返回 n 相对 max 的百分比,保留一位小数。
func pct(n, max int64) float64 {
if max <= 0 {
return 0
}
v := float64(n) / float64(max) * 100
// 四舍五入到 0.1,避免出现一长串小数把 CSS 撑得很长。
return float64(int(v*10+0.5)) / 10
}
func sameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
// formatDay 把日期格式化成「10-29」这种短标签,供文案使用。
func formatDay(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("01-02")
}
// FormatInsights 生成面板顶部的自动结论。
//
// 放在这里而不是模板里:结论需要做「有没有数据」「除零」这类判断,
// 全塞进模板会让它变成一堆 eq/if 的嵌套。
func (p *Page) FormatInsights() {
lang := p.Lang
add := func(key string, args ...interface{}) {
text := i18n.T(lang, key, args...)
// 缺词条时 T 会返回 key 本身,别把内部 key 印到页面上。
if text == "" || text == key {
return
}
p.Conclusions = append(p.Conclusions, text)
}
if !p.HasViews && p.Funnel.Total == 0 {
add("insights.insight.empty")
return
}
if p.HasViews {
add("insights.insight.weekViews", formatInt(p.Week.PV), formatInt(p.Week.UV))
}
// 样本太小的时候「转化率」没有解释力:1 位访客提了 1 个问题就是 100%,
// 这种数字只会误导箱主,不如不说。
if p.Month.UV >= 5 && p.Month.Questions > 0 {
add("insights.insight.conversion", formatPct(p.Conversion))
}
if p.BestDay != nil && p.BestDay.PV > 0 {
add("insights.insight.bestDay", p.BestDay.Label, formatInt(p.BestDay.PV))
}
if p.PeakHourN > 0 {
add("insights.insight.peakHour", p.PeakHour)
}
if p.Funnel.Total > 0 {
add("insights.insight.answerRate", formatPct(p.AnswerRate))
}
if p.FastReply > 0 {
add("insights.insight.fastReply", fmt.Sprintf("%.0f", p.FastReply))
}
if p.Funnel.Unread > 0 {
add("insights.insight.unread", formatInt(p.Funnel.Unread))
}
if p.Week.HasDelta {
switch {
case p.Week.Delta >= 5:
add("insights.insight.trendUp", fmt.Sprintf("%.0f", p.Week.Delta))
case p.Week.Delta <= -5:
add("insights.insight.trendDown", fmt.Sprintf("%.0f", -p.Week.Delta))
default:
add("insights.insight.trendFlat")
}
}
}
// formatInt 给数字加千分位。
func formatInt(n int64) string {
if n < 0 {
return "-" + formatInt(-n)
}
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package insights
import (
"net/http/httptest"
"testing"
"time"
)
func TestPct(t *testing.T) {
cases := []struct {
n, max int64
want float64
}{
{0, 0, 0}, // 除零:不能算出 NaN 塞进 CSS
{5, 0, 0}, // 同上
{10, 10, 100}, // 满格
{1, 3, 33.3}, // 四舍五入到一位小数
{2, 3, 66.7},
{1, 1000, 0.1},
}
for _, c := range cases {
if got := pct(c.n, c.max); got != c.want {
t.Errorf("pct(%d, %d) = %v, want %v", c.n, c.max, got, c.want)
}
}
}
func TestFormatInt(t *testing.T) {
cases := map[int64]string{
0: "0",
7: "7",
999: "999",
1000: "1,000",
12345: "12,345",
1234567: "1,234,567",
-1234: "-1,234",
}
for in, want := range cases {
if got := formatInt(in); got != want {
t.Errorf("formatInt(%d) = %q, want %q", in, got, want)
}
}
}
// 窗口切分是「今日 / 近 7 天 / 近 30 天」与「本周 vs 上周」的唯一实现,
// 算错不会报错、只会安静地显示错误的数字,所以单独测。
func TestAggregateWindows(t *testing.T) {
loc := time.Local
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23
prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16
prevTo := today.AddDate(0, 0, -WeekDays) // 10-22
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays-1)+i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
set := func(offsetDays int, pv, uv, q, a int64) {
key := today.AddDate(0, 0, offsetDays).Format("2006-01-02")
for i := range p.Days {
if p.Days[i].Key == key {
p.Days[i].PV, p.Days[i].UV = pv, uv
p.Days[i].Questions, p.Days[i].Answers = q, a
return
}
}
t.Fatalf("日期 %s 不在骨架里", key)
}
set(0, 5, 3, 1, 0) // 今天:本周 + 今日
set(-1, 2, 2, 0, 1) // 昨天:本周
set(-6, 4, 1, 2, 0) // 10-23:本周起点
set(-7, 9, 6, 0, 0) // 10-22:上周末日
set(-13, 6, 5, 0, 0) // 10-16:上周起点
p.aggregateWindows(today, weekFrom, prevFrom, prevTo)
// 本周 = 5+2+4;上周 = 9+6
if p.Week.PV != 11 {
t.Errorf("Week.PV = %d, want 11", p.Week.PV)
}
if p.Week.PrevPV != 15 {
t.Errorf("Week.PrevPV = %d, want 15", p.Week.PrevPV)
}
if !p.Week.HasDelta {
t.Fatal("上周有数据时 HasDelta 应为 true")
}
if got, want := p.Week.Delta, float64(11-15)/15*100; got != want {
t.Errorf("Week.Delta = %v, want %v", got, want)
}
if p.Today.PV != 5 || p.Today.UV != 3 {
t.Errorf("Today = %+v, want PV 5 UV 3", p.Today)
}
if p.Month.PV != 26 {
t.Errorf("Month.PV = %d, want 26(全部 5 天之和)", p.Month.PV)
}
if p.BestDay == nil || p.BestDay.PV != 9 {
t.Errorf("BestDay = %+v, want 10-22 的 9 次", p.BestDay)
}
}
// 上周一次访问都没有时不能给出「涨了 100%」这类结论。
func TestAggregateWindowsNoPrev(t *testing.T) {
loc := time.Local
today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc)
p := &Page{}
for i := 0; i < TrendDays; i++ {
d := today.AddDate(0, 0, -(TrendDays-1)+i)
p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")})
}
p.Days[len(p.Days)-1].PV = 3
p.aggregateWindows(today, today.AddDate(0, 0, -6), today.AddDate(0, 0, -13), today.AddDate(0, 0, -7))
if p.Week.HasDelta {
t.Error("上周 PV 为 0 时 HasDelta 应为 false")
}
}
func TestSameDay(t *testing.T) {
a := time.Date(2026, 10, 29, 0, 0, 0, 0, time.Local)
b := time.Date(2026, 10, 29, 23, 59, 59, 0, time.Local)
c := time.Date(2026, 10, 30, 0, 0, 1, 0, time.Local)
if !sameDay(a, b) {
t.Error("同一天的任意时刻应判为同一天")
}
if sameDay(a, c) {
t.Error("跨天应判为不同天")
}
}
// 访客标识必须「同日可去重、跨日不可关联」:这是面板隐私承诺的技术基础。
func TestVisitorID(t *testing.T) {
r := NewRecorder(nil, "test-secret") // 不启动写入,仅用哈希方法
defer r.Close()
day1 := time.Date(2026, 10, 29, 8, 0, 0, 0, time.Local)
day1Later := time.Date(2026, 10, 29, 22, 0, 0, 0, time.Local)
day2 := time.Date(2026, 10, 30, 8, 0, 0, 0, time.Local)
a := r.visitorID("1.2.3.4", day1)
if a != r.visitorID("1.2.3.4", day1Later) {
t.Error("同一天同一 IP 必须得到相同标识,否则 UV 去重失效")
}
if a == r.visitorID("1.2.3.4", day2) {
t.Error("跨天必须得到不同标识,否则可长期追踪同一访客")
}
if a == r.visitorID("5.6.7.8", day1) {
t.Error("不同 IP 同一天必须不同")
}
if len(a) != 32 {
t.Errorf("标识长度 = %d, want 32", len(a))
}
// 换密钥后整体不可关联(运维轮换 xsrf_key 时历史哈希不会被重新对上)
other := NewRecorder(nil, "another-secret")
defer other.Close()
if a == other.visitorID("1.2.3.4", day1) {
t.Error("不同密钥应给出不同标识")
}
}
func TestIsCrawler(t *testing.T) {
cases := map[string]bool{
"": true, // 空 UA 基本是脚本
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false,
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true,
"curl/8.4.0": true,
"python-requests/2.31.0": true,
"Mozilla/5.0 (compatible; bingbot/2.0)": true,
"facebookexternalhit/1.1": true,
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false,
}
for ua, want := range cases {
if got := isCrawler(ua); got != want {
t.Errorf("isCrawler(%q) = %v, want %v", ua, got, want)
}
}
}
func TestIsPrefetch(t *testing.T) {
cases := []struct {
name string
header string
value string
want bool
}{
{"普通请求", "", "", false},
{"Chrome 预取", "Sec-Purpose", "prefetch", true},
{"Chrome 预渲染", "Sec-Purpose", "prefetch;prerender", true},
{"Safari 预览", "X-Purpose", "preview", true},
{"Firefox 预取", "X-Moz", "prefetch", true},
}
for _, c := range cases {
req := httptest.NewRequest("GET", "/_/someone", nil)
if c.header != "" {
req.Header.Set(c.header, c.value)
}
if got := isPrefetch(req); got != c.want {
t.Errorf("%s: isPrefetch = %v, want %v", c.name, got, c.want)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package insights
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
)
// Recorder 异步记录箱主页访问。
//
// 为什么不同步写库:箱主页是最高频的读路径,一次页面渲染里已经有 5~6 次查询
// (用户、提问列表、回答数、三个功能开关)。再同步加一条 UPSERT,等于给每个
// 访客多付一次写事务的代价 —— 而统计本来就不需要精确到毫秒,丢掉最后几秒的
// 数据没有任何影响。所以这里是「尽力而为」:投递不阻塞,积压了就丢。
//
// 生命周期:Init 时创建,web 进程退出前 Close 一次(flush 残留)。未初始化时
// 包级变量为 nil,调用方用 RecordView 这个 nil 安全的包装函数。
type Recorder struct {
store db.BoxViewsStore
secret []byte
ch chan viewEvent
done chan struct{}
wg sync.WaitGroup
// 关闭后仍有 handler 在跑(优雅关闭期),此时不能再往已关闭的 channel 发送。
mu sync.RWMutex
closed bool
}
type viewEvent struct {
userID uint
at time.Time
visitor string
}
const (
// 队列长度:突发流量下攒 512 条足够,超出说明数据库已经跟不上了,
// 此时记录本身也不是最重要的东西。
recorderQueueSize = 512
// flush 间隔:2 秒足够让「刷新页面就能看到数字」的体验成立,
// 又能把同一秒内的几十次访问合并成一条 SQL。
recorderFlushInterval = 2 * time.Second
// 单批上限:一条 INSERT 里塞几百行会拉长事务,分批更稳。
recorderBatchSize = 200
)
// NewRecorder 创建记录器并立刻拉起后台 flush 循环。secret 用于给访客 IP 加盐哈希,
// 通常传 server.xsrf_key。
//
// 为什么在构造函数里就启动 goroutine,而不是让调用方另起一个 Run:
// 否则 Close 与 Run 之间存在竞态 —— 若进程在 Run 还没被调度时就退出,
// wg.Wait 会立刻返回,队列里最后几秒的访问就丢了。这里把启动收进来,
// wg 计数在构造时就加好,时序是确定的。
func NewRecorder(store db.BoxViewsStore, secret string) *Recorder {
r := &Recorder{
store: store,
secret: []byte("boxview:" + secret),
ch: make(chan viewEvent, recorderQueueSize),
done: make(chan struct{}),
}
r.wg.Add(1)
go r.loop()
return r
}
// loop 后台批量写入,直到 Close。
func (r *Recorder) loop() {
defer r.wg.Done()
ticker := time.NewTicker(recorderFlushInterval)
defer ticker.Stop()
batch := make([]viewEvent, 0, recorderBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
r.write(batch)
batch = batch[:0]
}
for {
select {
case ev := <-r.ch:
batch = append(batch, ev)
if len(batch) >= recorderBatchSize {
flush()
}
case <-ticker.C:
flush()
case <-r.done:
// 退出前把 channel 里剩下的取干净,否则优雅关闭期间的访问全丢。
for {
select {
case ev := <-r.ch:
batch = append(batch, ev)
if len(batch) >= recorderBatchSize {
flush()
}
default:
flush()
return
}
}
}
}
}
// Close 停止接收新事件,flush 残留后返回。幂等,可安全 defer。
func (r *Recorder) Close() {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
close(r.done)
r.mu.Unlock()
r.wg.Wait()
}
func (r *Recorder) write(batch []viewEvent) {
if r.store == nil {
return
}
opts := make([]db.RecordBoxViewOptions, 0, len(batch))
for _, ev := range batch {
opts = append(opts, db.RecordBoxViewOptions{
UserID: ev.userID,
ViewedOn: ev.at,
Visitor: ev.visitor,
})
}
// 用独立 context:请求的 context 在响应写完后就被取消了,
// 拿它写库会得到 "context canceled",统计静默丢失。
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := r.store.RecordBatch(ctx, opts); err != nil {
// 统计写失败不影响任何用户可见行为,只留日志。
logrus.WithError(err).WithField("count", len(opts)).Warn("写入访问统计失败")
}
}
// record 非阻塞投递。队列满时直接丢弃:宁可少记几次,也不能让访客的页面
// 因为统计而变慢。
func (r *Recorder) record(userID uint, at time.Time, visitor string) {
r.mu.RLock()
defer r.mu.RUnlock()
if r.closed {
return
}
select {
case r.ch <- viewEvent{userID: userID, at: at, visitor: visitor}:
default:
}
}
// visitorID 计算「当天」的访客标识:HMAC(secret, 日期 || IP)。
//
// 为什么带日期:不加日期就能长期追踪同一个 IP 的来访轨迹,那已经是一份
// 可关联的行为记录。加日期盐之后,同一天内的重复访问仍能去重(UV 准确),
// 跨天则完全不可关联 —— 代价是「近 7 天 UV」是 7 个日 UV 之和。
//
// 取前 16 字节十六进制 = 32 字符,碰撞概率在个人站量级可以忽略。
func (r *Recorder) visitorID(ip string, at time.Time) string {
mac := hmac.New(sha256.New, r.secret)
mac.Write([]byte(at.Format("2006-01-02")))
mac.Write([]byte{0})
mac.Write([]byte(ip))
return hex.EncodeToString(mac.Sum(nil))[:32]
}
// Views 全局记录器。未初始化(如单测、CLI)时为 nil。
var Views *Recorder
// RecordView 记录一次箱主页访问。参数分别是箱主 ID、客户端 IP、请求。
//
// 会自行过滤掉:箱主本人、明显的爬虫、预取/预渲染请求。这些都不该算进
// 「有多少人来看过我的箱」,否则箱主刷自己的页面就能看到漂亮的数字。
func RecordView(userID uint, ip string, r *http.Request) {
if Views == nil || userID == 0 {
return
}
if isPrefetch(r) || isCrawler(r.UserAgent()) {
return
}
now := time.Now()
Views.record(userID, now, Views.visitorID(ip, now))
}
// isPrefetch 判断这是不是浏览器/链接预览的预取请求。
// 预取会在用户根本没打开页面的情况下命中,算进 UV 会明显虚高。
func isPrefetch(r *http.Request) bool {
if r == nil {
return false
}
for _, h := range []string{"Sec-Purpose", "X-Purpose", "X-Moz"} {
v := strings.ToLower(r.Header.Get(h))
if strings.Contains(v, "prefetch") || strings.Contains(v, "prerender") || strings.Contains(v, "preview") {
return true
}
}
return false
}
// crawlerHints 常见爬虫 UA 片段。
//
// 只做粗筛,不追求完整:目标是不让搜索引擎与监控探针把 UV 抬高。
// 误判成本很低(少记一次),漏判成本也低(多记一次),所以用最简单的子串匹配。
var crawlerHints = []string{
"bot", "spider", "crawler", "crawl", "slurp", "scrapy",
"curl/", "wget/", "python-requests", "python-urllib", "go-http-client",
"httpclient", "okhttp", "axios", "headlesschrome", "phantomjs",
"facebookexternalhit", "telegrambot", "whatsapp", "discordbot",
"monitor", "uptime", "pingdom", "statuscake",
}
func isCrawler(ua string) bool {
if ua == "" {
// 空 UA:绝大多数是脚本。真·浏览器一定带 UA。
return true
}
ua = strings.ToLower(ua)
for _, hint := range crawlerHints {
if strings.Contains(ua, hint) {
return true
}
}
return false
}
// RetentionDays 访问明细的保留天数。
//
// 明细表一行代表「某访客某天来过某箱」,体积随「日 UV × 箱数」增长。
// 面板最长只看 30 天,但「历史最好的一天」要看全期,所以留一年出头:
// 既能让箱主看到长期趋势,又不至于让表无限膨胀。
const RetentionDays = 400
// StartMaintenance 启动每日清理协程。ctx 取消时退出。
//
// 启动时先跑一次:进程停机期间错过的清理在这里补上,不必等满 24 小时。
func StartMaintenance(ctx context.Context, store db.BoxViewsStore) {
go func() {
run := func() {
if store == nil {
return
}
c, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
n, err := store.Purge(c, time.Now().AddDate(0, 0, -RetentionDays))
if err != nil {
logrus.WithError(err).Warn("清理过期访问统计失败")
return
}
if n > 0 {
logrus.WithField("rows", n).Info("已清理过期的访问统计")
}
}
run()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
}
+112
View File
@@ -10,6 +10,7 @@ import (
"embed"
"fmt"
"html/template"
"path/filepath"
"strings"
"time"
@@ -247,12 +248,54 @@ func sendTemplateMail(email, title string, templateFS embed.FS, templatePath str
}
func sendMail(to, title, content string) error {
return sendMailWith(to, title, content, nil, nil)
}
// MailAttachment 邮件附件。
type MailAttachment struct {
Path string
// Name 附件显示名,留空则用文件名。
Name string
}
// MailInlineImage 正文内嵌图片。
//
// CID 是 Content-ID:正文 HTML 里写 <img src="cid:xxx">,邮件客户端就会用
// 附件里的这张图渲染。这是让「地图不依赖任何外链、不依赖 JS」的唯一可靠办法 ——
// 直接放外链图片,Gmail 默认不加载远程图片;放内联 SVG,Gmail/Outlook 会剥掉。
type MailInlineImage struct {
Path string
CID string
}
// sendMailWith 发送一封带内嵌图片与附件的 HTML 邮件。
//
// 图片与附件都从本地路径读取(调用方负责把文件写到临时目录或报告目录)。
// 任何一步失败都向上返回错误:报告这种「发了就该完整」的邮件,
// 静默丢附件比直接报错更糟。
func sendMailWith(to, title, content string, inline []MailInlineImage, atts []MailAttachment) error {
m := gomail.NewMessage()
m.SetHeader("From", fmt.Sprintf("TamaBox <%s>", conf.Mail.Account))
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetBody("text/html", content)
for _, img := range inline {
// 显式指定 Content-ID:gomail 的 Embed 默认会给一个随机值,
// 那样正文里就没法引用它。
m.Embed(img.Path, gomail.SetHeader(map[string][]string{
"Content-ID": {"<" + img.CID + ">"},
"Content-Disposition": {`inline; filename="` + filepath.Base(img.Path) + `"`},
}))
}
for _, a := range atts {
opts := []gomail.FileSetting{}
if a.Name != "" {
opts = append(opts, gomail.Rename(a.Name))
}
m.Attach(a.Path, opts...)
}
d := gomail.NewDialer(
conf.Mail.SMTP,
conf.Mail.Port,
@@ -265,3 +308,72 @@ func sendMail(to, title, content string) error {
}
return d.DialAndSend(m)
}
// SecurityReportMail 周期安全汇总邮件的载荷。
//
// HTML 片段用 template.HTML 传:报告正文里有 KPI 卡片和表格,
// 走 string 会被 html/template 转义成源码文本,收件人看到的是一堆尖括号。
type SecurityReportMail struct {
// To 收件人,由调用方决定(honeypot_report_email / security_alert_email)。
To string
Title string
Period string
Generated string
// MapCID 正文内嵌地图的 Content-ID;为空则不发地图(报告无数据时)。
MapCID string
// SummaryHTML KPI 卡片片段。
SummaryHTML template.HTML
// CountriesHTML 国家排行表片段。
CountriesHTML template.HTML
// Notes 口径说明。用 template.HTML 传:里面有多行 <br/> 结构。
Notes template.HTML
// Attachments 附件(报告 HTML)。
Attachments []MailAttachment
// InlineImages 内嵌图片(地图 PNG),其 CID 必须与 MapCID 一致。
InlineImages []MailInlineImage
}
// securityReportData 是模板的渲染载荷(独立成一个类型,方便单独测渲染)。
type securityReportData struct {
Title string
Period string
Generated string
MapCID string
SummaryHTML template.HTML
CountriesHTML template.HTML
Notes template.HTML
}
// SendSecurityReportMail 发送周期安全汇总邮件。
func SendSecurityReportMail(r SecurityReportMail) error {
content, err := RenderSecurityReportMail(r)
if err != nil {
return err
}
return sendMailWith(r.To, r.Title, content, r.InlineImages, r.Attachments)
}
// RenderSecurityReportMail 只渲染邮件正文,不发信。
//
// 单独拆出来是为了能在没有 SMTP 的环境里验证模板:模板里的字段名写错一个字母,
// 生产上表现为「报告发出去了但正文是空的」,而这封邮件一周才发一次,
// 等发现时已经缺了好几期。
func RenderSecurityReportMail(r SecurityReportMail) (string, error) {
t, err := template.ParseFS(templates.FS, "mail/security-report.html")
if err != nil {
return "", errors.Wrap(err, "parse template file")
}
var content bytes.Buffer
if err := t.Execute(&content, securityReportData{
Title: r.Title,
Period: r.Period,
Generated: r.Generated,
MapCID: r.MapCID,
SummaryHTML: r.SummaryHTML,
CountriesHTML: r.CountriesHTML,
Notes: r.Notes,
}); err != nil {
return "", errors.Wrap(err, "execute template")
}
return content.String(), nil
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package mail
import (
"html/template"
"testing"
"github.com/stretchr/testify/require"
)
// TestRenderSecurityReportMail 钉住报告邮件模板本身。
//
// 这封邮件一周才发一次,模板字段名写错的表现是「发出去了但正文空/漏块」,
// 很难在第一时间发现。这里同时验证两件容易出错的事:
// 1. HTML 片段必须原样插入(走 template.HTML),不能被转义成源码文本;
// 2. 地图走 cid: 引用,邮件客户端才会把它当内嵌图而不是外链。
func TestRenderSecurityReportMail(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{
Title: "2026 年 10 月 第 43 周安全状况周报",
Period: "2026-10-19 ~ 2026-10-25",
Generated: "2026-10-30 16:16",
MapCID: "map-43",
SummaryHTML: template.HTML(`<div class="kpi">命中 <strong>554</strong> 次</div>`),
CountriesHTML: template.HTML(`<table><tr><td>美国</td><td>160</td></tr></table>`),
Notes: template.HTML(`口径一<br/>口径二`),
})
require.NoError(t, err)
// HTML 片段原样出现
require.Contains(t, out, "<strong>554</strong>")
require.Contains(t, out, "<td>美国</td>")
require.Contains(t, out, "口径一<br/>口径二")
require.NotContains(t, out, "&lt;strong&gt;", "片段被转义了,收件人只会看到一堆尖括号")
// 地图按 Content-ID 引用
require.Contains(t, out, "cid:map-43")
// 模板里的变量都应已展开
require.NotContains(t, out, "{{")
require.Contains(t, out, "2026-10-19 ~ 2026-10-25")
}
// TestRenderSecurityReportMailNoMap 没有命中时不发地图,也就不该留一个坏图位。
func TestRenderSecurityReportMailNoMap(t *testing.T) {
out, err := RenderSecurityReportMail(SecurityReportMail{Title: "空报告"})
require.NoError(t, err)
require.NotContains(t, out, "cid:", "无数据时不该引用不存在的地图")
require.NotContains(t, out, "{{")
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Package notify 封装站级通知通道:通用 Webhook 与 Telegram Bot。
//
// 与 internal/mail(点对点邮件)的分工:mail 负责「给某个具体邮箱发」,notify 负责
// 「广播式」通知 —— Webhook 推给一个 URL(Discord/Slack/企业微信机器人等),
// Telegram 推给某个 chat_id(配合箱主自填的 chat_id 实现点对点)。
//
// 设计约束:任一通道发送失败都只记日志、不向上抛错,也绝不影响业务主流程 ——
// 通知是「尽力而为」的旁路,一条通知发不出去不该让提问/回答写不进去。
// 通道未配置(URL/token 为空)时对应发送静默跳过。
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
)
const httpTimeout = 5 * time.Second
// event 一条站级通知事件的载荷。Webhook 与 Telegram 都用它(字段名通用)。
type event struct {
Event string `json:"event"` // 事件名,如 new_question / new_answer / ...
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link,omitempty"`
Time string `json:"time"`
}
// Send 推送一条站级事件到所有已配置的广播通道(Webhook + Telegram)。
// 各通道独立发送、互不影响;失败仅记日志。
func Send(ctx context.Context, eventName, title, body, link string) {
ev := event{
Event: eventName,
Title: title,
Body: body,
Link: link,
Time: time.Now().Format("2006-01-02 15:04:05"),
}
if conf.Notify.WebhookURL != "" {
sendWebhook(ctx, conf.Notify.WebhookURL, ev)
}
// Telegram 站级广播需要一个全局 chat_id;当前设计里 Telegram 是「用户级点对点」
// (见 SendToTelegram),站级不额外广播,避免重复打扰。这里留空即不推。
}
// SendToTelegram 推送一条通知到指定 chat_id(箱主自填,配合站级 bot token)。
// chatID 为空或 bot token 未配置时静默跳过。
func SendToTelegram(ctx context.Context, chatID, text string) {
if chatID == "" || conf.Notify.TelegramBotToken == "" {
return
}
if err := sendTelegram(ctx, conf.Notify.TelegramBotToken, chatID, text); err != nil {
logrus.WithContext(ctx).WithError(err).Warn("Telegram 通知发送失败")
}
}
// sendWebhook POST 一条 JSON 到通用 Webhook URL。
//
// 适配 Discord / Slack 的格式差异:这里发送的是统一的 JSON 对象。Discord 的 webhook
// 期望 {"content": "..."},Slack 期望 {"text": "..."},企业微信期望 {"msgtype":"text",
// "text":{"content":"..."}} —— 无法用一种格式同时适配所有平台。因此这里发送「通用 JSON」,
// 运营方在 webhook 前端(如自己的一层转发)做格式适配;对只接受特定格式的机器人,
// 请用能转发的中间服务(如 Discord 支持自定义 message,Slack incoming webhook 也接受 text 字段)。
//
// 实际上为最大化开箱可用,这里优先兼容 Discord 的 content 字段 + 通用 event 字段并存:
// 既保留结构化信息,又让 Discord 机器人能直接读出人话。
func sendWebhook(ctx context.Context, url string, ev event) {
payload := map[string]interface{}{
"content": fmt.Sprintf("【%s】%s\n%s", ev.Title, ev.Body, ev.Link),
"event": ev.Event,
"title": ev.Title,
"body": ev.Body,
"link": ev.Link,
"time": ev.Time,
}
data, err := json.Marshal(payload)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("序列化 webhook 通知失败")
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("构造 webhook 请求失败")
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("webhook 通知发送失败")
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
logrus.WithContext(ctx).WithField("status", resp.StatusCode).
WithField("resp", string(body)).Warn("webhook 通知返回非 2xx")
}
}
// sendTelegram 通过 Telegram Bot API 发送一条消息到指定 chat_id。
func sendTelegram(ctx context.Context, botToken, chatID, text string) error {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
payload, err := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
})
if err != nil {
return errors.Wrap(err, "marshal telegram payload")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return errors.Wrap(err, "build telegram request")
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "telegram request")
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return errors.Errorf("telegram api status %d: %s", resp.StatusCode, string(body))
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package notify
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// TestSendWebhookPostsJSON 验证 webhook 发送的是带 content/event/title 的 JSON,
// 且能正确 POST 到目标 URL。
func TestSendWebhookPostsJSON(t *testing.T) {
var got map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
sendWebhook(t.Context(), srv.URL, event{
Event: "new_question",
Title: "新的提问",
Body: "你好",
Link: "https://example.com/_/box/1",
Time: "2026-10-29 19:00:00",
})
require.Equal(t, "new_question", got["event"])
require.Equal(t, "新的提问", got["title"])
require.Equal(t, "你好", got["body"])
require.NotEmpty(t, got["content"], "Discord 兼容的 content 字段必须有")
}
// TestSendWebhookSkipsWhenUnconfigured 未配置 webhook_url 时 Send 静默跳过(不 panic)。
func TestSendWebhookSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.WebhookURL = ""
defer func() { conf.Notify.WebhookURL = "" }()
// 不应 panic,也不应发起任何请求。
Send(t.Context(), "new_question", "t", "b", "l")
}
// TestSendToTelegramSkipsWhenUnconfigured chatID 或 bot token 为空时静默跳过。
func TestSendToTelegramSkipsWhenUnconfigured(t *testing.T) {
conf.Notify.TelegramBotToken = ""
defer func() { conf.Notify.TelegramBotToken = "" }()
SendToTelegram(t.Context(), "", "hello") // 空 chatID
SendToTelegram(t.Context(), "123", "hello") // 空 token
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"net/http"
"strings"
"time"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/shared"
)
// accessLogMiddleware 是 flamego.Classic() 内置 Logger() 的替代实现。
//
// 为什么不能用内置的:它记录的是 ctx.Request().RequestURI —— **含 query string**
// ([email protected]/logger.go:36,45)。本站有几个参数绝对不能进日志:
//
// ?t=<token> 私密提问的访问钥匙(route/question/question.go)
// ?code=<code> 一次性改密凭证(route/auth/recover.go)
// ?q=<keyword> 探索页搜索词
//
// 拿到 ?code= 可以直接重置该用户密码;拿到 ?t= 可以读别人发给箱主的私密提问。
// 而日志通常落 journald / out.log / 容器 json-file,凡是能看日志的人
// (外包运维、日志采集平台、Uptime 面板)都能直接利用。
//
// internal/tracing/flamego.go:36-40 早就为同一原因只取 URL.Path 并留了注释,
// 这里是同一个判断,之前只是漏在了框架自带的那层中间件上。
//
// 因此这里**只记 URL.Path**。日后若要按 query 排查,请显式加白名单键
// (tab/sort/page 这类无隐私含义的),不要把 RawQuery 整体加回来。
func accessLogMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
started := time.Now()
// 放在 Recovery 外层:panic 被 Recovery 兜成 500 后,这里仍能记录状态码。
ctx.Next()
status := ctx.ResponseWriter().Status()
if !shouldAccessLog(ctx.Request().URL.Path, status) {
return
}
logrus.WithFields(accessLogFields(ctx.Request().Request, status, time.Since(started))).Info("access")
}
}
// shouldAccessLog 判断这条请求要不要进访问日志。
//
// 静态资源一页几十个请求,成功的不记,否则日志会被淹掉;
// 4xx/5xx 仍然要记 —— 排查「主题图 / 字体 404」靠的就是这些。
func shouldAccessLog(path string, status int) bool {
return !(strings.HasPrefix(path, "/static/") && status < http.StatusBadRequest)
}
// accessLogFields 构造访问日志字段。
//
// 抽成独立函数而不是内联,是为了让「无论请求带什么 query,path 字段都只有 path」
// 这件事能被单测直接断言 —— 这正是之前出问题的地方(框架默认 Logger 记的是 RequestURI)。
func accessLogFields(req *http.Request, status int, elapsed time.Duration) logrus.Fields {
return logrus.Fields{
"method": req.Method,
// 只取 URL.Path。绝不要换成 RequestURI / RawQuery,见文件头注释。
"path": req.URL.Path,
"status": status,
"duration": elapsed.Round(time.Millisecond).String(),
// 客户端 IP 走站内唯一口径 shared.ResolveClientIP(反代下取真实来源,
// 且只在 TCP 对端可信时才采信代理头),与封禁记录用同一套值,
// 出事时能把「日志里这个人」和「ban_records 里这条」对上。
"remote_ip": shared.ResolveClientIP(req),
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"fmt"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestAccessLogNeverRecordsQuery 是本轮修复的回归测试。
//
// 修复前用的是 flamego.Classic(),它自带的 Logger() 记录 RequestURI(含 query),
// 于是 ?t=<私密题钥匙> 与 ?code=<改密凭证> 原样进了日志(本地实测:
// `path=/_/tblocal/4?t=SECRETTOKEN123`)。拿到这些值可以直接读私密提问或重置密码。
//
// 现在要求:不论请求带什么 query,日志里只有 URL.Path。
func TestAccessLogNeverRecordsQuery(t *testing.T) {
cases := []struct {
name string
target string
want string
}{
{"私密提问 token", "/_/alice/42?t=SECRETTOKEN123", "/_/alice/42"},
{"改密凭证", "/recover/reset?code=SECRETCODE456", "/recover/reset"},
{"探索页搜索词", "/explore?q=%E7%A7%98%E5%AF%86&sort=latest", "/explore"},
{"分页参数", "/explore?page=2", "/explore"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest("GET", c.target, nil)
fields := accessLogFields(req, 200, 3*time.Millisecond)
got, ok := fields["path"].(string)
require.True(t, ok, "path 字段必须是字符串")
require.Equal(t, c.want, got)
require.NotContains(t, got, "?", "path 里不允许出现 query 分隔符")
// 兜底:整个字段集合里都不许出现被拦截的凭证
for k, v := range fields {
require.NotContains(t, fmt.Sprint(v), "SECRET",
"字段 %q 泄露了 query 内容:%v", k, v)
}
})
}
}
// TestAccessLogSkipsStaticAssets 保证静态资源的成功请求不进日志(噪音控制),
// 但出错时仍然要记 —— 主题图 / 字体 404 正是靠这条排查的。
func TestAccessLogSkipsStaticAssets(t *testing.T) {
require.False(t, shouldAccessLog("/static/css/base.css", 200))
require.False(t, shouldAccessLog("/static/css/base.css", 304))
require.True(t, shouldAccessLog("/static/css/base.css", 404),
"静态资源 404 必须记录,否则排查不了图裂")
require.True(t, shouldAccessLog("/static/css/base.css", 500))
// 业务路径一律记录
require.True(t, shouldAccessLog("/", 200))
require.True(t, shouldAccessLog("/_/alice", 302))
require.True(t, shouldAccessLog("/staticish", 200), "只有 /static/ 前缀才算静态资源")
}
+66 -38
View File
@@ -191,6 +191,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
return
}
// 纵深防御(与 ConversationPage 同一口径):会话必须属于 URL 里这个箱子的真实属主。
//
// 少了这一步,domain 就只是一张"通行证":拿 A 箱的 domain 拼 B 箱的会话 token,
// 下面所有以 pageUser 为主体的判定都会落到 A 箱主身上 ——
// 被 B 箱主拉黑的提问者换个 domain 就能继续发消息,A 箱主的屏蔽词与日限额被套用到 B 的会话上。
// GET 页面早就有这条校验,POST 漏了。
if conv.OwnerID != pageUser.ID {
ctx.Redirect("/")
return
}
isOwner := ctx.IsLogged && ctx.User.ID == conv.OwnerID
isAsker := ctx.IsLogged && ctx.User.ID == conv.AskerID
if !isOwner && !isAsker {
@@ -224,6 +235,9 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
@@ -241,7 +255,17 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
// 私聊功能开关:发送前重新校验箱主私聊功能是否仍开启(创建会话时已查过,
// 但箱主可能在会话中途关闭私聊,此时应阻止继续发消息)。
chatState := db.FeatureStateOf(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
//
// 用 FeatureStateOfStrict(写路径约定):FeatureStateOf 在配置读失败时回退
// 默认值,对提问/投票是「开启」——等于一次读配置失败就绕过箱主的关闭。
// 私聊恰好因默认关闭而侥幸安全,但这里仍统一用 Strict,避免依赖那个巧合。
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), conv.OwnerID, db.FeatureChat)
if err != nil {
// 读配置失败即拒(fail-closed):不能让一次配置抖动绕过箱主的关闭。
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
if !chatState.Open {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errChatClosed"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
@@ -256,7 +280,8 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
}
// 上传图片(如有)
var imageURL string
var imageURL, imageKey string
imageAttached := false
if imageFile != nil {
uploadedURL, err := storage.UploadPictureToS3(imageFile, imageHeader)
if err != nil {
@@ -270,6 +295,31 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
return
}
imageURL = uploadedURL
defer func() {
if imageAttached || imageKey != "" {
return
}
if err := db.R2DeleteTasks.EnqueueValues(stdctx.Background(), conf.Upload.ImageBucket, []string{imageURL}); err != nil {
logrus.WithError(err).Error("Failed to enqueue unresolved uploaded message image compensation")
}
}()
parsedKey, owned, err := storage.ObjectKeyFromCDNURL(imageURL, conf.Upload.ImageBucketCDNHost)
if err != nil || !owned || parsedKey == "" {
logrus.WithError(err).WithField("owned", owned).Error("Failed to resolve uploaded message image key")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
imageKey = parsedKey
// 只有消息图片元数据成功落库才标记 attached;此前任意失败出口都补偿新对象。
defer func() {
if imageAttached {
return
}
if err := db.R2DeleteTasks.Enqueue(stdctx.Background(), conf.Upload.ImageBucket, []string{imageKey}); err != nil {
logrus.WithError(err).WithField("key", imageKey).Error("Failed to enqueue uploaded message image compensation")
}
}()
}
// 内容安全检测(仅文本)
@@ -283,49 +333,27 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
}
}
// 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。
// 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。
ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
var image *db.CreateMessageImageOptions
if imageURL != "" {
image = &db.CreateMessageImageOptions{URL: imageURL, ObjectKey: imageKey, FileName: imageHeader.Filename, FileSize: imageHeader.Size}
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 创建消息
msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: content,
_, err = db.Conversations.SendMessage(ctx.Request().Context(), db.SendConversationMessageOptions{
ConversationID: conv.ID, SenderID: ctx.User.ID, Content: content, Image: image,
})
if err != nil {
// 计数已递增但消息落库失败:回滚计数,避免占用配额
if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count")
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message")
switch {
case errors.Is(err, db.ErrDailyMessageLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit))
case errors.Is(err, db.ErrConversationLimit):
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached"))
default:
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send message")
ctx.SetInternalErrorFlash()
}
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
return
}
// 保存图片记录(如有)
if imageURL != "" {
if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{
MessageID: msg.ID,
URL: imageURL,
FileName: imageHeader.Filename,
FileSize: imageHeader.Size,
}); err != nil {
logrus.WithError(err).Error("Failed to save message image")
}
}
imageAttached = true
// 达到上限时关闭对话(幂等,多实例同时执行也安全)
if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil {
+134
View File
@@ -0,0 +1,134 @@
package route
import (
"net/url"
"strings"
"github.com/tamakyi/TamaBox/internal/conf"
)
// cspMode 归一化 [security] csp 的取值。默认 enforce。
//
// 为什么默认 enforce 而不是 report:本项没有配置 report-uri 时,report-only 只是
// 「发个头、什么都不拦」,等于没装 —— 装个不生效的开关反而给人「已防护」的错觉。
// 已用真浏览器巡检 20 个页面(匿名 11 + 登录 6 + 单题/投票/分享图)零违规,见
// outputs/sec-audit-2026-10-25/csp_sweep.py。
//
// 两个方向的安全性都保留了:写错值(如 "enforce " 以外的拼写错误)退到 report,
// 宁可少拦也不让整站样式与交互崩掉;真要临时关掉写 off。
func cspMode() string {
switch strings.ToLower(strings.TrimSpace(conf.Security.CSP)) {
case "off", "none", "false", "0":
return "off"
case "report", "report-only", "reportonly":
return "report"
case "enforce", "on", "true", "1", "":
return "enforce"
default:
return "report"
}
}
// cspOrigin 把配置里的「域名」或「完整 URL」统一成 scheme://host 形态。
// 配置项经常是两种写法混用(如 image_bucket_cdn_host 常写成 host/path),
// 直接拼进 CSP 会变成非法源而被浏览器整条忽略。
func cspOrigin(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
// cspPolicy 构造 Content-Security-Policy 头。
//
// 目标是补一道纵深防御,而不是把页面卡死:XSS 过滤器是一套手写白名单,
// 任何一次改动都可能开口子;CSP 让「即使注入了 <script src="//evil">」也执行不了。
//
// 白名单口径:
// - script 收得很紧(本站 + 内联 + eval + 公共 CDN + 验证码域 + 图片 CDN);
// 'unsafe-eval' 不能省 —— Alpine 3 用 new Function 编译 x-data 表达式,去掉整站交互失效。
// - 图片 / 样式 / 字体 / 连接放宽到 https::箱主上传图、社交链接头像、主题背景都可能
// 落在任意 CDN 上,收紧到具体域名只会换来一堆「图裂了」且很难自查。
// - object-src / base-uri / form-action / frame-ancestors 一并锁死。
func cspPolicy() string {
self := "'self'"
scriptSrc := []string{
self, "'unsafe-inline'", "'unsafe-eval'",
// 公共 CDN:UIkit / Alpine / dayjs / vConsole / waline(见 templates/base/header.html)
"https://cdnjs.snrat.com",
// umami 站点统计:header.html 在 window load 后动态注入 <script>,
// 静态 grep 模板里的 <script src> 抓不到它 —— 这是 enforce 实测时踩出来的
// 第一个漏项,浏览器报 "Loading the script ... violates ... script-src"。
"https://mami.shiroko.one",
// Cloudflare Turnstile
"https://challenges.cloudflare.com",
}
frameSrc := []string{"https://challenges.cloudflare.com"}
// Sentry 浏览器 SDK:templates/base/header.html 里是 `{{ if ne SentryDSN "" }}` 包着的,
// 不配 DSN 就完全不存在。所以这里也跟着配置条件加 —— 否则要么开着 enforce 时配了 DSN
// 才发现错误上报被拦(静默失效),要么白名单里常年挂一个用不到的第三方源。
// 上报走 connect-src,那边的 https: 已经覆盖 *.ingest.sentry.io。
if strings.TrimSpace(conf.App.SentryDSN) != "" {
scriptSrc = append(scriptSrc, "https://browser.sentry-cdn.com")
}
// 图片 CDN 也托管了脚本(部分库直接放在 R2 上),所以进 script-src;
// 存储 API 端点(ImageEndpoint / AliyunEndpoint)不进 —— 浏览器从不直接加载它们。
for _, raw := range []string{
conf.Upload.ImageBucketCDNHost,
conf.Upload.AliyunBucketCDNHost,
} {
if origin := cspOrigin(raw); origin != "" {
scriptSrc = append(scriptSrc, origin)
}
}
// 验证码域既是脚本源也是 iframe 源(Turnstile / reCAPTCHA 都是 iframe 形态)
if origin := cspOrigin(conf.Recaptcha.Domain); origin != "" {
scriptSrc = append(scriptSrc, origin)
frameSrc = append(frameSrc, origin)
}
directives := []string{
"default-src " + self,
"script-src " + strings.Join(dedupeStrings(scriptSrc), " "),
"style-src " + self + " 'unsafe-inline' https:",
"img-src " + self + " data: blob: https:",
"font-src " + self + " data: https:",
"media-src " + self + " https:",
"frame-src " + strings.Join(dedupeStrings(frameSrc), " "),
"connect-src " + self + " https:",
"object-src 'none'",
"base-uri 'none'",
"form-action " + self,
"frame-ancestors 'none'",
}
if uri := strings.TrimSpace(conf.Security.CSPReportURI); uri != "" {
directives = append(directives, "report-uri "+uri)
}
return strings.Join(directives, "; ")
}
// dedupeStrings 去重并保持顺序(白名单重复项无害,但排掉便于比对与测试)。
func dedupeStrings(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+120
View File
@@ -0,0 +1,120 @@
package route
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/conf"
)
// withSecurityConf 临时改 conf.Security 并在测试结束后还原,避免污染同包其它用例。
func withSecurityConf(t *testing.T, csp, reportURI string) {
t.Helper()
oldCSP, oldURI := conf.Security.CSP, conf.Security.CSPReportURI
conf.Security.CSP = csp
conf.Security.CSPReportURI = reportURI
t.Cleanup(func() {
conf.Security.CSP = oldCSP
conf.Security.CSPReportURI = oldURI
})
}
func TestCSPMode(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "enforce"}, // 未配置:默认拦截(report-only 没有上报地址等于没装)
{"report", "report"},
{"REPORT", "report"},
{"report-only", "report"},
{"off", "off"},
{"none", "off"},
{"enforce", "enforce"},
{" true ", "enforce"},
{"enfroce", "report"}, // 拼错退化为不拦截
}
for _, tc := range cases {
withSecurityConf(t, tc.in, "")
require.Equal(t, tc.want, cspMode(), "输入 %q", tc.in)
}
}
func TestCSPPolicy(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
// 生产配置的真实写法:Host 带路径、Domain 是完整 URL。
conf.Upload.ImageBucketCDNHost = "r2.example.net/tamabox"
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = "https://www.recaptcha.net"
withSecurityConf(t, "enforce", "")
policy := cspPolicy()
for _, want := range []string{
"default-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
// 脚本白名单:站内 + 内联 + eval(Alpine 需要)+ 公共 CDN + 验证码
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"https://cdnjs.snrat.com",
// umami 统计脚本是 header.html 里运行时动态注入的,静态 grep 模板看不到;
// 少这一条会让 enforce 模式直接拦掉它(已在浏览器实测中复现过)。
"https://mami.shiroko.one",
"https://challenges.cloudflare.com",
"https://www.recaptcha.net",
// 带路径的配置必须被归一化成 origin,否则整条源非法、浏览器静默忽略
"https://r2.example.net",
} {
require.Contains(t, policy, want)
}
require.NotContains(t, policy, "r2.example.net/tamabox", "带路径的配置必须裁成 origin")
require.NotContains(t, policy, "report-uri", "没配上报地址时不应出现 report-uri")
}
// TestCSPPolicySentry 覆盖「配了 sentry_dsn 才放行 Sentry CDN」这个条件白名单。
// 两个方向都会静默出错:线上没开 Sentry 时白名单里多挂一个第三方源;开了 Sentry
// 时错误上报被 enforce 直接拦掉,而页面上完全看不出异常。
func TestCSPPolicySentry(t *testing.T) {
old := conf.App
t.Cleanup(func() { conf.App = old })
conf.App.SentryDSN = ""
require.NotContains(t, cspPolicy(), "browser.sentry-cdn.com")
conf.App.SentryDSN = "https://[email protected]/1"
require.Contains(t, cspPolicy(), "https://browser.sentry-cdn.com")
}
func TestCSPPolicyReportURI(t *testing.T) {
withSecurityConf(t, "report", "https://csp.example.net/collect")
require.Contains(t, cspPolicy(), "report-uri https://csp.example.net/collect")
}
func TestIsNoStorePath(t *testing.T) {
for _, p := range []string{"/user", "/user/", "/user/profile", "/_", "/_/someone", "/_/someone/1"} {
require.True(t, isNoStorePath(p), p)
}
// 分享卡是给社交平台抓取的公开图片,且渲染成本高,刻意不套 no-store。
for _, p := range []string{"/", "/login", "/static/css/base.css", "/_foo", "/users", "/explore",
"/_/someone/share-card", "/_/someone/1/share-card"} {
require.False(t, isNoStorePath(p), p)
}
}
// TestCSPPolicyNoEmptyOrigin 防止配置留空时拼出非法源(如 "https://")。
func TestCSPPolicyNoEmptyOrigin(t *testing.T) {
old := conf.Upload
t.Cleanup(func() { conf.Upload = old })
conf.Upload.ImageBucketCDNHost = ""
conf.Upload.AliyunBucketCDNHost = ""
conf.Recaptcha.Domain = ""
policy := cspPolicy()
require.False(t, strings.Contains(policy, "https:// ") || strings.HasSuffix(policy, "https://"), policy)
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"fmt"
"net/http"
"runtime/debug"
"strings"
"github.com/flamego/flamego"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/trace"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/shared"
)
// recoveryMiddleware 替代 flamego.Recovery(),把 panic 渲染成 HTML 500 页。
//
// 为什么不用框架自带的:flamego.Recovery() 直接写纯文本 "Internal Server Error",
// 用户看到的是白底黑字的两行英文,地址栏还停在出错的 URL 上 ——
// 既不知道发生了什么,也没有回去的入口,更没法把这次错误的编号报给我们。
func recoveryMiddleware() flamego.Handler {
return func(ctx flamego.Context) {
defer func() {
if r := recover(); r != nil {
// 带栈:没有栈的 panic 日志几乎无法定位。
logrus.WithContext(ctx.Request().Context()).
Errorf("panic recovered: %v\n%s", r, debug.Stack())
if ctx.ResponseWriter().Written() {
// 头已经发出去了,没法再改状态码,只能让连接断在这里。
return
}
writeErrorPage(ctx, http.StatusInternalServerError, "error.internalTitle", "error.internalDesc")
}
}()
ctx.Next()
}
}
// writeErrorPage 输出不依赖模板系统的完整错误页。
//
// 为什么不用模板:这两个页面出现在两处「拿不到模板」的地方 ——
// NotFound 处理器不在组级中间件链里(template.Templater 与 Contexter 都挂在组级,
// 包括 session 与 CSRF),而 recovery 中间件挂在最外层,那时模板还没装配。
// 错误页恰恰是最不该依赖出错链路的页面,所以这里用内联 HTML + i18n.T。
func writeErrorPage(ctx flamego.Context, status int, titleKey, descKey string) {
lang := pickErrorPageLang(ctx.Request().Request)
title := i18n.T(lang, titleKey)
desc := i18n.T(lang, descKey)
home := i18n.T(lang, "deactivated.backHome")
hint := i18n.T(lang, "error.traceHint")
traceID := ""
if span := trace.SpanFromContext(ctx.Request().Context()); span.SpanContext().TraceID().IsValid() {
traceID = span.SpanContext().TraceID().String()
}
body := fmt.Sprintf(`<!DOCTYPE html>
<html lang="%s"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>%s</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: #f7f7f8; color: #1f2328;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", sans-serif; line-height: 1.7;
}
.card {
width: 100%%; max-width: 30rem; padding: 2rem 1.75rem; text-align: center;
background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.04);
}
h1 { font-size: 1.35rem; margin: 0 0 .75rem; font-weight: 600; }
p { margin: 0; color: #59636e; }
.hint { margin-top: 1rem; font-size: .8125rem; color: #818b98; }
code { background: #eceef0; padding: .1rem .35rem; border-radius: 4px; font-size: .8125rem; }
.actions { margin-top: 1.75rem; }
a.btn {
display: inline-block; padding: .55rem 1.25rem; border-radius: 6px;
background: #1f6feb; color: #fff; text-decoration: none; font-size: .9375rem;
}
a.btn:hover { background: #1a5fd0; }
@media (prefers-color-scheme: dark) {
body { background: #16181a; color: #e6edf3; }
.card { background: #1f2225; box-shadow: none; border: 1px solid #2c3136; }
p { color: #9aa4af; }
.hint { color: #7d8792; }
code { background: #2c3136; }
}
</style></head>
<body><div class="card">
<h1>%s</h1>
<p>%s</p>
%s
<div class="actions"><a class="btn" href="/">%s</a></div>
</div></body></html>`,
htmlLang(lang), esc(title), esc(title), esc(desc),
func() string {
if traceID == "" {
return ""
}
return `<p class="hint">` + esc(hint) + ` <code>` + esc(traceID) + `</code></p>`
}(),
esc(home),
)
ctx.ResponseWriter().Header().Set("Content-Type", "text/html; charset=utf-8")
ctx.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
// 错误页不缓存:修好之后用户刷新应该立刻拿到正常页面。
ctx.ResponseWriter().Header().Set("Cache-Control", "no-store")
ctx.ResponseWriter().WriteHeader(status)
_, _ = ctx.ResponseWriter().Write([]byte(body))
}
// pickErrorPageLang 选错误页语言。
//
// 这里拿不到 session(sessioner 是组级中间件,NotFound 不经过它),
// 所以只能从 Accept-Language 猜一个,猜不到就用站点默认语言。
func pickErrorPageLang(r *http.Request) string {
def := i18n.Resolve(shared.App.Lang())
header := strings.ToLower(r.Header.Get("Accept-Language"))
if header == "" {
return def
}
// 按声明顺序取第一个能识别的语言偏好(q 值排序这里不精确处理:
// 错误页的语言不需要那么讲究,按出现顺序取第一个命中的即可)。
for _, part := range strings.Split(header, ",") {
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
switch {
case strings.HasPrefix(tag, "zh-tw"), strings.HasPrefix(tag, "zh-hk"),
strings.HasPrefix(tag, "zh-hant"):
return i18n.Resolve("zh-TW")
case strings.HasPrefix(tag, "zh"):
return i18n.Resolve("zh-CN")
case strings.HasPrefix(tag, "ja"):
return i18n.Resolve("ja")
case strings.HasPrefix(tag, "en"):
return i18n.Resolve("en")
}
}
return def
}
// htmlLang 把内部语言代码转成 <html lang> 该用的 BCP 47 值。
func htmlLang(lang string) string {
switch lang {
case "zh-CN":
return "zh-CN"
case "zh-TW":
return "zh-Hant"
case "ja":
return "ja"
default:
return "en"
}
}
// esc 对极少数字符做转义。这里的内容全部来自字典(不是用户输入),
// 但仍然转义一次 —— 字典里出现引号或 & 时不能把页面结构弄坏。
func esc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;")
return r.Replace(s)
}

Some files were not shown because too many files have changed in this diff Show More