Compare commits
52
Commits
6fad0cbe50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fdc3df22d | ||
|
|
7ffab24025 | ||
|
|
25aee35030 | ||
|
|
7636456d4e | ||
|
|
937e688cfa | ||
|
|
3e7719f200 | ||
|
|
c633789a92 | ||
|
|
ea9ba3e8fc | ||
|
|
036156b6fb | ||
|
|
48eea98a27 | ||
|
|
d690355cdc | ||
|
|
08bbd17751 | ||
|
|
c6966138b4 | ||
|
|
b9060c982b | ||
|
|
1e9ffc0718 | ||
|
|
62edf88029 | ||
|
|
693b1102c2 | ||
|
|
c9b426acbb | ||
|
|
87176516f3 | ||
|
|
7f86db8937 | ||
|
|
f9aaef17e5 | ||
|
|
4e574ac64a | ||
|
|
5a05a61647 | ||
|
|
4fcaa197cf | ||
|
|
5def41d0f8 | ||
|
|
d5d25c804b | ||
|
|
89e263cbd4 | ||
|
|
03cb5ddcf0 | ||
|
|
f2e0c43083 | ||
|
|
0124e392c7 | ||
|
|
0041cc51f8 | ||
|
|
d4e6da76f1 | ||
|
|
c971d99bdb | ||
|
|
ced4c1635c | ||
|
|
11d6e701ee | ||
|
|
46932b4c53 | ||
|
|
430c6ef6d8 | ||
|
|
e807dea218 | ||
|
|
7802bb2078 | ||
|
|
a3cf99a8da | ||
|
|
79199ac3cd | ||
|
|
af39651734 | ||
|
|
219adefc95 | ||
|
|
8380fc9e2b | ||
|
|
cbb8908275 | ||
|
|
0226956feb | ||
|
|
0c282914c2 | ||
|
|
64436d9d61 | ||
|
|
a5441c99fd | ||
|
|
c680671b7e | ||
|
|
812b70cb4c | ||
|
|
86ea96e5ca |
+20
@@ -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
|
||||
|
||||
@@ -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`(恢复)。
|
||||
@@ -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` 已推送。教训:**人像与场景的"雾度"要对齐**——场景压白多少,人像也压相当比例。
|
||||
|
||||
@@ -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 包链接,不能「先转义再正则」否则 " 被吞进 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)→ 只把 `&` 转 `&`;
|
||||
`action="...?{{ .B }}"`(值是查询片段)→ `%3d`/`%26`。**以后 query 一律在 handler 拼完整 URL。**
|
||||
- 修法:Questioner 里生成 `AnswerAction` / `NextUnreadAction`(完整 URL),模板直接引用;
|
||||
守门 `templates/question_action_test.go`(断言无 `%3d`/`%26` 且有 `&sort=new`)。
|
||||
- 顺带确认(线上):单题页「回答并进入下一条未读」跳转、页脚「下一条未读」跳转、
|
||||
全部读完的「没有未读提问了」提示 —— 三条行为都正确,`Location` 带 `from=admin&read=unread&sort=new`。
|
||||
- 断言坑:页面上有 `/user/questions`(导航)和 `/user/questions?from=admin&…`(页脚返回)两个链接,
|
||||
用 `re.search` 取第一个会误判「返回丢参数」。要按 href 全量匹配。
|
||||
|
||||
## 部署后复验:14/14 通过(含「日志重复」排障)
|
||||
|
||||
- 复验结果:`answer?from=admin&read=unread&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。
|
||||
@@ -179,3 +179,72 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
|
||||
- 换图方式:覆盖同目录 403.webp → 重新编译,无配置项。
|
||||
- 体积代价:每封禁请求 8.6 KB(原 13 字节)。被封来源重试频繁时要留意带宽,
|
||||
已压到 256×256;别塞几百 KB 原图。
|
||||
|
||||
### 关于「403 图放 R2 是否更好」的结论
|
||||
|
||||
结论:**保持 embed**。关键事实是「服务端代理/重定向并不省我们带宽」——
|
||||
字节最终还是从本机发给被封客户端;想真省只能让客户端自己去 R2 取,
|
||||
那就要放弃「403 直接是图片」,改成 403 + 一个 `<img src=外链>` 的 HTML。
|
||||
而本项目 R2 目前只有预签名 URL(7 天过期),长期用必须开 r2.dev 或绑自定义域
|
||||
(docs/theme-new-sakura.md 已记:直连 `*.r2.cloudflarestorage.com` 会 403)。
|
||||
8.6 KB 的单张图不值得引入外部依赖;被 ban 的多是脚本,根本不会加载图片。
|
||||
若动机只是「换图不想重编译」,加 `[security] ban_image_path` 磁盘覆盖更划算。
|
||||
|
||||
### 改为 ban_image_path(配置驱动,默认纯文字)
|
||||
|
||||
按用户要求撤掉 embed:`internal/route/assets/`(含 403.webp)已删除,
|
||||
行为改为「配了合法图 → 回图;留空 / 读不到 / 不是图 / >2MB → 纯文字 403」。
|
||||
- `[security] ban_image_path`(相对路径以进程工作目录为基准);
|
||||
- `loadBanImage()` 在 `route.New()` 里启动时读一次 → `atomic.Pointer[banResponse]`,
|
||||
热路径零磁盘 IO;任何失败只 Warn 不改启动(封禁响应是最后一道兜底,不能变 500);
|
||||
- **只认内容不认扩展名**(`http.DetectContentType` 必须是 `image/*`):配成
|
||||
app.ini/.env 会被拒绝,否则等于把配置文件吐给攻击者(变异验证过这条);
|
||||
- 上限 2 MB(被封来源反复重试,大图放大出网带宽);`Content-Length` 显式给;
|
||||
HEAD 不写 body;`no-store`。
|
||||
- 实测 sniff:真 webp → `image/webp`;`app.sample.ini` → `text/plain`(被拒)。
|
||||
|
||||
**教训**:同一功能改了两版(先 embed 后配置化),第二版要把上一版的资源一起清掉,
|
||||
否则留一个没人引用的 `assets` 包。`go vet ./...` 不报、测试也不报(该包仍被旧测试引用时)。
|
||||
|
||||
## /user/questions:筛选项「已读 是/否」+ 手机端标签行不折行
|
||||
|
||||
- 筛选栏第 5 列标签「阅读状态」四字,在 ~53px 的窄列里必折两行。
|
||||
→ 词条 `qlist.readStatus` 删除,换 `qlist.readFilter`(已读/Read/既読/已讀)+
|
||||
`qlist.filterYes`/`qlist.filterNo`(是/否)。**只改标签,option 的 value 仍是
|
||||
read/unread**(后端 `readParam` 校验依赖它)。`qlist.all`(全部)保留作默认不筛选。
|
||||
- 手机端「时间 + 状态标签 + 标记/置顶」挤一行:实测(360/390/375 iframe + dump-dom 量 rect)
|
||||
一行只有 306–336px,完整时间占 127px,2 标签 + 2 按钮要 343px → 必折。
|
||||
→ 时间改 `tb-short`=m-d H:i / `tb-text`=Y-m-d H:i:s(复用 pill 的 640px 断点);
|
||||
→ `.qlist-pill` 基础值 min-width 40→34、padding 0 8→6(手机端省 20px),
|
||||
桌面 `@media(min-width:640px)` 仍覆盖回 62px/10px。
|
||||
结果:2 标签 + 2 按钮在 360 就单行;「私密+待答+未读」三标签仍会在 ≤375 折行(可接受)。
|
||||
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
|
||||
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
|
||||
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
|
||||
|
||||
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
|
||||
|
||||
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
|
||||
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
|
||||
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
|
||||
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
|
||||
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
|
||||
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
|
||||
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
|
||||
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
|
||||
|
||||
## 全站数据页 /stats(替换首页统计卡)
|
||||
|
||||
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
|
||||
明细见 `topics/stats.md`。
|
||||
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
|
||||
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
|
||||
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
|
||||
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
|
||||
热力图 CSS grid + 主色 opacity 分 4 档。
|
||||
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
|
||||
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
|
||||
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
|
||||
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
|
||||
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
|
||||
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 2026-10-24
|
||||
|
||||
## /stats 入口收敛到页脚
|
||||
|
||||
- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
|
||||
- 改动:
|
||||
- `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `</details>` 后那一段)。
|
||||
- `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import;
|
||||
`/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。
|
||||
- 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。
|
||||
- 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。
|
||||
- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异;
|
||||
重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。
|
||||
- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删,
|
||||
否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。
|
||||
|
||||
## /stats 文案与脚注排版修正
|
||||
|
||||
- 页脚入口文案「数据」→「统计数据」(`site.footer.stats`,四语言 `Statistics` / `統計データ` / `統計數據`)。
|
||||
- **「提问最集中在三 19 点前后」的 bug**:洞察句子复用了热力图列头的 `stats.wd.*`
|
||||
("一"…"日" / "Mon"…),那是为窄列头设计的单字标签,进句子就成了"三"、英文是 "Wed"。
|
||||
新增一组完整名 `stats.weekday.1..7`(周一/週一/Monday/月曜日),洞察改用这组;
|
||||
`stats.wd.*` 只留给热力图列头。已在 `overview_test.go` 加断言锁住,防止回退。
|
||||
→ 教训:**短标签和完整名必须分两套词条**,别为了少写一组 key 复用。
|
||||
- 页面脚注四条时间(基准日 / 计算于 / 最后一条提问 / 最后一条回答)由
|
||||
「A:x · B:y」挤成一行改为 `grid-template-columns: max-content 1fr` 各占一行,
|
||||
标签列与值列对齐;下面两段说明仍为独立段落。四个标签统一带冒号。
|
||||
|
||||
## 部署前自查:两个 P0 都已修
|
||||
|
||||
用户部署到测试环境后要求自查。查出的问题都是我自己上一轮引入的:
|
||||
|
||||
1. **「今日」两项被每日缓存冻住**(P0,与页面「实时」说明矛盾)。
|
||||
→ `TodayCounts` 拆出,60 秒 TTL + 跨日按日期失效;命中缓存时返回副本。
|
||||
新增 `TestTodayNumbersNotFrozen` / `TestTodayCacheExpiresAcrossMidnight`。
|
||||
2. **日期分组跟着 PG 会话时区(默认 UTC)**(P0,隐蔽:数字都"合理",只是整体偏 8 小时)。
|
||||
psql 实测:`2026-10-24 01:30+08` 在 UTC 会话下 to_char 得 `2026-10-23`、hour=17。
|
||||
→ `analyticsLocalize()` 用 `AT TIME ZONE INTERVAL '+08:00'`(偏移取 `time.At()` 的 Zone,
|
||||
不用 IANA 名)。EXPLAIN 确认过滤仍走 created_at 索引。
|
||||
3. PeakDay 用 `>=` 会让「最忙的一天」随更新漂移到最近的同值日期 → 改 `>`。
|
||||
4. `ovRefresh` 里的聚合改用 `context.WithoutCancel` + 30s 超时:第一个访客中途关页面时,
|
||||
排队的请求不会各自重跑全表 GROUP BY。
|
||||
|
||||
性能实测(20 万行):totals 337ms / 字数分布 313ms / 热力图 100ms / 日趋势 47ms,
|
||||
本地合计约 0.8s,1C1G 估 2s,靠预热兜住。百万行才需要合并扫描。
|
||||
|
||||
顺带发现(未改,与本次无关):`Dockerfile` 基础镜像是 `golang:1.20-alpine`,
|
||||
而 `go.mod` 要求 `go 1.27.3` —— 1.20 不支持 GOTOOLCHAIN 自动下载,Docker 构建必然失败;
|
||||
`-X` 里的包路径还是 `NekoWheel/NekoBox`。说明实际部署不走这个 Dockerfile。
|
||||
|
||||
## 测试环境实测(box-dev-api.337845818.best,用户时区 UTC+8)
|
||||
|
||||
- `/stats` 200,首访 5.2s(含 TLS),稳定后 TTFB 1.6s,同站 `/rules` 1.34s
|
||||
→ 本页只多约 250ms,1C1G 上可接受,无需合并聚合 SQL。
|
||||
- 环境默认语言是 ja(`/`、`/rules` 同样是 ja),与本次改动无关;语言存 session
|
||||
(`/i18n/{lang}`),故 /stats **绝不能加 CDN 缓存头** —— 内容随 session 变化且无 Vary: Cookie。
|
||||
- 逐项验算自洽:月柱合计 95 = 累计提问;热力图 7×24=168 格合计 95;
|
||||
最忙时段周三 19 点 = 22 与小时合计 26 峰值一致;折线 30 点峰值 10-19 共 5 条。
|
||||
- `/stats/` 尾斜杠 302 跳首页 —— 与 `/rules/`、`/sponsor/` 一致,框架行为。
|
||||
|
||||
## P0:回答耗时口径是错的(本次修)
|
||||
|
||||
`SetRead` 把 updated_at 往前推(实测 10 天前的提问,delta = 240h)。
|
||||
所以「回答耗时 = updated_at − created_at」实际测的是「提问到最后一次被碰」,
|
||||
线上「6 条超过 7 天」就是这么来的,24 小时占比 66.7% 同样偏低。
|
||||
|
||||
修法:`questions.answered_at`(首次回答时间),只在 answer 由空变非空时写,
|
||||
改写回答不重置;聚合 `COALESCE(answered_at, updated_at)`;加列时回填一次冻结历史。
|
||||
测试:`internal/db/answered_at_test.go` 5 个用例,含变异验证(改回 updated_at 即红)。
|
||||
|
||||
## 顺带修
|
||||
|
||||
- 环比句式:中文「比前一个月 -69.6%」、英文「...: -69.6% questions.」读不通 →
|
||||
「上月提问量环比 %s%%」/「Month-over-month change for last month: %s%%」。
|
||||
- 脚注「当日 0 点的快照」不实(实际是当天首访/重启时算)→ 改成准确描述。
|
||||
- DB 故障时 `ovRefresh` 串行 + 30s 超时会让请求排成 N×30s → 加 30s 失败冷却
|
||||
(`ovFailAt`),`todayNumbers` 失败也盖时间戳,不再每请求打两条 COUNT。
|
||||
|
||||
## 30 天折线补纵轴刻度(`cbb8908`)
|
||||
|
||||
- `internal/template/chart.go`:`Polyline`/`AreaPath` 两个函数收敛成 `NewLineChart`
|
||||
返回 `LineChart{HasData,Max,Points,Area,Ticks}`。**坐标/面积/刻度必须一次算**,
|
||||
分两次各自归一化会让线和刻度错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n 向上取整),刻度 上限/一半/0。
|
||||
1.5 倍只在 mag≥100 时参与(否则算出 7.5 半格);上限恒为偶数 → 中间刻度必为整数。
|
||||
- 刻度文字**不能放进 SVG**:viewBox 是 `preserveAspectRatio="none"` 横向拉伸的,
|
||||
文字会被拉扁。改左边一列 HTML 标签(`.tb-line-y` + 绝对定位 `.tb-line-tick`,
|
||||
top 用 Go 算好的 `TopPct`),横向虚线仍在 SVG 里(`non-scaling-stroke`)。
|
||||
- 列宽靠看不见的 `.tb-line-ruler`(最宽那条刻度)在流内占位撑开,不写死 em。
|
||||
- 面积路径改为沿基线 `h-padBottom` 闭合(原来落到 viewBox 底边,会漏进轴标签留白)。
|
||||
- 全零/空数据只出轴不出线(`HasData`),否则一条贴基线的直线看着像"数据都是 0"。
|
||||
- `internal/template/chart_test.go`:锁三条不变式 + 0..2000 穷举。
|
||||
- 视觉自检:900px 亮/暗 + 390px iframe(Windows headless 最小窗宽 ~485px,不能直接缩窗)。
|
||||
|
||||
## 取证:`GetHotQuestions` 的 `ORDER BY updated_at DESC`(未改代码)
|
||||
|
||||
- **先纠正前提:这个方法全仓零调用点**,只有接口声明 + 实现(`route/` 里 Questions 的
|
||||
15 个调用方法不含它;`git log -S` 自 baseline 起也没删过调用)。属死代码,线上零影响。
|
||||
- 连真库实测(临时测试,跑完已删):
|
||||
- 两条公开已答问题:old 9 天前回答 / new 1 小时前回答 → `GetHotQuestions` 得 `new old`;
|
||||
- `SetRead(old)` 后 → `old new`(名次被翻);`SetPinned(old)` 同样;
|
||||
- 换 `ORDER BY COALESCE(answered_at, updated_at) DESC` → 稳定 `new old`;
|
||||
- old 的 `answered_at` / `updated_at` 相差 216h。
|
||||
- 机制:`dbutil.Model.UpdatedAt` 是 GORM 约定字段,任何 `Update/Updates/Save` 都会刷新。
|
||||
所以污染源不止「标记已读」:`SetPinned` / `SetPublic` / `SetPrivate` / `UpdateCensor` 全是。
|
||||
- **同类残留(仍在线上生效)**:`QuestionStats` 的 `LastAnswerAt = MAX(updated_at)`,
|
||||
被老的 `/api/v1/site-stats` 用着(首页已不 fetch,但公开接口还在)。实测被推到当下,
|
||||
比真实最后回答晚 1 小时。新 `/stats` 页已改用 `COALESCE(answered_at, updated_at)`。
|
||||
- 另:`analytics.go` 的「今日回答数」也用 `updated_at >= since`(注释已承认偏大),未改。
|
||||
|
||||
## 已改(`8380fc9`):删死代码 + 修 LastAnswerAt
|
||||
|
||||
- 删 `GetHotQuestions`(接口声明 + 实现)。零调用点、唯一实现者 `*questions`、无 fake/mock。
|
||||
- `QuestionStats.last_a` 改 `MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at))`。
|
||||
先写测试跑红(偏差 216h)再改绿,回退路径另有一条测试。
|
||||
- 影响面只有 `/api/v1/site-stats`(`last_answer_at` + `text`),字段名/结构不变,值变准。
|
||||
- 性能:5 万行实测 7.9ms vs 8.0ms,3 次重复差 −0.5~1.8ms(噪声带内,约 30ns/行);
|
||||
执行计划仍 Seq Scan + Aggregate,且该接口有 30 分钟缓存。
|
||||
- 仍未改:`analytics.go:202` 今日回答数用 `updated_at >= since`(置顶会算进"今日回答"),
|
||||
改它会直接改变 /stats 首页数字,留待用户决定。
|
||||
|
||||
## 已改(`219adef`):今日回答数也换 answered_at
|
||||
|
||||
- `TodayCounts` 今日回答:`answer <> '' AND updated_at >= ?` → `answered_at >= ?`。
|
||||
实测:三天前回答的旧问题依次 SetPinned/SetPrivate/SetPublic → today answers 1→2(改前)。
|
||||
- 口径 = 「今天**首次**被回答的提问数」,与文案「今日回答 / Answered today」一致;
|
||||
改写回答不重置 answered_at,所以改写不算今日回答(有意)。
|
||||
- 性能:`answered_at` 无索引 → Seq Scan;但 `updated_at` 本来也没索引,同样是 Seq Scan。
|
||||
20 万行实测 36.7ms vs 38.0ms(3.5%,噪声级)。**没加索引**,注释里留了
|
||||
「真慢了再加 idx_questions_answered_at」。
|
||||
- ⚠️ 顺手发现:`analytics.go` 注释原写「只扫当天数据(走 created_at 索引)」,
|
||||
但「今日回答」那半句一直不是索引扫(updated_at 无索引)。已改正注释。
|
||||
- 测试:`TestTodayAnswersIgnoreNonAnswerWrites`(改前红 1 vs 2,改后绿)。
|
||||
|
||||
## 已改:/stats 脚注去掉「数据基准日」、「计算于」→「快照时间」
|
||||
|
||||
- 用户反馈「计算于」表达怪:它其实只是**累计数字与图表**那份快照的生成时间,
|
||||
而今日两项在缓存命中时会被 `withToday` 换成更新的读数 —— 放在页脚最底部读起来像「整页时间」。
|
||||
- 「数据基准日」与它 100% 重复:`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 `now`),
|
||||
删掉 `stats.foot.dataDate` 词条 + `Overview.DataDate` 字段(模板/预览测试同步改)。
|
||||
- 页脚从 4 行变 3 行:快照时间 / 最后一条提问 / 最后一条回答;「今日两项实时」由下面
|
||||
的 `stats.foot.cacheNote` 说明,不塞进标签里。
|
||||
- 词条:快照时间 / 快照時間 / Snapshot: / 集計時刻(`stats.foot.updated` 改名改值)。
|
||||
- i18n 有词条数量对等测试,删键必须四个语言一起删。
|
||||
@@ -0,0 +1,126 @@
|
||||
# 2026-10-25
|
||||
|
||||
## /stats 文案清理:把「写给运营者看」的话删掉
|
||||
|
||||
用户指出「数据速览」下面那句「下面几句是从上面的数字直接读出来的,没有推测」怪 ——
|
||||
那是对他自己说的(作者视角的免责),不是对访客说的。顺着扫了页面全部 40 余条文案。
|
||||
|
||||
改动:
|
||||
|
||||
- **删 `stats.insight.note`**(四语言 + 模板那行 `<p class="tb-card-note">`)。
|
||||
卡片留「数据速览」+ 列表即可;`stats.insight.empty` 保留。
|
||||
删掉说明行后标题与列表之间只剩 4px,给 `.tb-insights` 补 `margin: 8px 0 0`。
|
||||
- **`stats.foot.cacheNote` 去掉实现细节**:「当天重算一次的快照(当天第一次访问时算,
|
||||
服务重启也会重算)」→「累计数字与图表每天更新一次」。访客不需要知道缓存是懒重算、
|
||||
也不需要知道重启会触发重算;准确的新鲜度由「快照时间」那行给出。
|
||||
- 顺手修了 `templates/stats.html` 里一条过期注释(还写着「四条时间」「基准日」,
|
||||
上一轮已改成三行「快照时间」)。
|
||||
|
||||
保留未动(判断过,不属于这个问题):
|
||||
|
||||
- `stats.foot.privacy` 与顶部 `stats.lead` 的后半句近乎同义(隐私声明重复一次)——
|
||||
页脚隐私声明是惯例,重复不算噪音,故保留。
|
||||
- `stats.today.tag` 的「(实时)」在 60s TTL 下仍成立,不改。
|
||||
|
||||
## 约定(已写进 topics/stats.md)
|
||||
|
||||
页面文案一律以**访客**为第二人称对象:不写实现细节(缓存、重启、索引),
|
||||
不写作者视角的免责(「没有推测」「这里只有」「本文」这类把读者当审稿人的话)。
|
||||
数据来源/隐私/新鲜度这些确实要对访客交代的事,放 `stats.lead` 与页脚注。
|
||||
|
||||
## 环比句改成「点明月份 + 条数」
|
||||
|
||||
用户问「上月提问量环比 -75.0%」是不是「上月同期比本月同期」。**不是**——实际比的是
|
||||
两个**完整自然月**(9 月 vs 8 月)。但这个问题本身就是证据:句子里「上月」和「环比」
|
||||
两个相对词叠着,读者得先做两次翻译,很容易读成「同期对比」。
|
||||
|
||||
- 新句:`9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。`
|
||||
EN:`September had 5,620 questions; August had 4,890, up 14.9%.`
|
||||
- 口径没变(仍是上月 vs 上上月,本月没过完不参与),只换表达。
|
||||
- `page.go` 新增 `monthText`:CJK 一律「9 月」,英文用 `time.Month().String()`
|
||||
("month 9" 不是英语),不带年份——相比的两个月相邻。
|
||||
- 三个词条 `momUp/momDown/momFlat` 取代 `mom`:方向词没法塞进同一个模板,
|
||||
四种语言语序不同。参数顺序统一(本月份名/条数、对比月名/条数、|pct|)。
|
||||
- `|pct| < 0.05` 走持平,避免「多了 0.0%」。
|
||||
- 测试:`TestInsightMoMNamesBothMonths`(上升/下降/持平三分支,断言两个月份名 +
|
||||
两个条数都在句子里)、`TestMonthText`。
|
||||
- 顺手修预览测试:`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 上,外部不可分辨。已在报告第四节改正。
|
||||
@@ -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`)。
|
||||
@@ -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: ""`(不是回归)。
|
||||
@@ -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`。
|
||||
@@ -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),必须用绝对路径。
|
||||
@@ -1,73 +0,0 @@
|
||||
# TamaBox-v4 长期笔记(索引版)
|
||||
|
||||
明细已拆分到 `topics/*.md`(按需 Read 全文):
|
||||
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
|
||||
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
|
||||
`honeypot` 蜜罐多家族伪装 / `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%(切口平直,正好贴页面下沿)。
|
||||
@@ -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 ./...`
|
||||
@@ -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` 已清理)。
|
||||
@@ -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 都处理对了)、
|
||||
后台移动端短标签方案。这几块质量高于平均,别为了统一风格去改。
|
||||
@@ -0,0 +1,134 @@
|
||||
# 全站数据页(/stats)
|
||||
|
||||
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
|
||||
|
||||
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
|
||||
(`templates/base/footer.html`,词条 `site.footer.stats`)。
|
||||
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
|
||||
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
|
||||
新增入口不要再往首页加——那会让两处数字需要同步维护。
|
||||
|
||||
## 分层
|
||||
|
||||
```
|
||||
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
|
||||
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
|
||||
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
|
||||
→ route.Stats → templates/stats.html
|
||||
```
|
||||
|
||||
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
|
||||
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
|
||||
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
|
||||
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
|
||||
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
|
||||
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
|
||||
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
|
||||
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
|
||||
|
||||
## 口径(改之前先看)
|
||||
|
||||
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
|
||||
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
|
||||
- 回答耗时用 `answered_at - created_at`(**不是 updated_at**)。GORM 的 `Update`
|
||||
会自动刷新 updated_at,已读/置顶/公开切换都走 Update —— 实测一条 10 天前的提问
|
||||
被 `SetRead` 后 updated_at 直接推到当下。故新增 `questions.answered_at`(可空),
|
||||
只在 answer 由空变非空时写一次,改写回答不重置;历史行为 NULL 时
|
||||
`COALESCE(answered_at, updated_at)` 回退,且首次加列时用 updated_at 冻结一次
|
||||
(`backfillAnsweredAt`,靠 `Migrator().HasColumn` 判断是否刚加列)。
|
||||
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
|
||||
**句子必须点明月份与条数**:「9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。」
|
||||
不要写「上月提问量环比 -75.0%」——两个相对词(上月 / 环比)叠在一起,用户实测读成
|
||||
「上月同期 vs 本月同期」。也不要用带符号的裸百分比(「环比 -75%」)当结论。
|
||||
三套句式 `stats.insight.momUp/momDown/momFlat`(方向词进不了同一个模板,语法各语言不通);
|
||||
参数顺序统一是 本月份名/条数、对比月份名/条数、绝对值百分比,`|pct|<0.05` 走持平。
|
||||
- 累计与图表是**当天重算一次**的快照(当天首访时算,重启也会重算;不是「0 点快照」),
|
||||
「今日提问/回答」两项实时(60 秒 TTL,见下)。
|
||||
- 页脚时间标签因此叫「快照时间」而不是「计算于」:它只覆盖累计与图表,
|
||||
今日两项会被 `withToday` 换掉。原先另有「数据基准日」一行,与它是 100% 重复
|
||||
(`Overview.DataDate` 就是 `UpdatedAt` 的日期部分),已删。
|
||||
- 今日两项的 60s TTL **不要**为了省查询改成 30 分钟:每分钟最多 2 条 COUNT,
|
||||
20 万行实测 38ms/条 → 最坏 109 秒 DB 时间/天(0.13%),换不到什么;
|
||||
而 30 分钟会让「(实时)」变成假话、提问者自己看不见刚发的那条。
|
||||
- 日/周/时分组必须在 SQL 里 `AT TIME ZONE INTERVAL '+08:00'` 显式换算,
|
||||
不能依赖数据库会话时区 —— 见「时区」一节。
|
||||
|
||||
## 两个已踩过的坑(2026-10-24 部署前自查发现)
|
||||
|
||||
**1. 「今日」两项曾被每日缓存冻住。** 原先 `SiteAnalytics` 里查了今日数,塞进
|
||||
`Overview`,而 `Overview` 整天复用 → 页面顶着「实时」的说明显示一整天不变的常量。
|
||||
现在拆出 `SiteAnalyticsStore.TodayCounts`,`stats` 层单独 60 秒缓存(`todayTTL`),
|
||||
跨日按日期字符串失效(`todayDate`)。缓存命中时返回的是**副本**(`withToday`),
|
||||
不能原地改 `ovCached` —— 那是数据竞争。
|
||||
|
||||
**2. 日期分组默认跟着 PG 会话时区走,通常是 UTC。** PG 官方镜像 `timezone=UTC`,
|
||||
而 Go 按容器本地时区(Dockerfile 设 Asia/Shanghai)切「今天」。实测同一条
|
||||
`2026-10-24 01:30+08` 的提问:会话 UTC 下 `to_char` 给 `2026-10-23`、`EXTRACT(HOUR)` 给 17。
|
||||
→ 日报表整体偏 8 小时(中国 0–8 点被算进前一天),热力图时段全错位,且与「今日」对不上。
|
||||
现在 `analyticsLocalize()` 统一包一层 `AT TIME ZONE INTERVAL '+08:00'`,偏移取自
|
||||
`time.Time.Zone()`(不用 IANA 名:Go 在未设 TZ 时给 "Local",PG 不认)。
|
||||
过滤条件仍是裸列,索引照用(EXPLAIN 确认走 Bitmap Index Scan)。
|
||||
|
||||
## 性能实测(20 万行 questions,本地 PG,冷缓存)
|
||||
|
||||
| 查询 | 耗时 | 扫描方式 |
|
||||
|---|---|---|
|
||||
| totals(全表条件聚合) | 337 ms | Seq Scan |
|
||||
| 字数分布(全表) | 313 ms | Parallel Seq Scan |
|
||||
| 热力图 180 天 | 100 ms | Bitmap Index Scan |
|
||||
| 日趋势 30 天 | 47 ms | Bitmap Index Scan |
|
||||
|
||||
合计约 0.8 秒;1C1G 云机按 2–3 倍算约 2 秒。靠启动后 3 秒预热挪出请求路径,
|
||||
可接受。**若 questions 到百万行**,三次全表扫描会到 6–10 秒,那时应把
|
||||
totals + 字数分布 + 回答耗时合并成一条 SQL(都是同一张表的无条件 CASE 聚合,
|
||||
合并后只扫一次)。现在不做:不知道真实表规模,每天只算一次,不值得为省 0.5 秒
|
||||
把 SQL 拆成大块。
|
||||
|
||||
## 图表实现(零前端库)
|
||||
|
||||
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
|
||||
|
||||
- 折线:`NewLineChart` 一个函数给出 `{Max, Points, Area, Ticks}`,
|
||||
SVG `viewBox` + `preserveAspectRatio="none"` + `vector-effect: non-scaling-stroke`
|
||||
(否则窄屏描边被压细)。
|
||||
- **坐标 / 面积 / 刻度必须同一次调用算出**:分开算会各按自己看到的峰值归一化,
|
||||
线和刻度就错开半格。
|
||||
- 纵轴上限 `niceMax(峰值)`(1/2/1.5/3/4/6/8×10^n),刻度 = 上限 / 一半 / 0;
|
||||
上限恒为偶数 → 中间刻度必是整数。1.5 倍只在 mag≥100 时参与(否则是 7.5 半格)。
|
||||
- 刻度文字放 SVG **外面**:viewBox 横向被拉伸,里面的文字会跟着变扁。
|
||||
做法是左列绝对定位的 HTML 标签(top 用 Go 算好的 `TopPct`)+ 看不见的占位元素撑列宽。
|
||||
- 面积沿基线(`h-padBottom`)闭合,别落到 viewBox 底边(那是轴标签的留白)。
|
||||
- 全零/空数据只出轴不出线,否则贴基线的直线看着像「数据都是 0」。
|
||||
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
|
||||
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
|
||||
不用算周长。
|
||||
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
|
||||
(连续透明度在小方块上肉眼分不出来)。
|
||||
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
|
||||
展示顺序(周一起)在 `weekdayOrder` 里转。
|
||||
|
||||
## 预览与验证
|
||||
|
||||
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
|
||||
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
|
||||
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
|
||||
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
|
||||
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
|
||||
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
|
||||
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
|
||||
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
|
||||
|
||||
## 隐私红线
|
||||
|
||||
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
|
||||
「收到过提问的箱子」这类计数可以,具体是谁不行。
|
||||
|
||||
## 文案(读者是访客,不是站长)
|
||||
|
||||
- 不写实现细节:缓存策略、懒重算、"服务重启也会重算"、索引、TTL —— 访客要的只是
|
||||
「多久更新一次」。准确的新鲜度交给页脚「快照时间」那行。
|
||||
- 不写作者视角的免责:「下面几句…没有推测」「本文」「这里只有」这类,读起来像在跟审稿人
|
||||
解释,而不是在给访客看。已删 `stats.insight.note`。
|
||||
- 确实要对访客交代的只有三件:数据来源/隐私(`stats.lead` + `stats.foot.privacy`)、
|
||||
新鲜度(`stats.foot.cacheNote`)、口径例外(`stats.trend.monthlyNote` 的"当月还没过完")。
|
||||
- 删说明行会改变间距:`.tb-insights` 自己补 `margin-top`,别指望 `tb-card-note` 的 16px。
|
||||
@@ -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")
|
||||
|
||||
+120
-13
@@ -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,56 @@ 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 之类会被拒绝,
|
||||
; 否则等于把配置文件当响应体吐给攻击者。
|
||||
; 建议用 256×256 左右的图并压到 20 KB 以内 —— 被封的来源会反复重试,
|
||||
; 每张图都会照常占用出网带宽。
|
||||
ban_image_path =
|
||||
|
||||
; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
||||
default_moderation_strictness =
|
||||
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
||||
@@ -224,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)计数。
|
||||
@@ -305,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.
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 写入。
|
||||
|
||||
+20
-4
@@ -112,19 +112,35 @@ honeypot_cred_dir = data/honeypot-creds
|
||||
|
||||
### 被封之后会看到什么
|
||||
|
||||
状态码 `403`,body 是一张 256×256 的 webp(约 8.6 KB,画面写着 403 Forbidden),不再是那行 13 字节的明文:
|
||||
状态码永远 `403`,body 由 `[security] ban_image_path` 决定:
|
||||
|
||||
```ini
|
||||
[security]
|
||||
ban_image_path = data/403.webp
|
||||
```
|
||||
|
||||
| 情况 | 响应体 |
|
||||
|---|---|
|
||||
| 配了合法图片 | 图片本身(`Content-Type` 按内容嗅探,png/jpeg/gif/webp 都行) |
|
||||
| 留空 | 纯文字 `403 Forbidden`(改造前的老行为) |
|
||||
| 文件读不到 / 是目录 / 不是图片 / 超过 2 MB | 纯文字 `403 Forbidden` + 启动日志 Warn |
|
||||
|
||||
```
|
||||
$ curl -si https://你的站点/anything | head -4
|
||||
$ curl -si https://你的站点/anything | head -5
|
||||
HTTP/2 403
|
||||
content-type: image/webp
|
||||
content-length: 8608
|
||||
cache-control: no-store, no-cache, must-revalidate
|
||||
```
|
||||
|
||||
这样做有两个目的:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。
|
||||
为什么要能换成图:爆破脚本常靠读响应体里的 `403 Forbidden` 确认自己已被拦,换掉 body 让这类判断失效;人肉访问(尤其是被共享出口连累的误封)时,看到一张明确的图比白屏好判断。但这是**可选**的——配错了不会影响封禁本身,只是回到纯文字。
|
||||
|
||||
图片用 `go:embed` 编进二进制(`internal/route/assets/403.webp`),不占任何可公开访问的 URL。想换成自己的图:覆盖同目录文件后重新编译、重启即可。注意体积——被封的来源会反复重试,8.6 KB 是刻意压过的(原图别直接塞几百 KB 的)。
|
||||
几个约束,都是刻意的:
|
||||
|
||||
- **只认内容,不认扩展名**(`http.DetectContentType`)。把 `ban_image_path` 配成 `app.ini` / `.env` 会被拒绝并告警——否则等于把配置文件原样吐给每一个被封的攻击者。
|
||||
- **上限 2 MB**。被封来源会反复重试,每张图都照常占用出网带宽;建议 256×256、20 KB 以内。
|
||||
- **启动时读一次**,之后每个请求只读内存,不在热路径上做磁盘 IO。换图 = 换文件 + 重启进程。
|
||||
- `no-store`:中间层缓存一张 403 没有意义,还会让解封后的响应继续命中缓存。
|
||||
|
||||
### 封的是一把「键」,不是一个人
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+33
@@ -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()
|
||||
|
||||
Vendored
+66
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 下对象数不应触发分页")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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), "窗口未结束时不能发不完整的报告")
|
||||
}
|
||||
+130
-5
@@ -5,8 +5,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -17,10 +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{
|
||||
@@ -68,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 是只追加的审计表,写入完全由
|
||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||
@@ -80,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 {
|
||||
@@ -89,23 +109,85 @@ 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"))
|
||||
|
||||
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
|
||||
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
|
||||
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
if _, err := stats.OverviewData(ctx.Context); err != nil {
|
||||
logrus.WithError(err).Warn("预热全站统计数据失败")
|
||||
}
|
||||
}()
|
||||
|
||||
// 最外层挂载 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)
|
||||
@@ -114,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 归一化配置键名。
|
||||
|
||||
+82
-3
@@ -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:仅命中后台
|
||||
@@ -126,7 +130,70 @@ var (
|
||||
// 目录会被强制为 0700、文件 0600;留空使用默认 data/honeypot-creds。
|
||||
// 目录不可写时只降级为「不保存凭据」,不会影响站点启动。
|
||||
HoneypotCredDir string `ini:"honeypot_cred_dir"`
|
||||
// BanImagePath 被封禁时返回的图片路径(本地文件,相对路径以进程工作目录为基准)。
|
||||
//
|
||||
// 留空、文件读不到、或内容不是图片时,一律回退为纯文字 "403 Forbidden" ——
|
||||
// 这条路径绝不能因为一张图把「拒绝」响应弄坏,更不能让缺失的资源变成 500。
|
||||
//
|
||||
// 只接受 image/* 的内容:如果配成 app.ini 之类的文件会被拒绝并告警,
|
||||
// 否则等于把配置文件当响应体吐给每一个被封的攻击者。
|
||||
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 {
|
||||
@@ -190,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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright 2022 E99p1ant. 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"
|
||||
)
|
||||
|
||||
// SiteAnalyticsStore 提供「全站公开数据页」所需的聚合查询。
|
||||
//
|
||||
// 与 internal/stats 的分工:本层只做数据库聚合(纯查询,不缓存、不生成文案),
|
||||
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
|
||||
type SiteAnalyticsStore interface {
|
||||
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
|
||||
// TodayCounts 单独取「今日提问 / 今日回答」。
|
||||
//
|
||||
// 它们刻意不进 SiteAnalytics:整页聚合一整天只算一次,而这两个数要跟着
|
||||
// 页面上的「实时」说明走,必须每次(或每隔几十秒)重新取。
|
||||
TodayCounts(ctx context.Context, since time.Time) (questions, answers int64, err error)
|
||||
}
|
||||
|
||||
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
|
||||
var Analytics SiteAnalyticsStore
|
||||
|
||||
// SiteAnalytics 是一次全站聚合的原始结果,所有计数都是「累及至今」或「窗口内」的口径,
|
||||
// 具体窗口由调用方传入的 now 决定(见 stats 包的 Overview)。
|
||||
type SiteAnalytics struct {
|
||||
RegisteredUsers int64
|
||||
TotalQuestions int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 // 至少收到过一条提问的箱主数
|
||||
AnsweringBoxes int64 // 至少回答过一条提问的箱主数
|
||||
TotalMessages int64
|
||||
LastQuestionAt *time.Time
|
||||
LastAnswerAt *time.Time
|
||||
|
||||
Daily []CountPoint // 近 30 天(按日)
|
||||
Monthly []CountPoint // 近 12 个月(按月)
|
||||
|
||||
// Heatmap 一周 7 天 × 24 小时的提问量,下标 0 为周日(与 EXTRACT(DOW) 对齐)。
|
||||
Heatmap [7][24]int64
|
||||
HeatmapDays int // Heatmap 的统计窗口天数,用于在页面上说明口径
|
||||
|
||||
LengthBins [6]int64 // 提问字数分布
|
||||
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
|
||||
}
|
||||
|
||||
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
|
||||
type CountPoint struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
|
||||
func NewSiteAnalyticsStore(db *gorm.DB) SiteAnalyticsStore {
|
||||
return &siteAnalytics{db}
|
||||
}
|
||||
|
||||
type siteAnalytics struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
const (
|
||||
// 趋势窗口:近 30 天按日、近 12 个月按月。
|
||||
analyticsDailyDays = 30
|
||||
analyticsMonths = 12
|
||||
// 热力图窗口:一年太贵(全表聚合),半年足以代表「全站习惯」。
|
||||
analyticsHeatmapDays = 180
|
||||
|
||||
// 字数分桶上限(左闭右开),最后一桶为 500 以上。
|
||||
lengthBinExpr = `CASE
|
||||
WHEN CHAR_LENGTH(content) < 20 THEN 0
|
||||
WHEN CHAR_LENGTH(content) < 50 THEN 1
|
||||
WHEN CHAR_LENGTH(content) < 100 THEN 2
|
||||
WHEN CHAR_LENGTH(content) < 200 THEN 3
|
||||
WHEN CHAR_LENGTH(content) < 500 THEN 4
|
||||
ELSE 5 END`
|
||||
)
|
||||
|
||||
// analyticsDialect 把两套数据库里含义相同、写法不同的表达式收敛到一处。
|
||||
//
|
||||
// 项目实际部署用 PostgreSQL(见 Init 的索引分支),MySQL 分支只是保留可用性:
|
||||
// 配置切到 MySQL 时不应整页 500,而应安静地少几个数。
|
||||
type analyticsDialect struct {
|
||||
day string
|
||||
month string
|
||||
dow string
|
||||
hour string
|
||||
replyMin string
|
||||
}
|
||||
|
||||
func analyticsDialectFor(db *gorm.DB, at time.Time) analyticsDialect {
|
||||
tz := analyticsTZOffset(at)
|
||||
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
// MySQL 的 DATETIME 不带时区,驱动按 DSN 的 loc(默认 UTC)写入,
|
||||
// 因此库里存的是 UTC 墙钟,CONVERT_TZ 再搬到进程时区。用偏移而不是
|
||||
// 时区名:CONVERT_TZ 用时区名要求 mysql.time_zone* 表已导入。
|
||||
conv := func(col string) string {
|
||||
return fmt.Sprintf("CONVERT_TZ(%s, '+00:00', '%s')", col, tz)
|
||||
}
|
||||
day := conv("created_at")
|
||||
return analyticsDialect{
|
||||
day: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m-%%d')", day),
|
||||
month: fmt.Sprintf("DATE_FORMAT(%s, '%%Y-%%m')", day),
|
||||
dow: fmt.Sprintf("(DAYOFWEEK(%s) - 1)", day),
|
||||
hour: fmt.Sprintf("HOUR(%s)", day),
|
||||
// 回答耗时以 answered_at 为准。历史行该列为空(加列之前回答的),
|
||||
// 只能退回 updated_at —— 那部分含已读/置顶带来的误差,无法追溯修正。
|
||||
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, COALESCE(answered_at, updated_at))",
|
||||
}
|
||||
}
|
||||
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
|
||||
loc := func(col string) string { return analyticsLocalize(col, at) }
|
||||
return analyticsDialect{
|
||||
day: fmt.Sprintf("to_char(%s, 'YYYY-MM-DD')", loc("created_at")),
|
||||
month: fmt.Sprintf("to_char(%s, 'YYYY-MM')", loc("created_at")),
|
||||
dow: fmt.Sprintf("CAST(EXTRACT(DOW FROM %s) AS INTEGER)", loc("created_at")),
|
||||
hour: fmt.Sprintf("CAST(EXTRACT(HOUR FROM %s) AS INTEGER)", loc("created_at")),
|
||||
replyMin: "CAST(EXTRACT(EPOCH FROM (COALESCE(answered_at, updated_at) - created_at)) / 60 AS INTEGER)",
|
||||
}
|
||||
}
|
||||
|
||||
// analyticsLocalize 把带时区的列转成「Go 进程所在时区的墙钟时间」再参与日期分组。
|
||||
//
|
||||
// 不能依赖数据库会话时区:PG 官方镜像默认 timezone=UTC,而 Go 侧按容器本地时区
|
||||
// (Dockerfile 里写的是 Asia/Shanghai)切「今天」。两边不一致时,日报表与热力图会
|
||||
// 整体偏 8 小时 —— 中国时间 0–8 点的提问被算进前一天,且和页面顶部「今日」对不上。
|
||||
// 这类偏差肉眼看不出来(数字本身都合理),只能靠显式换算消除。
|
||||
//
|
||||
// 用固定偏移而不是 IANA 名:Go 在容器未设 TZ 时会给出 "Local",PG 不认这个名字。
|
||||
// 中国无夏令时,固定偏移与真实时区完全等价;有夏令时的部署在窗口内可能差 1 小时。
|
||||
func analyticsLocalize(col string, at time.Time) string {
|
||||
return fmt.Sprintf("%s AT TIME ZONE INTERVAL '%s'", col, analyticsTZOffset(at))
|
||||
}
|
||||
|
||||
// analyticsTZOffset 返回 +08:00 这样的偏移字面量。
|
||||
func analyticsTZOffset(at time.Time) string {
|
||||
_, off := at.Zone()
|
||||
sign := "+"
|
||||
if off < 0 {
|
||||
sign, off = "-", -off
|
||||
}
|
||||
return fmt.Sprintf("%s%02d:%02d", sign, off/3600, off%3600/60)
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
|
||||
loc := now.Location()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
dailySince := startOfDay.AddDate(0, 0, -(analyticsDailyDays - 1))
|
||||
// 先归到当月 1 号再回退,避免月底 AddDate 溢出(3-31 减 11 个月会滚到 3-03)。
|
||||
monthSince := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -(analyticsMonths - 1), 0)
|
||||
heatmapSince := startOfDay.AddDate(0, 0, -analyticsHeatmapDays)
|
||||
|
||||
out := &SiteAnalytics{HeatmapDays: analyticsHeatmapDays}
|
||||
|
||||
if err := db.aggregateTotals(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateTrends(ctx, out, now, dailySince, monthSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateHeatmap(ctx, out, now, heatmapSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.aggregateContent(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&User{}).Count(&out.RegisteredUsers).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count registered users")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Message{}).Count(&out.TotalMessages).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "count messages")
|
||||
}
|
||||
|
||||
// 「今日」两项不在这里查:本函数的结果整天缓存,把会变的数字放进来
|
||||
// 会让页面顶着一个「实时」的说明显示一整天不变的值。见 TodayCounts。
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TodayCounts 取今日提问数与今日回答数。
|
||||
//
|
||||
// 口径:
|
||||
// - 今日提问 = created_at 落在今天的提问,走 idx_questions_created_at;
|
||||
// - 今日回答 = answered_at 落在今天的提问,即「今天首次被回答」。
|
||||
// 不能拿 updated_at 当回答时间:置顶、公开/私密切换、标记已读都会刷新它
|
||||
// (GORM 的 Update 一律更新 updated_at),箱主晚上整理一批三个月前的老问题
|
||||
// —— 一次回答都没有 —— 页面上「今日回答」就会凭空多出几十条。
|
||||
//
|
||||
// answered_at 没有索引,这一条是全表过滤(与改动前的 updated_at 同级别);
|
||||
// 十万行量级约几十毫秒,页面另有 60 秒缓存,够用。真慢了再加
|
||||
// idx_questions_answered_at,不必为这个查询动表结构。
|
||||
func (db *siteAnalytics) TodayCounts(ctx context.Context, since time.Time) (int64, int64, error) {
|
||||
var questions, answers int64
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("created_at >= ?", since).Count(&questions).Error; err != nil {
|
||||
return 0, 0, errors.Wrap(err, "count today's questions")
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Where("answer <> '' AND answered_at >= ?", since).Count(&answers).Error; err != nil {
|
||||
return 0, 0, errors.Wrap(err, "count today's answers")
|
||||
}
|
||||
return questions, answers, nil
|
||||
}
|
||||
|
||||
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
|
||||
//
|
||||
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
|
||||
// 后者只在 PostgreSQL 上可用。
|
||||
func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics) error {
|
||||
var row struct {
|
||||
Total int64
|
||||
Answered int64
|
||||
Private int64
|
||||
Read int64
|
||||
Anonymous int64
|
||||
ActiveBoxes int64 `gorm:"column:active_boxes"`
|
||||
AnsweringBoxes int64 `gorm:"column:answering_boxes"`
|
||||
LastQ *time.Time `gorm:"column:last_q"`
|
||||
LastA *time.Time `gorm:"column:last_a"`
|
||||
}
|
||||
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 is_private THEN 1 ELSE 0 END), 0) AS private,
|
||||
COALESCE(SUM(CASE WHEN is_read OR answer <> '' THEN 1 ELSE 0 END), 0) AS read,
|
||||
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
|
||||
COUNT(DISTINCT user_id) AS active_boxes,
|
||||
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
|
||||
MAX(created_at) AS last_q,
|
||||
MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a`).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "aggregate question totals")
|
||||
}
|
||||
|
||||
out.TotalQuestions = row.Total
|
||||
out.Answered = row.Answered
|
||||
out.Private = row.Private
|
||||
out.Read = row.Read
|
||||
out.Anonymous = row.Anonymous
|
||||
out.ActiveBoxes = row.ActiveBoxes
|
||||
out.AnsweringBoxes = row.AnsweringBoxes
|
||||
out.LastQuestionAt = row.LastQ
|
||||
out.LastAnswerAt = row.LastA
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
|
||||
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, now, dailySince, monthSince time.Time) error {
|
||||
d := analyticsDialectFor(db.DB, now)
|
||||
|
||||
var daily []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.day+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", dailySince).
|
||||
Group(d.day).Order(d.day).
|
||||
Scan(&daily).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate daily trend")
|
||||
}
|
||||
for _, row := range daily {
|
||||
out.Daily = append(out.Daily, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
|
||||
var monthly []struct {
|
||||
Key string
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.month+" AS key, COUNT(*) AS n").
|
||||
Where("created_at >= ?", monthSince).
|
||||
Group(d.month).Order(d.month).
|
||||
Scan(&monthly).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate monthly trend")
|
||||
}
|
||||
for _, row := range monthly {
|
||||
out.Monthly = append(out.Monthly, CountPoint{Key: row.Key, N: row.N})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, now, since time.Time) error {
|
||||
d := analyticsDialectFor(db.DB, now)
|
||||
|
||||
var rows []struct {
|
||||
Dow int
|
||||
Hr int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(d.dow+" AS dow, "+d.hour+" AS hr, COUNT(*) AS n").
|
||||
Where("created_at >= ?", since).
|
||||
Group(d.dow + ", " + d.hour).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate weekday/hour heatmap")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
// 越界值直接丢弃:MySQL 的 DAYOFWEEK 或异常时区数据都可能给出 7 以上的下标,
|
||||
// 落进固定数组会 panic,而这份数据只影响一张图,不值得让整页失败。
|
||||
if row.Dow < 0 || row.Dow > 6 || row.Hr < 0 || row.Hr > 23 {
|
||||
continue
|
||||
}
|
||||
out.Heatmap[row.Dow][row.Hr] = row.N
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
|
||||
// 内容分布只用时长差值,不涉及日期分组,时区不影响结果。
|
||||
d := analyticsDialectFor(db.DB, time.Now())
|
||||
|
||||
var lengths []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(lengthBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Group(lengthBinExpr).
|
||||
Scan(&lengths).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate content length distribution")
|
||||
}
|
||||
for _, row := range lengths {
|
||||
if row.Bin >= 0 && row.Bin < len(out.LengthBins) {
|
||||
out.LengthBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
|
||||
// 回答耗时:只用已回答的提问,按分钟分桶。
|
||||
// 口径注意:updated_at 也会被置顶等写操作刷新,因此这里算出的耗时偏大;
|
||||
// 展示时只呈现「24 小时内回答占比」这类粗粒度结论,不宣称精确中位数。
|
||||
replyBinExpr := fmt.Sprintf(`CASE
|
||||
WHEN %[1]s < 60 THEN 0
|
||||
WHEN %[1]s < 360 THEN 1
|
||||
WHEN %[1]s < 1440 THEN 2
|
||||
WHEN %[1]s < 4320 THEN 3
|
||||
WHEN %[1]s < 10080 THEN 4
|
||||
ELSE 5 END`, d.replyMin)
|
||||
|
||||
var replies []struct {
|
||||
Bin int
|
||||
N int64
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select(replyBinExpr + " AS bin, COUNT(*) AS n").
|
||||
Where("answer <> ''").
|
||||
Group(replyBinExpr).
|
||||
Scan(&replies).Error; err != nil {
|
||||
return errors.Wrap(err, "aggregate reply latency distribution")
|
||||
}
|
||||
for _, row := range replies {
|
||||
if row.Bin >= 0 && row.Bin < len(out.ReplyBins) {
|
||||
out.ReplyBins[row.Bin] = row.N
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2022 E99p1ant. 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"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSiteAnalytics 验证 /stats 页的聚合口径。
|
||||
//
|
||||
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
|
||||
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
|
||||
// 对着 mock 断言等于什么都没验证。
|
||||
func TestSiteAnalytics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := newTestDB(t)
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("users", "questions", "messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
store := NewSiteAnalyticsStore(db)
|
||||
questions := NewQuestionsStore(db)
|
||||
|
||||
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
|
||||
|
||||
// 一条登录用户提的、已回答的长问题
|
||||
answered, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
|
||||
|
||||
// 一条匿名提的私密短问题,未读未答
|
||||
_, err = questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 一条被软删除的:不该出现在任何数字里
|
||||
deleted, err := questions.Create(ctx, CreateQuestionOptions{
|
||||
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
|
||||
|
||||
got, err := store.SiteAnalytics(ctx, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 计数
|
||||
require.EqualValues(t, 1, got.RegisteredUsers)
|
||||
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
|
||||
require.EqualValues(t, 1, got.Answered)
|
||||
require.EqualValues(t, 1, got.Private)
|
||||
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
|
||||
require.EqualValues(t, 1, got.Anonymous)
|
||||
require.EqualValues(t, 1, got.ActiveBoxes)
|
||||
require.EqualValues(t, 1, got.AnsweringBoxes)
|
||||
|
||||
// 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
todayQ, todayA, err := store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, todayQ)
|
||||
require.EqualValues(t, 1, todayA)
|
||||
|
||||
// 日/月趋势里能找到今天与本月
|
||||
today := now.Format("2006-01-02")
|
||||
require.EqualValues(t, 2, countOf(got.Daily, today))
|
||||
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
|
||||
|
||||
// 热力图:两条提问都发生在当前这一小时
|
||||
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
|
||||
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
|
||||
|
||||
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
|
||||
require.EqualValues(t, 1, got.LengthBins[0])
|
||||
require.EqualValues(t, 1, got.LengthBins[3])
|
||||
|
||||
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
|
||||
require.EqualValues(t, 1, got.ReplyBins[0])
|
||||
var replyTotal int64
|
||||
for _, n := range got.ReplyBins {
|
||||
replyTotal += n
|
||||
}
|
||||
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
|
||||
|
||||
// 最后时间戳
|
||||
require.NotNil(t, got.LastQuestionAt)
|
||||
require.NotNil(t, got.LastAnswerAt)
|
||||
}
|
||||
|
||||
// TestTodayAnswersIgnoreNonAnswerWrites 「今日回答」只数今天首次被回答的提问。
|
||||
//
|
||||
// 原来按 updated_at 算,而置顶、公开/私密切换都会刷新 updated_at:
|
||||
// 箱主今晚整理一批三个月前的老问题(一次回答都没有),页面上「今日回答」
|
||||
// 就凭空多出几十条。改成 answered_at 后,只有真的发生了「首次回答」才算。
|
||||
func TestTodayAnswersIgnoreNonAnswerWrites(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
store := NewSiteAnalyticsStore(conn)
|
||||
questions := NewQuestionsStore(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
old := now.Add(-72 * time.Hour)
|
||||
|
||||
// 三天前提出并回答的历史问题
|
||||
legacy, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "旧问题", FromIP: "1.1.1.1"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, legacy.ID, "旧答案"))
|
||||
// 把三个时间都冻回三天前(AnswerByID 会把 answered_at / updated_at 刷成当下)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("created_at", old).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("answered_at", old).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("updated_at", old).Error)
|
||||
|
||||
// 今天真的答了一条
|
||||
fresh, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "新问题", FromIP: "1.1.1.2"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, questions.AnswerByID(ctx, fresh.ID, "今天的答案"))
|
||||
|
||||
_, answers, err := store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, answers)
|
||||
|
||||
// 箱主今晚翻旧账:置顶、切私密、再切回公开 —— 一次回答都没发生
|
||||
require.NoError(t, questions.SetPinned(ctx, legacy.ID, true))
|
||||
require.NoError(t, questions.SetPrivate(ctx, legacy.ID))
|
||||
require.NoError(t, questions.SetPublic(ctx, legacy.ID))
|
||||
|
||||
_, answers, err = store.TodayCounts(ctx, dayStart)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, answers, "置顶 / 公开切换不该算成「今日回答」")
|
||||
}
|
||||
|
||||
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
|
||||
func TestSiteAnalyticsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := newTestDB(t)
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("users", "questions", "messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, got.TotalQuestions)
|
||||
require.Zero(t, got.RegisteredUsers)
|
||||
require.Nil(t, got.LastQuestionAt)
|
||||
require.Nil(t, got.LastAnswerAt)
|
||||
|
||||
var heatTotal int64
|
||||
for _, row := range got.Heatmap {
|
||||
for _, n := range row {
|
||||
heatTotal += n
|
||||
}
|
||||
}
|
||||
require.Zero(t, heatTotal)
|
||||
}
|
||||
|
||||
func countOf(points []CountPoint, key string) int64 {
|
||||
for _, p := range points {
|
||||
if p.Key == key {
|
||||
return p.N
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2022 E99p1ant. 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"
|
||||
)
|
||||
|
||||
// TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。
|
||||
//
|
||||
// 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换
|
||||
// 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」,
|
||||
// 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。
|
||||
func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
store := NewQuestionsStore(conn)
|
||||
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first"))
|
||||
|
||||
var got Question
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at")
|
||||
firstAt := *got.AnsweredAt
|
||||
require.WithinDuration(t, time.Now(), firstAt, time.Minute)
|
||||
|
||||
// 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。
|
||||
require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited"))
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt)
|
||||
require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at")
|
||||
}
|
||||
|
||||
// TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。
|
||||
func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
q := Question{UserID: 1, Content: "secret"}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer"))
|
||||
|
||||
var got Question
|
||||
require.NoError(t, conn.First(&got, q.ID).Error)
|
||||
require.NotNil(t, got.AnsweredAt)
|
||||
require.True(t, got.IsPrivate)
|
||||
require.True(t, got.IsRead)
|
||||
}
|
||||
|
||||
// TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。
|
||||
//
|
||||
// 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下
|
||||
// (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。
|
||||
// 若仍在用 updated_at,这条会进第四档而不是第一档。
|
||||
func TestReplyBinsUseAnsweredAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
now := time.Now()
|
||||
createdAt := now.Add(-72 * time.Hour)
|
||||
answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档
|
||||
q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
|
||||
// 直接写列,绕开 GORM 的 UpdatedAt 自动更新。
|
||||
require.NoError(t, conn.Exec(
|
||||
"UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?",
|
||||
"answer", true, answeredAt, now, q.ID).Error)
|
||||
|
||||
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||
require.NoError(t, err)
|
||||
|
||||
var total int64
|
||||
for _, n := range got.ReplyBins {
|
||||
total += n
|
||||
}
|
||||
require.EqualValues(t, 1, total)
|
||||
require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档")
|
||||
require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏")
|
||||
|
||||
// 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。
|
||||
require.NotNil(t, got.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute)
|
||||
}
|
||||
|
||||
// TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空,
|
||||
// 退回 updated_at,不能让这些提问从分布里消失。
|
||||
func TestReplyBinsFallBackToUpdatedAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
now := time.Now()
|
||||
// 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。
|
||||
createdAt := now.Add(-10 * 24 * time.Hour)
|
||||
updatedAt := now.Add(-9 * 24 * time.Hour)
|
||||
q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
require.NoError(t, conn.Exec(
|
||||
"UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?",
|
||||
"old answer", updatedAt, q.ID).Error)
|
||||
|
||||
got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, got.Answered)
|
||||
require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布")
|
||||
}
|
||||
|
||||
// TestQuestionStatsLastAnswerIgnoresLaterEdits 「最后一个回答」不能跟着置顶/公开切换跑。
|
||||
//
|
||||
// QuestionStats 仍被公开接口 /api/v1/site-stats 使用。它原来取 MAX(updated_at),
|
||||
// 而 updated_at 会被 SetPinned / SetPrivate / SetPublic / SetRead 刷新,
|
||||
// 于是箱主整理一下旧问题(哪怕一次回答都没发生),接口就说「刚刚有人回答了」。
|
||||
func TestQuestionStatsLastAnswerIgnoresLaterEdits(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
store := NewQuestionsStore(conn)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Truncate(time.Second)
|
||||
answeredAt := now.Add(-9 * 24 * time.Hour)
|
||||
|
||||
q := Question{UserID: 1, Content: "q", CreatedAt: answeredAt.Add(-time.Hour), UpdatedAt: answeredAt}
|
||||
require.NoError(t, conn.Create(&q).Error)
|
||||
require.NoError(t, store.AnswerByID(ctx, q.ID, "answer"))
|
||||
// AnswerByID 会把两个时间刷成当下,冻回真实回答时刻(模拟一条正常的历史数据)。
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
|
||||
UpdateColumn("updated_at", answeredAt).Error)
|
||||
require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID).
|
||||
UpdateColumn("answered_at", answeredAt).Error)
|
||||
|
||||
// 全是「没回答任何东西」的写操作。
|
||||
require.NoError(t, store.SetPinned(ctx, q.ID, true))
|
||||
require.NoError(t, store.SetPrivate(ctx, q.ID))
|
||||
require.NoError(t, store.SetPublic(ctx, q.ID))
|
||||
require.NoError(t, store.SetRead(ctx, q.ID))
|
||||
|
||||
qs, err := store.QuestionStats(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, qs.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Minute,
|
||||
"最后一个回答被置顶/公开切换刷新过的 updated_at 带偏了")
|
||||
}
|
||||
|
||||
// TestQuestionStatsLastAnswerFallsBackToUpdatedAt 加列之前的历史行 answered_at 为空,
|
||||
// 退回 updated_at —— 与改动前的行为一致,不能让这些行从统计里消失。
|
||||
func TestQuestionStatsLastAnswerFallsBackToUpdatedAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
answeredAt := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'legacy','a',false,true,false,?,?)",
|
||||
answeredAt.Add(-time.Hour), answeredAt).Error)
|
||||
|
||||
qs, err := NewQuestionsStore(conn).QuestionStats(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, qs.LastAnswerAt)
|
||||
require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Second)
|
||||
}
|
||||
|
||||
// TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。
|
||||
func TestBackfillAnsweredAt(t *testing.T) {
|
||||
conn, clean := newTestDB(t)
|
||||
defer clean("questions")
|
||||
|
||||
// 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。
|
||||
require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at"))
|
||||
|
||||
now := time.Now()
|
||||
createdAt := now.Add(-20 * 24 * time.Hour)
|
||||
answeredAtOld := now.Add(-19 * 24 * time.Hour)
|
||||
|
||||
// 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)",
|
||||
createdAt, answeredAtOld).Error)
|
||||
require.NoError(t, conn.Exec(
|
||||
"INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)",
|
||||
createdAt, answeredAtOld).Error)
|
||||
|
||||
require.NoError(t, backfillAnsweredAt(conn))
|
||||
|
||||
var answered Question
|
||||
require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error)
|
||||
require.NotNil(t, answered.AnsweredAt)
|
||||
require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second)
|
||||
|
||||
var open Question
|
||||
require.NoError(t, conn.Where("content = ?", "open").First(&open).Error)
|
||||
require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "回答日应出现在趋势结果里")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||
|
||||
+45
-23
@@ -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,32 +95,16 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate 只负责声明式的「加表 / 加列 / 加索引」——这些变更幂等、无数据丢失风险。
|
||||
if err := db.AutoMigrate(AllTables...); err != nil {
|
||||
return nil, errors.Wrap(err, "auto migrate")
|
||||
}
|
||||
|
||||
// 功能开关初始化:为历史账号补齐 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")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
@@ -119,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)
|
||||
@@ -127,6 +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 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
@@ -139,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")
|
||||
}
|
||||
@@ -217,6 +236,9 @@ func dedupeUniqueFields(db *gorm.DB) error {
|
||||
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
|
||||
// 全站数据页(/stats):按 created_at 做范围扫描后按天/小时分组。
|
||||
// 没有这个索引时,趋势与热力图会退化成全表扫描 + 排序;加上之后是范围扫描 + 聚合。
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_created_at ON questions(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
|
||||
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
|
||||
// 上面那条中间隔着 is_private,帮不上这个排序。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "迁移必须幂等")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+126
-36
@@ -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,13 +43,19 @@ 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 且未回答)。
|
||||
// excludeID 用于跳过正在查看的那条,避免在两条未读之间来回跳。
|
||||
NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error)
|
||||
SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
|
||||
GetHotQuestions(ctx context.Context, limit int) ([]*Question, error)
|
||||
|
||||
// 全站统计
|
||||
QuestionStats(ctx context.Context) (*QuestionStats, error)
|
||||
@@ -59,7 +66,7 @@ type QuestionStats struct {
|
||||
TotalQuestions int64
|
||||
AnsweredQuestions int64
|
||||
LastQuestionAt *time.Time // 最后一个提问发生时间(questions.created_at 最大值)
|
||||
LastAnswerAt *time.Time // 最后一个回答发生时间(已回答问题的 updated_at 最大值)
|
||||
LastAnswerAt *time.Time // 最后一个回答发生时间(answered_at 最大值,历史行退回 updated_at)
|
||||
}
|
||||
|
||||
func NewQuestionsStore(db *gorm.DB) QuestionsStore {
|
||||
@@ -83,6 +90,13 @@ type Question struct {
|
||||
AskerUserID uint `json:"-"`
|
||||
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
|
||||
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
|
||||
// AnsweredAt 首次回答的时间,用于算「回答耗时」。为空表示还没回答过。
|
||||
//
|
||||
// 不能拿 updated_at 当回答时间:GORM 的 Update 会自动刷新 updated_at,
|
||||
// 而已读、置顶、公开/私密切换都走 Update,回答之后再点一次「已读」
|
||||
// 就会把耗时撑大(实测:一条 10 天前的提问被 SetRead 后 updated_at 直接推到当下)。
|
||||
// 只在 answer 由空变非空时写一次,改写回答不重置。
|
||||
AnsweredAt *time.Time `json:"-"`
|
||||
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
|
||||
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
|
||||
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
|
||||
@@ -97,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,
|
||||
@@ -107,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 {
|
||||
@@ -156,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 {
|
||||
@@ -391,7 +446,7 @@ func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer stri
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"answer": answer, "is_read": true, "is_private": true}).Error; err != nil {
|
||||
Updates(answerUpdates(question.Answer, answer, map[string]interface{}{"is_private": true})).Error; err != nil {
|
||||
return errors.Wrap(err, "update question answer as private")
|
||||
}
|
||||
return nil
|
||||
@@ -407,43 +462,69 @@ func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) err
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
|
||||
Updates(answerUpdates(question.Answer, answer, nil)).Error; err != nil {
|
||||
return errors.Wrap(err, "update question answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// answerUpdates 组装回答写入的字段集合。
|
||||
//
|
||||
// 只有「原本没回答」时才写 answered_at:改写已有回答不该重置首次回答时间,
|
||||
// 否则「回答耗时」会跟着编辑动作变大。extra 用于合并调用方自己的附加字段
|
||||
// (如强制私密),保持仍然只有一条 UPDATE。
|
||||
func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[string]interface{} {
|
||||
updates := map[string]interface{}{"answer": answer, "is_read": true}
|
||||
for k, v := range extra {
|
||||
updates[k] = v
|
||||
}
|
||||
if prevAnswer == "" {
|
||||
updates["answered_at"] = time.Now()
|
||||
}
|
||||
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 {
|
||||
@@ -490,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
|
||||
}
|
||||
@@ -544,27 +636,14 @@ func escapeLike(s string) string {
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// GetHotQuestions 返回"热门问题":最新活跃的公开已回答提问(按回答更新时间倒序)。
|
||||
// 提问箱没有点赞数据,用"最近被回答"作为热度近似,适合主页/探索页展示。
|
||||
func (db *questions) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
var questions []*Question
|
||||
if err := db.WithContext(ctx).
|
||||
Where("is_private = ? AND answer != ''", false).
|
||||
Order("updated_at DESC").
|
||||
Limit(limit).
|
||||
Find(&questions).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get hot questions")
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// QuestionStats 返回全站提问相关聚合统计。
|
||||
// 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。
|
||||
// 最后一个回答时间取"已回答问题"的 updated_at 最大值——回答动作会更新 updated_at,
|
||||
// 与 crontab 旧逻辑(answer 非空记录中 updated_at 最大)一致。
|
||||
//
|
||||
// 最后一个回答时间取 answered_at,不取 updated_at:GORM 的 Update 一律刷新
|
||||
// updated_at,而置顶、公开/私密切换、标记已读都会走 Update,于是「最后一个回答」
|
||||
// 会被这些与回答无关的操作推到当下(实测一条 9 天前回答的提问,置顶后 LastAnswerAt
|
||||
// 变成"刚刚")。answered_at 为空的是加列之前的历史行,退回 updated_at,
|
||||
// 与改动前的口径一致,不会让这些行从统计里消失。
|
||||
func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) {
|
||||
var total, answered int64
|
||||
if err := db.WithContext(ctx).Model(&Question{}).Count(&total).Error; err != nil {
|
||||
@@ -579,7 +658,7 @@ func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error)
|
||||
LastA *time.Time `gorm:"column:last_a"`
|
||||
}
|
||||
if err := db.WithContext(ctx).Model(&Question{}).
|
||||
Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer != '' THEN updated_at ELSE NULL END) AS last_a").
|
||||
Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a").
|
||||
Scan(&t).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "get last question/answer time")
|
||||
}
|
||||
@@ -591,3 +670,14 @@ func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error)
|
||||
LastAnswerAt: t.LastA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// backfillAnsweredAt 为「加列之前就已经回答」的提问冻结回答时间。
|
||||
//
|
||||
// 只能在刚加上 answered_at 这一列时跑一次(调用方按 HasColumn 判断)。
|
||||
// 取值用 updated_at 当近似 —— 那是当时唯一能拿到的可信时间。
|
||||
// 之后这些行的 updated_at 还会被已读/置顶继续往前推,冻结住才不会让
|
||||
// 历史「回答耗时」一天天变大,看着像统计出错。
|
||||
func backfillAnsweredAt(db *gorm.DB) error {
|
||||
return db.Exec(
|
||||
`UPDATE questions SET answered_at = updated_at WHERE answer <> '' AND answered_at IS NULL`).Error
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¤t, 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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") }()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"` // 留空表示关闭
|
||||
}
|
||||
|
||||
+163
-3
@@ -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",
|
||||
@@ -399,12 +414,17 @@ var enDict = map[string]string{
|
||||
"qlist.pinned": "Pinned",
|
||||
"qlist.pin": "Pin",
|
||||
"qlist.unpin": "Unpin",
|
||||
"qlist.readStatus": "Read status",
|
||||
"qlist.read": "Read",
|
||||
"qlist.unread": "Unread",
|
||||
"qlist.statsRead": "Read",
|
||||
"qlist.statsUnread": "Unread",
|
||||
|
||||
// Filter bar: label is "Read" (not "Read status") so it fits the narrow mobile column;
|
||||
// Yes/No is used instead of Read/Unread for the same reason.
|
||||
"qlist.readFilter": "Read",
|
||||
"qlist.filterYes": "Yes",
|
||||
"qlist.filterNo": "No",
|
||||
|
||||
// Question submitted page (v4)
|
||||
"success.title": "Question sent successfully!",
|
||||
"success.yourQuestion": "Your question:",
|
||||
@@ -614,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",
|
||||
@@ -623,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",
|
||||
@@ -752,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",
|
||||
@@ -833,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",
|
||||
@@ -898,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",
|
||||
@@ -921,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",
|
||||
@@ -946,4 +978,132 @@ var enDict = map[string]string{
|
||||
"theme.name.sakura": "Sakura",
|
||||
"theme.name.shiroko": "Shiroko",
|
||||
"theme.name.new-sakura": "New Sakura",
|
||||
|
||||
// Site statistics page (/stats): aggregate numbers only
|
||||
"site.footer.stats": "Statistics",
|
||||
"stats.title": "Site statistics",
|
||||
"stats.lead": "Only aggregate numbers here: how many questions the site receives, how fast they get answered, and when people tend to show up. No individual question, asker identity, or source address is shown.",
|
||||
"stats.unavailable": "Statistics are temporarily unavailable. Please check back later.",
|
||||
|
||||
"stats.today.questions": "Asked today",
|
||||
"stats.today.answers": "Answered today",
|
||||
"stats.today.tag": "(live)",
|
||||
|
||||
"stats.kpi.users": "Registered users",
|
||||
"stats.kpi.questions": "Questions asked",
|
||||
"stats.kpi.answered": "Answered",
|
||||
"stats.kpi.answerRate": "Answer rate",
|
||||
"stats.kpi.activeBoxes": "Boxes that received questions",
|
||||
"stats.kpi.messages": "Direct messages",
|
||||
|
||||
"stats.insight.title": "At a glance",
|
||||
"stats.insight.empty": "Not enough question data yet.",
|
||||
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
|
||||
"stats.insight.peak": "Busiest day was %s with %s questions.",
|
||||
"stats.insight.busiest": "Questions cluster around %s %d:00.",
|
||||
"stats.insight.momDown": "%s had %s questions; %s had %s, down %.1f%%.",
|
||||
"stats.insight.momFlat": "%s had %s questions; %s had %s, unchanged.",
|
||||
"stats.insight.momUp": "%s had %s questions; %s had %s, up %.1f%%.",
|
||||
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
|
||||
"stats.insight.private": "%s%% of questions were sent privately.",
|
||||
|
||||
"stats.trend.dailyTitle": "Questions over the last 30 days",
|
||||
"stats.trend.dailyNote": "Daily question count.",
|
||||
"stats.trend.monthlyTitle": "Questions per month (last 12)",
|
||||
"stats.trend.monthlyNote": "The last bar is the current month, still in progress.",
|
||||
|
||||
"stats.mix.title": "Composition",
|
||||
"stats.mix.note": "Three ratios: public or private, answered or not, and whether the asker was signed in.",
|
||||
"stats.mix.public": "Public questions",
|
||||
"stats.mix.answered": "Answered",
|
||||
"stats.mix.login": "From signed-in users",
|
||||
|
||||
"stats.heat.title": "When questions arrive",
|
||||
"stats.heat.note": "Questions from the last %d days grouped by weekday and hour — darker means busier.",
|
||||
"stats.heat.less": "Less",
|
||||
"stats.heat.more": "More",
|
||||
|
||||
"stats.content.title": "Length and pace",
|
||||
"stats.content.note": "How long questions are, and how fast they get answered.",
|
||||
"stats.content.lengthTitle": "Question length",
|
||||
"stats.content.replyTitle": "Time to answer",
|
||||
|
||||
"stats.len.1": "under 20 chars",
|
||||
"stats.len.2": "20–49 chars",
|
||||
"stats.len.3": "50–99 chars",
|
||||
"stats.len.4": "100–199 chars",
|
||||
"stats.len.5": "200–499 chars",
|
||||
"stats.len.6": "500+ chars",
|
||||
|
||||
"stats.reply.1": "within 1 hour",
|
||||
"stats.reply.2": "1–6 hours",
|
||||
"stats.reply.3": "6–24 hours",
|
||||
"stats.reply.4": "1–3 days",
|
||||
"stats.reply.5": "3–7 days",
|
||||
"stats.reply.6": "over 7 days",
|
||||
|
||||
"stats.wd.1": "Mon",
|
||||
"stats.wd.2": "Tue",
|
||||
"stats.wd.3": "Wed",
|
||||
"stats.wd.4": "Thu",
|
||||
"stats.wd.5": "Fri",
|
||||
"stats.wd.6": "Sat",
|
||||
"stats.wd.7": "Sun",
|
||||
"stats.weekday.1": "Monday",
|
||||
"stats.weekday.2": "Tuesday",
|
||||
"stats.weekday.3": "Wednesday",
|
||||
"stats.weekday.4": "Thursday",
|
||||
"stats.weekday.5": "Friday",
|
||||
"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:",
|
||||
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
|
||||
"stats.foot.cacheNote": "Totals and charts are updated once a day; the two \"today\" figures are live.",
|
||||
}
|
||||
|
||||
+162
-3
@@ -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": "無題の投票",
|
||||
@@ -399,12 +414,16 @@ var jaDict = map[string]string{
|
||||
"qlist.pinned": "ピン留め中",
|
||||
"qlist.pin": "ピン留め",
|
||||
"qlist.unpin": "ピン留め解除",
|
||||
"qlist.readStatus": "既読ステータス",
|
||||
"qlist.read": "既読",
|
||||
"qlist.unread": "未読",
|
||||
"qlist.statsRead": "既読",
|
||||
"qlist.statsUnread": "未読",
|
||||
|
||||
// 絞り込みバー:ラベルは「既読」だけ(「既読ステータス」はスマホの狭い列で折り返す)
|
||||
"qlist.readFilter": "既読",
|
||||
"qlist.filterYes": "はい",
|
||||
"qlist.filterNo": "いいえ",
|
||||
|
||||
// 質問送信完了ページ(v4)
|
||||
"success.title": "質問を送信しました!",
|
||||
"success.yourQuestion": "あなたの質問:",
|
||||
@@ -614,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": "平均順位",
|
||||
@@ -623,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": "その他",
|
||||
@@ -752,6 +773,7 @@ var jaDict = map[string]string{
|
||||
|
||||
// 質問箱ページ:訪問者に表示(P0)
|
||||
"question.errWrongPassword": "パスワードが正しくありません",
|
||||
"question.errPasswordLocked": "パスワードの誤入力が多すぎます。%d 分後にもう一度お試しください。",
|
||||
"question.errAskPasswordWrong": "質問用パスワードが正しくありません",
|
||||
"question.errLoginRequired": "質問箱の所有者は登録ユーザーのみ質問できるように設定しています。先にログインしてください。",
|
||||
"question.errSelfChat": "自分自身とのチャットは開始できません",
|
||||
@@ -833,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 つの項目が必要です",
|
||||
@@ -898,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": "不正なリクエストパス",
|
||||
@@ -921,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": "返信を受け取るための有効なメールアドレスを入力してください",
|
||||
@@ -946,4 +977,132 @@ var jaDict = map[string]string{
|
||||
"theme.name.sakura": "さくら",
|
||||
"theme.name.shiroko": "シロコ",
|
||||
"theme.name.new-sakura": "新さくら",
|
||||
|
||||
// サイト統計ページ(/stats):集計値のみ
|
||||
"site.footer.stats": "統計データ",
|
||||
"stats.title": "サイト統計",
|
||||
"stats.lead": "ここにあるのは集計値だけです。サイト全体にどれだけ質問が届き、どれくらいで回答され、いつ人が来るのか。個々の質問内容・質問者の身元・送信元アドレスは一切表示しません。",
|
||||
"stats.unavailable": "統計データを一時的に取得できません。しばらくしてから再度お試しください。",
|
||||
|
||||
"stats.today.questions": "今日の質問",
|
||||
"stats.today.answers": "今日の回答",
|
||||
"stats.today.tag": "(リアルタイム)",
|
||||
|
||||
"stats.kpi.users": "登録ユーザー",
|
||||
"stats.kpi.questions": "質問の総数",
|
||||
"stats.kpi.answered": "回答済み",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "質問を受け取った箱",
|
||||
"stats.kpi.messages": "DM の総数",
|
||||
|
||||
"stats.insight.title": "ひと目でわかる数字",
|
||||
"stats.insight.empty": "まだ分析できるほどの質問データがありません。",
|
||||
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
|
||||
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
|
||||
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
|
||||
"stats.insight.momDown": "%sは %s 件、%sは %s 件で %.1f%% 減少しました。",
|
||||
"stats.insight.momFlat": "%sは %s 件、%sは %s 件で変わりません。",
|
||||
"stats.insight.momUp": "%sは %s 件、%sは %s 件で %.1f%% 増加しました。",
|
||||
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
|
||||
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
|
||||
|
||||
"stats.trend.dailyTitle": "直近 30 日の質問数",
|
||||
"stats.trend.dailyNote": "1 日ごとの質問数です。",
|
||||
"stats.trend.monthlyTitle": "直近 12 か月の質問数",
|
||||
"stats.trend.monthlyNote": "最後の棒は当月で、まだ集計中です。",
|
||||
|
||||
"stats.mix.title": "内訳",
|
||||
"stats.mix.note": "3 つの割合:公開か非公開か、回答済みか、ログイン済みの利用者か。",
|
||||
"stats.mix.public": "公開の質問",
|
||||
"stats.mix.answered": "回答済み",
|
||||
"stats.mix.login": "ログイン利用者の質問",
|
||||
|
||||
"stats.heat.title": "質問が届く時間帯",
|
||||
"stats.heat.note": "直近 %d 日の質問を「曜日 × 時間」で集計。色が濃いほど集中しています。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "長さと速さ",
|
||||
"stats.content.note": "質問の長さと、回答までの速さ。",
|
||||
"stats.content.lengthTitle": "質問の文字数",
|
||||
"stats.content.replyTitle": "回答までの時間",
|
||||
|
||||
"stats.len.1": "20 文字未満",
|
||||
"stats.len.2": "20–49 文字",
|
||||
"stats.len.3": "50–99 文字",
|
||||
"stats.len.4": "100–199 文字",
|
||||
"stats.len.5": "200–499 文字",
|
||||
"stats.len.6": "500 文字以上",
|
||||
|
||||
"stats.reply.1": "1 時間以内",
|
||||
"stats.reply.2": "1–6 時間",
|
||||
"stats.reply.3": "6–24 時間",
|
||||
"stats.reply.4": "1–3 日",
|
||||
"stats.reply.5": "3–7 日",
|
||||
"stats.reply.6": "7 日以上",
|
||||
|
||||
"stats.wd.1": "月",
|
||||
"stats.wd.2": "火",
|
||||
"stats.wd.3": "水",
|
||||
"stats.wd.4": "木",
|
||||
"stats.wd.5": "金",
|
||||
"stats.wd.6": "土",
|
||||
"stats.wd.7": "日",
|
||||
"stats.weekday.1": "月曜日",
|
||||
"stats.weekday.2": "火曜日",
|
||||
"stats.weekday.3": "水曜日",
|
||||
"stats.weekday.4": "木曜日",
|
||||
"stats.weekday.5": "金曜日",
|
||||
"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": "最後の回答:",
|
||||
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
|
||||
"stats.foot.cacheNote": "累計値とグラフは 1 日 1 回更新されます。「今日」の 2 項目はリアルタイムです。",
|
||||
}
|
||||
|
||||
+163
-3
@@ -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": "未命名投票",
|
||||
@@ -399,12 +414,17 @@ var zhCNDict = map[string]string{
|
||||
"qlist.pinned": "置顶中",
|
||||
"qlist.pin": "置顶",
|
||||
"qlist.unpin": "取消置顶",
|
||||
"qlist.readStatus": "阅读状态",
|
||||
"qlist.read": "已读",
|
||||
"qlist.unread": "未读",
|
||||
"qlist.statsRead": "已读",
|
||||
"qlist.statsUnread": "未读",
|
||||
|
||||
// 筛选栏:标签用「已读」而不是「阅读状态」——四字标签在手机窄列里会折成两行;
|
||||
// 选项 是/否 比 已读/未读 更短,语义等价(「已读:是」= 已读)。
|
||||
"qlist.readFilter": "已读",
|
||||
"qlist.filterYes": "是",
|
||||
"qlist.filterNo": "否",
|
||||
|
||||
// 提问成功页(v4)
|
||||
"success.title": "提问发送成功!",
|
||||
"success.yourQuestion": "你的提问:",
|
||||
@@ -614,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": "平均排名",
|
||||
@@ -623,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": "其他",
|
||||
@@ -752,6 +774,7 @@ var zhCNDict = map[string]string{
|
||||
|
||||
// 提问箱页:访客可见(P0)
|
||||
"question.errWrongPassword": "密码错误",
|
||||
"question.errPasswordLocked": "密码错误次数过多,请 %d 分钟后再试",
|
||||
"question.errAskPasswordWrong": "提问密码错误",
|
||||
"question.errLoginRequired": "提问箱的主人设置了仅注册用户才能提问,请先登录。",
|
||||
"question.errSelfChat": "不能给自己发起私聊",
|
||||
@@ -833,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 个字段",
|
||||
@@ -910,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": "非法的请求路径",
|
||||
@@ -933,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": "请填写有效的电子邮箱地址以接收回复",
|
||||
@@ -946,4 +978,132 @@ var zhCNDict = map[string]string{
|
||||
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
|
||||
"form.register.agreeRules": "请先阅读并同意社区规则",
|
||||
"form.register.repeatPassword": "两次输入的密码不一致",
|
||||
|
||||
// 全站数据页(/stats):只展示聚合数字,不含任何单条内容
|
||||
"site.footer.stats": "统计数据",
|
||||
"stats.title": "全站数据",
|
||||
"stats.lead": "这里只有汇总后的数字:全站收到了多少提问、回答得有多快、大家习惯什么时候来。不含任何一条具体提问的内容、提问者身份或来源地址。",
|
||||
"stats.unavailable": "数据暂时不可用,稍后再刷新看看。",
|
||||
|
||||
"stats.today.questions": "今日提问",
|
||||
"stats.today.answers": "今日回答",
|
||||
"stats.today.tag": "(实时)",
|
||||
|
||||
"stats.kpi.users": "注册用户",
|
||||
"stats.kpi.questions": "累计提问",
|
||||
"stats.kpi.answered": "已回答",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "收到过提问的箱子",
|
||||
"stats.kpi.messages": "累计私聊消息",
|
||||
|
||||
"stats.insight.title": "数据速览",
|
||||
"stats.insight.empty": "提问数据还太少,暂时看不出什么。",
|
||||
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
|
||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
|
||||
"stats.insight.busiest": "提问最集中在%s %d 点前后。",
|
||||
"stats.insight.momDown": "%s收到 %s 条提问,比 %s的 %s 条少了 %.1f%%。",
|
||||
"stats.insight.momFlat": "%s收到 %s 条提问,与 %s的 %s 条持平。",
|
||||
"stats.insight.momUp": "%s收到 %s 条提问,比 %s的 %s 条多了 %.1f%%。",
|
||||
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
|
||||
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
|
||||
|
||||
"stats.trend.dailyTitle": "近 30 天提问趋势",
|
||||
"stats.trend.dailyNote": "按天统计的提问量。",
|
||||
"stats.trend.monthlyTitle": "近 12 个月提问量",
|
||||
"stats.trend.monthlyNote": "最后一根柱子是当月,还没过完。",
|
||||
|
||||
"stats.mix.title": "构成",
|
||||
"stats.mix.note": "三组比例:公开还是私密、有没有被回答、提问者有没有登录。",
|
||||
"stats.mix.public": "公开提问",
|
||||
"stats.mix.answered": "已回答",
|
||||
"stats.mix.login": "登录用户提问",
|
||||
|
||||
"stats.heat.title": "一周内的提问时段",
|
||||
"stats.heat.note": "最近 %d 天的提问按「星期 × 小时」聚合,颜色越深表示越集中。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "内容与节奏",
|
||||
"stats.content.note": "提问有多长,回答有多快。",
|
||||
"stats.content.lengthTitle": "提问字数分布",
|
||||
"stats.content.replyTitle": "回答耗时分布",
|
||||
|
||||
"stats.len.1": "20 字以内",
|
||||
"stats.len.2": "20–49 字",
|
||||
"stats.len.3": "50–99 字",
|
||||
"stats.len.4": "100–199 字",
|
||||
"stats.len.5": "200–499 字",
|
||||
"stats.len.6": "500 字以上",
|
||||
|
||||
"stats.reply.1": "1 小时内",
|
||||
"stats.reply.2": "1–6 小时",
|
||||
"stats.reply.3": "6–24 小时",
|
||||
"stats.reply.4": "1–3 天",
|
||||
"stats.reply.5": "3–7 天",
|
||||
"stats.reply.6": "7 天以上",
|
||||
|
||||
"stats.wd.1": "一",
|
||||
"stats.wd.2": "二",
|
||||
"stats.wd.3": "三",
|
||||
"stats.wd.4": "四",
|
||||
"stats.wd.5": "五",
|
||||
"stats.wd.6": "六",
|
||||
"stats.wd.7": "日",
|
||||
"stats.weekday.1": "周一",
|
||||
"stats.weekday.2": "周二",
|
||||
"stats.weekday.3": "周三",
|
||||
"stats.weekday.4": "周四",
|
||||
"stats.weekday.5": "周五",
|
||||
"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": "最后一条回答:",
|
||||
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
|
||||
"stats.foot.cacheNote": "累计数字与图表每天更新一次;「今日」两项实时。",
|
||||
}
|
||||
|
||||
+162
-3
@@ -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": "未命名投票",
|
||||
@@ -399,12 +414,16 @@ var zhTWDict = map[string]string{
|
||||
"qlist.pinned": "置頂中",
|
||||
"qlist.pin": "置頂",
|
||||
"qlist.unpin": "取消置頂",
|
||||
"qlist.readStatus": "閱讀狀態",
|
||||
"qlist.read": "已讀",
|
||||
"qlist.unread": "未讀",
|
||||
"qlist.statsRead": "已讀",
|
||||
"qlist.statsUnread": "未讀",
|
||||
|
||||
// 篩選列:標籤用「已讀」,四字標籤在手機窄欄會折成兩行
|
||||
"qlist.readFilter": "已讀",
|
||||
"qlist.filterYes": "是",
|
||||
"qlist.filterNo": "否",
|
||||
|
||||
// 提問成功頁(v4)
|
||||
"success.title": "提問發送成功!",
|
||||
"success.yourQuestion": "你的提問:",
|
||||
@@ -614,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": "平均排名",
|
||||
@@ -623,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": "其他",
|
||||
@@ -752,6 +773,7 @@ var zhTWDict = map[string]string{
|
||||
|
||||
// 提問箱頁:訪客可見(P0)
|
||||
"question.errWrongPassword": "密碼錯誤",
|
||||
"question.errPasswordLocked": "密碼錯誤次數過多,請 %d 分鐘後再試",
|
||||
"question.errAskPasswordWrong": "提問密碼錯誤",
|
||||
"question.errLoginRequired": "提問箱的主人設定了僅註冊使用者才能提問,請先登入。",
|
||||
"question.errSelfChat": "不能給自己發起私聊",
|
||||
@@ -833,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 個欄位",
|
||||
@@ -898,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": "非法的請求路徑",
|
||||
@@ -921,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": "請填寫有效的電子郵件地址以接收回覆",
|
||||
@@ -946,4 +977,132 @@ var zhTWDict = map[string]string{
|
||||
"theme.name.sakura": "櫻花",
|
||||
"theme.name.shiroko": "砂狼白子",
|
||||
"theme.name.new-sakura": "新·櫻花",
|
||||
|
||||
// 全站數據頁(/stats):只顯示彙總數字,不含任何單則內容
|
||||
"site.footer.stats": "統計數據",
|
||||
"stats.title": "全站數據",
|
||||
"stats.lead": "這裡只有彙總後的數字:全站收到多少提問、回答得有多快、大家習慣什麼時候來。不含任何一則具體提問的內容、提問者身分或來源位址。",
|
||||
"stats.unavailable": "數據暫時無法使用,稍後再重新整理看看。",
|
||||
|
||||
"stats.today.questions": "今日提問",
|
||||
"stats.today.answers": "今日回答",
|
||||
"stats.today.tag": "(即時)",
|
||||
|
||||
"stats.kpi.users": "註冊使用者",
|
||||
"stats.kpi.questions": "累計提問",
|
||||
"stats.kpi.answered": "已回答",
|
||||
"stats.kpi.answerRate": "回答率",
|
||||
"stats.kpi.activeBoxes": "收到過提問的箱子",
|
||||
"stats.kpi.messages": "累計私聊訊息",
|
||||
|
||||
"stats.insight.title": "數據速覽",
|
||||
"stats.insight.empty": "提問數據還太少,暫時看不出什麼。",
|
||||
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
|
||||
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
|
||||
"stats.insight.busiest": "提問最集中在%s %d 點前後。",
|
||||
"stats.insight.momDown": "%s收到 %s 則提問,比 %s的 %s 則少了 %.1f%%。",
|
||||
"stats.insight.momFlat": "%s收到 %s 則提問,與 %s的 %s 則持平。",
|
||||
"stats.insight.momUp": "%s收到 %s 則提問,比 %s的 %s 則多了 %.1f%%。",
|
||||
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
|
||||
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
|
||||
|
||||
"stats.trend.dailyTitle": "近 30 天提問趨勢",
|
||||
"stats.trend.dailyNote": "依天統計的提問量。",
|
||||
"stats.trend.monthlyTitle": "近 12 個月提問量",
|
||||
"stats.trend.monthlyNote": "最後一根柱子是當月,還沒過完。",
|
||||
|
||||
"stats.mix.title": "組成",
|
||||
"stats.mix.note": "三組比例:公開還是私密、有沒有被回答、提問者有沒有登入。",
|
||||
"stats.mix.public": "公開提問",
|
||||
"stats.mix.answered": "已回答",
|
||||
"stats.mix.login": "登入使用者提問",
|
||||
|
||||
"stats.heat.title": "一週內的提問時段",
|
||||
"stats.heat.note": "最近 %d 天的提問依「星期 × 小時」彙總,顏色越深表示越集中。",
|
||||
"stats.heat.less": "少",
|
||||
"stats.heat.more": "多",
|
||||
|
||||
"stats.content.title": "內容與節奏",
|
||||
"stats.content.note": "提問有多長,回答有多快。",
|
||||
"stats.content.lengthTitle": "提問字數分布",
|
||||
"stats.content.replyTitle": "回答耗時分布",
|
||||
|
||||
"stats.len.1": "20 字以內",
|
||||
"stats.len.2": "20–49 字",
|
||||
"stats.len.3": "50–99 字",
|
||||
"stats.len.4": "100–199 字",
|
||||
"stats.len.5": "200–499 字",
|
||||
"stats.len.6": "500 字以上",
|
||||
|
||||
"stats.reply.1": "1 小時內",
|
||||
"stats.reply.2": "1–6 小時",
|
||||
"stats.reply.3": "6–24 小時",
|
||||
"stats.reply.4": "1–3 天",
|
||||
"stats.reply.5": "3–7 天",
|
||||
"stats.reply.6": "7 天以上",
|
||||
|
||||
"stats.wd.1": "一",
|
||||
"stats.wd.2": "二",
|
||||
"stats.wd.3": "三",
|
||||
"stats.wd.4": "四",
|
||||
"stats.wd.5": "五",
|
||||
"stats.wd.6": "六",
|
||||
"stats.wd.7": "日",
|
||||
"stats.weekday.1": "週一",
|
||||
"stats.weekday.2": "週二",
|
||||
"stats.weekday.3": "週三",
|
||||
"stats.weekday.4": "週四",
|
||||
"stats.weekday.5": "週五",
|
||||
"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": "最後一則回答:",
|
||||
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
|
||||
"stats.foot.cacheNote": "累計數字與圖表每天更新一次;「今日」兩項即時。",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "<strong>", "片段被转义了,收件人只会看到一堆尖括号")
|
||||
|
||||
// 地图按 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, "{{")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user