From adcc42cc8cb2f7ad25a6355403707feae1947e16 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 15 Sep 2026 11:06:09 +0800 Subject: [PATCH] fix anwser token and database issus --- .workbuddy/memory/2026-09-10.md | 69 ++++++++ .workbuddy/memory/2026-09-11.md | 42 +++++ .workbuddy/memory/2026-09-15.md | 252 ++++++++++++++++++++++++++++ .workbuddy/memory/MEMORY.md | 66 +++++++- dev/gofmt_keep_crlf.py | 63 +++++++ internal/captcha/math.go | 20 +-- internal/conf/conf.go | 21 +-- internal/conf/limits.go | 66 +++----- internal/conf/static.go | 79 ++++----- internal/db/conversation.go | 3 +- internal/db/db.go | 16 +- internal/db/feature_control.go | 86 ++++------ internal/db/follow_ups.go | 34 ++-- internal/db/questions.go | 10 +- internal/db/upload_images_test.go | 10 +- internal/db/users.go | 26 ++- internal/db/users_test.go | 74 ++++++-- internal/form/form.go | 44 ++--- internal/i18n/i18n.go | 10 +- internal/mail/mail.go | 7 +- internal/route/csrf_binding_test.go | 26 +-- internal/route/gzip.go | 40 ++--- internal/route/route.go | 82 ++++----- internal/security/censor/aliyun.go | 166 ++++++------------ internal/security/censor/censor.go | 63 ++++--- internal/security/censor/policy.go | 99 +++-------- internal/security/censor/text.go | 46 ++--- internal/shared/ip.go | 66 +++----- internal/storage/s3.go | 10 +- internal/template/markdown.go | 37 ++-- route/question/follow_up.go | 2 +- route/user/harassment.go | 47 ++---- route/user/vote.go | 131 ++++++--------- 33 files changed, 1011 insertions(+), 802 deletions(-) create mode 100644 .workbuddy/memory/2026-09-11.md create mode 100644 .workbuddy/memory/2026-09-15.md create mode 100644 dev/gofmt_keep_crlf.py diff --git a/.workbuddy/memory/2026-09-10.md b/.workbuddy/memory/2026-09-10.md index 86852c0..beb5104 100644 --- a/.workbuddy/memory/2026-09-10.md +++ b/.workbuddy/memory/2026-09-10.md @@ -381,3 +381,72 @@ RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未 `route/user/moderation.go`、`vote.go`),因为 gofmt 把 CRLF 规范成 LF。 剥离 CR 后比对,本次改动**零格式差异** → 格式是对的。 **切勿跑 `gofmt -w`**,那会把文件转成 LF、造成全量 diff 污染。 + +--- + +## 第七轮:精简注释(删复述、留 WHY) + +用户反馈「注释太多」,要求仅保留重要部分。原则(AskUserQuestion 已确认): +**保守尺度** —— 删除复述型/装饰型,保留全部安全约束、踩坑、外部服务怪癖,长注释压成 1-3 行。 + +### 摸底数据(脚本 `comment_survey.py`,排除 outputs/dev/.workbuddy) + +- `.go` 139 文件 29,185 行,注释 3,102 行(10.6%);`.html` 41 文件 7,784 行,注释 152 行(2.0%)。 +- 最重:`policy.go` 38.9%、`conf/limits.go` 37.8%、`shared/ip.go` 33.0%、`conf/static.go` 27.8%、 + `harassment.go` 25.2%、`aliyun.go` 24.7%(152 行,绝对量最大)、`text.go` 22.2%、`censor.go` 21.3%、 + `form.go` 19.8%、`gzip.go` 19.3%、`markdown.go` 18.1%、`route.go` 17.8%(80 行)。 + +### 第一批已完成(-106 行,-31%) + +| 文件 | 原 | 现 | 减 | +|---|---|---|---| +| `policy.go` | 84 | 52 | -32(-38%)| +| `conf/limits.go` | 45 | 25 | -20(-44%)| +| `shared/ip.go` | 58 | 32 | -26(-45%)| +| `aliyun.go` | 152 | 124 | -28(-18%,**仅处理到前 180 行,余下待续**)| + +保留的关键信息(勿再删):fail-closed 语义、严格档 ⊇ 改造前、底线维度不可关、 +阿里云 596/600 字上限与分段重叠、格式标记防历史缓存误判「通过」、SDK 标签表不可信、 +IPv6 /64 聚合仅限流不可用于黑名单、源站未锁可被伪造头、双 CDN 必须删对方权威头。 + +### ⚠️ 复用方法(重要) + +1. **删注释会触发 gofmt 重排**:删掉字段/映射项之间的注释后,相邻行合并成一个对齐组, + 列宽会变。手算列宽易错 → 用脚本 `gofmt_keep_crlf.py `: + **跑 gofmt 后按原文件行尾还原 CRLF**(直接 `gofmt -w` 会把 CRLF 转成 LF,造成全量 diff 污染)。 +2. **仓库行尾本就不统一**(`policy.go`/`aliyun.go`/`text.go`/`dict_*`/`profile.html` 是 CRLF; + `limits.go`/`features.go`/`password.go` 是 LF)。改动前先确认,改后保持一致。 +3. `grep -c $'\r' file` 在 Git Bash 下**不可靠**(`$'\r'` 可能被当空模式,导致「全部行命中」)。 + 查行尾要用 Python 数 `b'\r\n'`。 +4. `templates/user/moderation-strictness.html` 仍**只能用 shell/脚本改**(Write/Edit 会注入 + `data-page-node-id`)。 + +### 验证 + +`go build ./internal/security/... ./internal/shared/... ./internal/conf/...` 通过; +`go test ./internal/security/censor/` ok(严格度与 2.0 用例全过,确认删注释未影响行为)。 + +--- + +## 续:注释清理第二批 + 编译卡死根因(晚间) + +### 第二批已清理(本轮继续) + +`conf/static.go`、`conf/conf.go`、`security/censor/{text,censor,aliyun}.go`、`route/{gzip,route}.go`、 +`form/form.go`、`template/markdown.go`、`db/{db,feature_control}.go`、`captcha/math.go`、 +`route/user/harassment.go`、`route/user/vote.go`(最大,~149→少,N+1→2SQL、sanitizeCSVCell、BOM 等 WHY 保留)、 +`db/follow_ups.go`(部分,待构建释放后收尾)、`security/censor/policy.go`(最高比 28%→压缩,保 WHY)、 +`i18n/i18n.go`(删内联复述、修 typo)。 + +### 编译卡死根因(已定位并绕过) + +- 真因:`proxy.golang.org` 在本沙箱 `HTTP=000`(连接超时),`go build` 触网即无限等待 → 表象「卡死」。 + `goproxy.cn` → 200。解法:`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod` + **定向包构建**(勿 `./...`)。 +- `go env -w` 失败(`%AppData% is not defined`),无法持久化,只能每条命令 export。 +- 重活儿构建(`./route/user/ ./internal/route/ ./internal/db/`,overlay 换 s3 stub 避 cgo/webp)已用上述代理跑通、进行中(1C1G 首编 20+ 分钟)。 + +### 行尾结论 + +`core.autocrlf=true` → 工作区 CRLF、blob LF,Git 归一,**无 diff 污染**,不必手动转行尾。 +`gofmt_keep_crlf.py` 重建到 `dev/gofmt_keep_crlf.py`(Temp 版被清),保留工作区 CRLF 以免 gofmt -l 误报。 + diff --git a/.workbuddy/memory/2026-09-11.md b/.workbuddy/memory/2026-09-11.md new file mode 100644 index 0000000..c07c996 --- /dev/null +++ b/.workbuddy/memory/2026-09-11.md @@ -0,0 +1,42 @@ +# 2026-09-11 工作日志 + +## 代码审阅(全库,安全/正确性/可优化) + +结论:**无 P0、无 P1 可利用漏洞**;仅 P2(一致性 / 健壮性 / 性能)。 + +### 已验证「没问题」的项(勿重复排查) + +1. **越权**:所有写接口均有归属校验 —— `getOwnedVote` 显式比对 `vote.UserID != ctx.User.ID`(vote.go:388-401); + question 路由挂在 `question.Questioner` 中间件组(route.go:262-274),`PublishAnswer` 二次校验 `question.UserID`; + follow-up 写操作以 `question.ID` 为作用域(`AnswerByID`/`UpdateAnswerByID`)。profile/password/harassment/tags/features 一律用 `ctx.User.ID`。 +2. **SQL 注入**:`internal/db` 全部用 `?` 绑定参数;拼接仅出现在常量/配置(dedupeUniqueFields、tagCond、randomFunc)。 +3. **XSS**:`internal/template/markdown.go` 白名单消毒扎实 —— strip `on*`、禁 `style`、`safeURL` 协议白名单 + (http/https/mailto/相对)、拒 `//` 协议相对、强制 `rel="noopener noreferrer"`。 +4. **竞态**:follow-up 用事务 + `FOR UPDATE` 行锁;投票「一人一票」用复合唯一索引。 +5. **GORM bool 默认值陷阱**:已规避(`Enabled` 不加 `gorm:"default:true"`,见 feature_control.go:79-81), + 且 `reconcileLegacyChatEnabled`(:609-618)修复旧 backfill 脏行。 +6. **审核缓存回放无 fail-open**:`AliyunTextCensorParser`(aliyun.go:433-461)与 `QiniuTextCensorParser` + (qiniu.go:133-170)都会从 raw 重建 `Details`,故 `Decide` 仍能按当前档位裁决(改档位对历史缓存同样生效)。 + ⚠️ text.go:66 注释称「回放缓存结果时为 nil」**是错的**,需修正。 + +### P2 清单(待用户选批后落地) + +| # | 文件:行 | 问题 | 建议 | +|---|---|---|---| +| 1 | censor.go:72,111,118-125 | 缓存按**原文** `text` 查,实际送审 `RemoveTrustedURL(text)`,落库 `Input` 存原文 → 审计与实际送审不一致 + 归一化相同文本重复调 API | 归一化一次,查找/送审/落库统一用归一化文本,原文另存字段 | +| 2 | censor.go:85-102 | qiniu 缓存命中仅 `Pass` 才返回,aliyun 命中即返回(不看 Pass)→ 依赖「aliyun 恒为链尾」隐式前提 | 两分支语义对齐,或显式固化链尾不变量 | +| 3 | text.go:66 | 注释失真(称回放时 Details 为 nil,实际会重建) | 修正注释 | +| 4 | route/vote/vote.go:814-822 | 配额用「插入后计数」判定,非原子条件插入 → 并发可短暂超额(有界、自动结束自愈) | 改 `WHERE count 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-`,测试结束(未失败时)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` 占用的 `i@github.red`/`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` 反复报未格式化。**注释里避免使用 `''`**(改写为「空字符串」等文字)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 2c0bbce..5dfda01 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -16,10 +16,39 @@ go test -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./... ``` (`dev/overlay.json` 里的路径指向 TAMAKY~1 短名,可能失效;用上面 Temp 里的那份。) -- `internal/db` 的测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`), - 与业务改动无关,可跳过;`go vet` 已能覆盖其编译。 -- **`proxy.golang.org` 在本沙箱返回 Bad Gateway**:拉新依赖 / `go mod tidy` 前先 - `export GOPROXY=https://goproxy.cn,direct`。 +- `internal/db` 测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`)。 + **用户本地有测试 PG**:`DB_TYPE=postgres DB_USER=postgres DB_PASSWORD=15207786347hXPm DB_HOST=127.0.0.1 DB_PORT=5432` + (DSN 形如 `postgres://user:pwd@host:port?sslmode=disable`,不带库名;`NewTestDB` 会自建 + `tamabox-test-` 并在成功时 DROP)。跑法: + ``` + DB_TYPE=postgres DB_USER=postgres DB_PASSWORD= DB_HOST=127.0.0.1 DB_PORT=5432 \ + go test -overlay= -count=1 ./internal/db/ + ``` + ✅ 2026-09-15 起 `go test ./...`(带 DB)**全绿**。此前该套件的一大堆失败**全是测试自身缺陷** + (bcrypt 直接比哈希、测试数据撞车、顶层 `t.Parallel()` + 共享表 + `TRUNCATE ... RESTART IDENTITY`、 + cleanup 漏清 `upload_image_questions`),已修。 +- ⚠️ **`internal/db` 包级 store 单例(`Users`/`Questions`/`FollowUps`/`UploadImages`/`FeatureControls`…) + 只在 `db.Init` 装配**。若某 store 方法内部直接调这些全局单例,测试不装配 → **nil panic**。 + 2026-09-15 已把 `DeleteByID`、`MarkDeletedByOwner` 改为 `NewXStore(db.DB)`(用自身 store 的 `*gorm.DB`)。 + **新增此类跨 store 调用时务必用 `NewXStore(db.DB)`,不要引用包级全局。** `feature_control.go` 的 + `FeatureStateOf` 是唯一例外(有 nil 守卫,返回默认配置)。 +- **「编译卡死」真因 = `proxy.golang.org` 不可达**:本沙箱 `curl -m10 proxy.golang.org` → `HTTP=000`(连接超时,rc=28), + 而 `goproxy.cn` → `HTTP=200`。任何触及网络的 `go build/test/mod` 会**无限等待代理超时**,表象就是「卡死」。 + `go env` 默认 `GOPROXY=https://proxy.golang.org,direct` → 必卡。 + ✅ 解法(每条 go 命令前都要):`export GOPROXY=https://goproxy.cn,direct GOSUMDB=off GOFLAGS=-mod=mod`。 + ⚠️ **「整库编译慢 / 卡死」是伪命题**:只要 GOPROXY 正确,`go build ./...` 实测 **EXIT=0**, + `./route/user/` 只要 **7 秒**(缓存热时全库也是秒级)。此前 20+ 分钟/240s 超时**全是代理挂起**。 + ⚠️ **1C1G 是用户的正式生产环境,不是沙箱** —— 禁止用「沙箱慢/环境限制」解释任何超时(2026-09-15 用户纠正)。 + ⚠️ 方法论:**超时 ≠ 慢**。先用 `go list -deps `(纯元数据、不编译)拆开「模块解析」与「编译」 + 两层;前者 2.3s 说明模块在缓存,问题就必在别处。 + ⚠️ `go env -w` 在本沙箱**会失败**(`cannot find go env config: %AppData% is not defined`,Git Bash 无该变量), + 无法持久化,只能每条命令 export。GROUP1(security/censor、conf、form、template、shared、captcha)已验证 EXIT=0(~10s)。 +- **行尾:仓库是「混合行尾」,不是统一 CRLF**(`core.autocrlf=true`,提交 blob 为 LF,Git 自动归一,**不污染 diff**)。 + 实测:`internal/db/*`、`internal/mail/mail.go` 是 **LF**;`route/**`、`internal/route/route.go`、 + `internal/db/users.go` 是 **CRLF**。→ 编辑时**保持各文件原行尾**,不要整体转换。 + `gofmt -l` 会把 CRLF 文件**误报**为未格式化(false positive)。 + ✅ 改注释/字段后统一用 `dev/gofmt_keep_crlf.py `(原 Temp 版被清,已重建到 `dev/`):跑 gofmt 后按原文件行尾还原, + 既应用格式化又保留工作区 CRLF、避免 gofmt -l 噪声。(`grep -c $'\r'` 在 Git Bash 不可靠,查行尾用 Python 数 `b'\r\n'`。) - `internal/security/censor/censor.go`、`internal/security/censor/text_test.go` 存在**升级前就有**的 空格缩进(`gofmt -l` 会列出)。除非专门整理,否则不要顺手 gofmt,避免污染 diff。 @@ -56,6 +85,28 @@ 否则「审核失败」与「真的违规」在日志里无法区分。 - ⚠️ `SetContentType(requests.Json)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。 +## 表单校验(勿重复造轮子) + +- **长度/必填校验已在表单层完成**:字段的 `valid:"required;maxlen:N"` 标签由 + `internal/form/form.go:131` 的 `govalid.Check()` 在 `Bind()` 时执行,handler 顶部用 + `ctx.HasError()` 承接(见 `route/user/profile.go:76`、`route/user/harassment.go:30`)。 + 报错文案通过标签的 `msg:` 指向 i18n 键(如 `form.profile.name` / `form.profile.linkMax`)。 +- ⚠️ **因此不要再在 handler 里手写一套长度校验**:既会成为永不触发的死代码(超限早在 Bind 阶段就被拒), + 又容易与真实上限冲突(profile 实际为:Name≤20、Intro≤100、各 link≤30,而非想当然的 50/500/500)。 + 2026-09-15 曾据此误判 P2「profile 缺长度校验」并写了一版,已回滚。 +- 判断某字段是否已有校验:**先看 `internal/form/*.go` 的 struct 标签**,不要只看 handler 代码。 + +## GORM 约定 + +- **字符串列默认值必须带引号**:`gorm:"default:'none'"`(裸写 `default:none` 会生成非法 SQL)。 +- `default:` 标签会让 GORM 在 **Create** 时把该字段的**零值替换为默认值** + (`feature_control.go:79` 有 ⚠️ 记录,曾导致「取消勾选保存后库里仍是 true」)。 + 因此「空串有语义」的字段不要加 `default:`(如 `ModerationStrictness` 用空串表示「跟随站点默认」)。 + `Updates(map[string]interface{}{...})` 不受此影响(只写指定键)。 +- **启动期「历史数据归一」的既有惯例**:在 `db.Init` 的 `AutoMigrate` 之后追加幂等函数 + (`backfillFeatureControls` / `reconcileLegacyChatEnabled` / `normalizeChatQuota` / `normalizeHarassmentSetting`), + 失败仅告警、不阻断启动。新增此类归一照此模式即可。 + ## 文件工具陷阱(重要) - `templates/user/moderation-strictness.html`:**用 Write/Edit 写它会被自动注入 `data-page-node-id` 属性** @@ -63,6 +114,13 @@ → **该文件今后只用 shell 修改**。相关断言(`templates/render_test.go`)已改成不依赖属性顺序的正则。 - 往 gofmt 对齐组**中间**插 map 键,会把「拆分后剩余组」整组重新对齐 → 污染 diff。 新键应插在组末尾或独立成组(前后留空行)。i18n 字典已因此返工过一次。 + 同理:**在结构体/字面量对齐组中间插入 `//` 注释行会打断对齐组**,使相邻行被重新对齐; + 要加说明就放在该组**末尾**(下一个注释区之前),或改用行尾注释。 + 验证方法:`tr -d '\r' < f.go > a.go; gofmt a.go > b.go; diff a.go b.go`(无输出=不会重排)。 +- ⚠️ **gofmt 会把「文档注释」里的 `''` 规范化成 `”`**(Go 1.19+ doc comment 重排:`` `` ``→`“`、`''`→`”`), + 于是 `gofmt -l` 反复报未格式化。**注释里不要用 `''`**,改写为「空字符串」等文字。 +- ⚠️ `gofmt` 是 **Windows 原生程序,看不到 MSYS 的 `/tmp` 路径**(报 `GetFileAttributesEx ... cannot find`)。 + 临时比对文件要放到 Windows 可见路径(如 `C:/Users//AppData/Local/Temp/...`)。 ## 仓库状态 - **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。 diff --git a/dev/gofmt_keep_crlf.py b/dev/gofmt_keep_crlf.py new file mode 100644 index 0000000..f0745b0 --- /dev/null +++ b/dev/gofmt_keep_crlf.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# dev/gofmt_keep_crlf.py +# +# 运行 gofmt -w,但保留文件原始行尾(CRLF / LF),避免 gofmt 把 CRLF 文件整文件 +# 重写成 LF 造成的万行级 diff 污染。 +# +# 用法: +# python dev/gofmt_keep_crlf.py [file2 ...] +# git diff --name-only | grep '\.go$' | xargs python dev/gofmt_keep_crlf.py +# +# 实现要点:先把源文件按 LF 写入临时副本交给 gofmt,gofmt 输出也是 LF; +# 若原文件是 CRLF,再把输出整体换回 CRLF 写回,从而只保留真实的格式化改动。 +import os +import sys +import subprocess + +GOFMT = r"C:/Program Files/Go/bin/gofmt" + + +def fmt_one(path): + with open(path, "rb") as f: + data = f.read() + crlf = b"\r\n" in data + + tmp = path + ".gofmt.tmp" + with open(tmp, "wb") as f: + f.write(data.replace(b"\r\n", b"\n")) + try: + r = subprocess.run([GOFMT, "-w", tmp], capture_output=True) + if r.returncode != 0: + sys.stderr.write( + "gofmt FAILED on %s:\n%s\n" % (path, r.stderr.decode("utf-8", "replace")) + ) + return False + with open(tmp, "rb") as f: + out = f.read() + finally: + try: + os.remove(tmp) + except OSError: + pass + + if crlf: + out = out.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + with open(path, "wb") as f: + f.write(out) + return True + + +def main(): + files = [a for a in sys.argv[1:] if a.strip()] + if not files: + sys.stderr.write("usage: gofmt_keep_crlf.py [file ...]\n") + return 2 + ok = True + for p in files: + if not fmt_one(p): + ok = False + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/captcha/math.go b/internal/captcha/math.go index d6c396c..fb63bd0 100644 --- a/internal/captcha/math.go +++ b/internal/captcha/math.go @@ -27,10 +27,8 @@ import ( ) const ( - // Session key prefix for storing math captcha answers sessionKeyPrefix = "math_captcha_" - // TTL for each captcha in session - captchaTTL = 5 * time.Minute + captchaTTL = 5 * time.Minute // maxCaptchaAttempts 同一条验证码被尝试(含模态框预校验)的最大次数, // 超过即销毁该验证码,迫使刷新,防止对答案做无限轮询爆破。 maxCaptchaAttempts = 5 @@ -52,7 +50,6 @@ type StoredAnswer struct { // GenerateMath creates a new arithmetic captcha problem and stores the answer in session. func GenerateMath(s session.Session) (*MathProblem, error) { - // Generate random operands op1, err := rand.Int(rand.Reader, big.NewInt(90)) if err != nil { return nil, errors.Wrap(err, "generate operand 1") @@ -64,7 +61,6 @@ func GenerateMath(s session.Session) (*MathProblem, error) { a := int(op1.Int64()) + 1 // 1-90 b := int(op2.Int64()) + 1 // 1-90 - // Pick a random operator (+, -, *) opIdx, err := rand.Int(rand.Reader, big.NewInt(3)) if err != nil { return nil, errors.Wrap(err, "generate operator") @@ -90,18 +86,16 @@ func GenerateMath(s session.Session) (*MathProblem, error) { answer = a * b } - // 生成唯一 ID idBytes := make([]byte, 8) if _, err := rand.Read(idBytes); err != nil { return nil, errors.Wrap(err, "generate captcha id") } id := hex.EncodeToString(idBytes) - // 清理策略说明:flamego session 接口不支持遍历,无法主动清理过期条目。 - // 泄漏可控:验证码一次性消费(VerifyMath 校验后立即删除), - // 且每个用户同一时刻只生成 1 个新 key;session 本身过期后存储层整体清理。 + // 清理策略:flamego session 不支持遍历,无法主动清理过期条目。但验证码一次性消费 + // (校验后立即删除)、每用户同时刻只生成 1 个新 key,session 过期后存储层整体清理, + // 泄漏可控。 - // Store answer in session key := sessionKeyPrefix + id s.Set(key, StoredAnswer{ Answer: answer, @@ -136,14 +130,12 @@ func VerifyMath(s session.Session, token string, lang string) (bool, error) { return false, errors.New(i18n.T(lang, "captcha.errAnswerFormat")) } - // Retrieve stored answer key := sessionKeyPrefix + id stored, ok := s.Get(key).(StoredAnswer) if !ok { return false, errors.New(i18n.T(lang, "captcha.errExpired")) } - // Check expiry if time.Since(stored.CreatedAt) > captchaTTL { s.Delete(key) return false, errors.New(i18n.T(lang, "captcha.errExpired")) @@ -186,8 +178,8 @@ func VerifyOnly(s session.Session, id string, answer int) bool { return subtle.ConstantTimeCompare([]byte(strconv.Itoa(answer)), []byte(strconv.Itoa(stored.Answer))) == 1 } -// sessionLang 从会话读取匿名用户显式选择的语言(与 Contexter 一致,语言偏好现托管于 -// session 而非独立 lang cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。 +// sessionLang 从会话读取匿名用户显式选择的语言(语言偏好托管于 session 而非独立 lang +// cookie,避免被 session 中间件的 Set-Cookie 覆盖);未设置时回退默认。 func sessionLang(s session.Session) string { if v := s.Get("lang"); v != nil { if str, ok := v.(string); ok && str != "" { diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 060b5cc..5670ccc 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -12,7 +12,7 @@ import ( "gopkg.in/ini.v1" ) -// File is the configuration object. +// File 是解析后的配置对象。 var File *ini.File func Init() error { @@ -110,19 +110,14 @@ func Init() error { } // validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。 +// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。 // -// 单独抽成纯函数(只依赖入参、不读全局)是为了能直接被单测覆盖,也让 Init 保持轻薄。 -// -// 规则: -// - 审核整体关闭(enabled=false)→ 不校验,此时不需要任何凭据。 -// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都没配凭据时 -// 维持既有行为(运行期 fail-closed),不阻断启动 —— 否则 app.sample.ini 原样拷贝 -// (enable_text_censor = true 且凭据留空)将无法启动,破坏开箱流程。 -// - 显式选 qiniu / aliyun → 必须配齐该通道的凭据,否则启动即失败。 -// 理由:缺凭据时运行期审核必然失败,而审核失败是 fail-closed(拒绝提交), -// 会让发布/私信整站不可用,且现象隐蔽(只在日志可见)——不如启动就报错。 -// - 其它取值 → 直接报错,不做静默回退:拼写错误若被悄悄当成 auto, -// 运维会以为「已经只用七牛了」,实际仍在走双通道。 +// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。 +// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时 +// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。 +// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败, +// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。 +// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。 func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error { if !enabled { return nil diff --git a/internal/conf/limits.go b/internal/conf/limits.go index 8c089a2..5cd81ae 100644 --- a/internal/conf/limits.go +++ b/internal/conf/limits.go @@ -23,62 +23,39 @@ const ( // LimitsConfig 全站内容 / 数量上限([limits] 配置段)的字段集合。 // -// 过去这些上限以常量形式散落在 internal/db(MaxTags / MaxCustomTextLen 等)与 -// 路由层硬编码(私聊单条 2000 字、会话条数 clamp 5~200 等)中,调整需改代码重编译, -// 且前后端、i18n 文案各自携带一份数值容易漂移。统一收口到 [limits] 段后: -// - 后端校验、模板注入、前端提示全部引用 conf.Limits 的同一份值; -// - 调整仅需修改 conf/app.ini 并重启进程,无需改代码; -// - 长度类上限统一按字符(rune / Unicode 码点)计数。 +// 收口到本段前,这些上限散落在 internal/db 与路由层硬编码,改一次要重编译且前后端数值易漂移。 +// 现后端校验 / 模板注入 / 前端提示统一引用同一份值,调整只需改 app.ini 重启; +// 长度类上限一律按字符(rune)计数。 type LimitsConfig struct { - // ── 标签与屏蔽词(账号设置页)── - - // 标签个数上限 - MaxTags int `ini:"max_tags"` - // 单个标签最大字符数 - MaxTagLen int `ini:"max_tag_len"` - // 屏蔽词个数上限 - MaxBlockWords int `ini:"max_block_words"` - // 单个屏蔽词最大字符数 + MaxTags int `ini:"max_tags"` + MaxTagLen int `ini:"max_tag_len"` + MaxBlockWords int `ini:"max_block_words"` MaxBlockWordLen int `ini:"max_block_word_len"` - // ── 投票 ── - - // 参与者填写的「自定义补充选项」文本最大字符数。 - // 覆盖两类入口:单选/多选/排序的全局补充选项,以及表单收集字段的 - // 「其他/自定义选项」(field_{id}_custom)。 + // 参与者填写的「自定义补充选项」文本最大字符数(含表单字段的「其他」)。 CustomAnswerLen int `ini:"custom_answer_len"` - // 站长创建投票时单个「选项」文本的最大字符数。 - // 覆盖单选/多选/排序的预设选项,以及表单收集字段(select/radio/checkbox/rank) - // 的预置选项。与 CustomAnswerLen 是两条独立规则(站长侧预设刻意放宽),可各自调参。 + // 站长创建投票时单个「选项」文本最大字符数。与 CustomAnswerLen 是两条独立规则 + // (站长侧预设刻意放宽),可各自调参。 VoteOptionLen int `ini:"vote_option_len"` - // ── 提问 / 回答 / 追问 ── - - // 提问正文最大字符数(访客向提问箱提交的问题内容)。 - // 旧实现依赖 govalid 的 maxlen 标签(编译期写死 2000),无法随配置调整, - // 且与前端 maxlength=1000 不一致,现已移到路由层按本值做 rune 校验。 + // 提问正文最大字符数。旧实现靠 govalid maxlen 写死 2000 且与前端 1000 不一致, + // 现改为路由层按本值做 rune 校验。 QuestionContentLen int `ini:"question_content_len"` - // 回答正文最大字符数:箱主对提问的正式回答,以及箱主对追问的回复(共用)。 - AnswerContentLen int `ini:"answer_content_len"` - // 追问正文最大字符数(提问者在问题被回答后继续追问的内容)。 + // 回答正文最大字符数:箱主对提问的正式回答,以及对追问的回复(共用)。 + AnswerContentLen int `ini:"answer_content_len"` FollowUpContentLen int `ini:"followup_content_len"` - // ── 私聊 ── - // 单条消息(发送 / 回复共用)最大字符数 ChatMessageLen int `ini:"chat_message_len"` - // 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的 - // chat_max_messages;新注册用户的默认值即该硬顶(而非固定 20)。 + // 每个会话的消息条数硬顶。用户只能在 1..该值间设置 chat_max_messages, + // 新用户默认值即该硬顶(而非固定 20)。 ChatMaxMessages int `ini:"chat_max_messages"` - // 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)。 - // 该值只是「用户可设置的上限」,不是站点强制的每日配额。 + // 每日私信条数的「用户可设上限」(非站点强制配额):用户可设 0..该值,0 表示不限制。 DailyMessageLimitMax int `ini:"daily_message_limit_max"` } -// Limits 保存运行时的全部上限配置。conf.Init 中会先 MapTo [limits] 段覆盖显式键, -// 再调用 sanitizeLimits 把 <1 的值回退到内置默认,因此: -// - 缺失 [limits] 段或个别键 → 使用内置默认; -// - ini 键留空(int 解析为 0)或误填 0/负数 → 自动回退默认,不会把上限静默置零。 +// Limits 保存运行时的全部上限配置。conf.Init 先 MapTo [limits] 段,再由 sanitizeLimits +// 把 <1 的值回退内置默认,因此缺段、缺键、留空(解析为 0)或填负数都不会让上限静默置零。 var Limits = LimitsConfig{ MaxTags: defaultMaxTags, MaxTagLen: defaultMaxTagLen, @@ -94,10 +71,9 @@ var Limits = LimitsConfig{ DailyMessageLimitMax: defaultDailyMessageLimitMax, } -// sanitizeLimits 归一化 [limits] 配置:所有上限最小为 1(0 / 负数说明未配置或手滑, -// 一律回退内置默认,避免「留空=0」把标签全拒、消息全超长等隐性灾难)。 -// 注意 DailyMessageLimitMax 同样要求 >=1:0 会让用户侧永远只能设为 0(=不限制), -// 语义上等于关闭每日配额功能,需显式使用该字段其余语义时请另行设计。 +// sanitizeLimits 归一化 [limits]:所有上限最小为 1,0 / 负数一律回退内置默认, +// 避免「留空=0」把标签全拒、消息全超长等隐性灾难。 +// DailyMessageLimitMax 同样要求 >=1:为 0 会让用户只能设为 0(=不限制),等于关闭每日配额功能。 func sanitizeLimits(l *LimitsConfig) { clamp1 := func(p *int, def int) { if *p < 1 { diff --git a/internal/conf/static.go b/internal/conf/static.go index d26b94e..4845383 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -4,24 +4,17 @@ package conf -// Build time and commit information. -// -// ⚠️ WARNING: should only be set by "-ldflags". +// ⚠️ 仅应由 "-ldflags" 注入。 var ( BuildTime string BuildCommit = "dev" ) -// 文本审核服务通道取值([security] text_censor_source)。 -// -// 定义在 conf 侧是为了让「配置校验」(conf.Init)与「通道装配」 -// (internal/security/censor)引用同一份字面量,避免两边各写一份字符串而漂移。 +// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧, +// 让配置校验(conf.Init)与通道装配(internal/security/censor)共用同一份字面量。 const ( - // CensorSourceAuto 七牛优先、阿里云兜底(历史行为,留空同义)。 - CensorSourceAuto = "auto" - // CensorSourceQiniu 只用七牛。 - CensorSourceQiniu = "qiniu" - // CensorSourceAliyun 只用阿里云。 + CensorSourceAuto = "auto" // 七牛优先、阿里云兜底(历史行为,留空同义) + CensorSourceQiniu = "qiniu" CensorSourceAliyun = "aliyun" ) @@ -47,43 +40,32 @@ var ( IPBlacklist string `ini:"ip_blacklist"` IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"` // TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP - // (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr, - // 防止客户端伪造 header 绕过速率限制与 IP 黑名单。 + // (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律用 RemoteAddr, + // 防止伪造 header 绕过速率限制与 IP 黑名单。 TrustProxy bool `ini:"trust_proxy"` - // TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施 - // (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析 - // X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP / - // EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。 - // 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合 - // Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定, - // 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。 + // TrustedProxies 逗号分隔的 IP / CIDR,仅限自己的反向代理 / LB。仅当 TCP 对端 + // 落在该清单内才解析 X-Forwarded-For / X-Real-IP;留空则这两个头永不被信任 + // (CDN 权威头仍会采用,前提是源站已由 Cloudflare Tunnel / 回源网段锁定, + // 从而免于维护上游 CDN 的庞大 CIDR 清单)。 TrustedProxies string `ini:"trusted_proxies"` - // 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。 - EnableAutoBan bool `ini:"enable_auto_ban"` - // BanTrackNotFound 是否把「未注册路径的 404」计入封禁统计。默认 false:正常业务 - // (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征 - // 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下 - // 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。 + EnableAutoBan bool `ini:"enable_auto_ban"` // fail2ban 风格:检测到扫描特征后临时封禁来源 IP + // BanTrackNotFound 是否把未注册路径的 404 计入封禁统计。默认 false:仅命中后台 + // 扫描特征路径(KindSensitive)才统计,避免共享出口(CDN 边缘 / CGNAT)下 + // 正常用户被 404 探测流量连坐误伤。 BanTrackNotFound bool `ini:"ban_track_notfound"` BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数 BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数 BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒) BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟) - // DefaultModerationStrictness 站点默认的「内容审核严格度」档位 - // (lax / standard / strict)。仅对未单独设置档位的账号生效: - // 账号一旦显式选择了档位就以账号设置为准(见 moderation.StrictnessFor)。 - // 留空或非法值回退 strict(最严档)—— 保证升级不会放宽任何拦截; - // 严格档拦截面不小于升级前,部署后默认拦截面可能略有扩大; - // 若希望默认更少误伤,在本段显式写 standard 即可,无需改代码。 + // DefaultModerationStrictness 站点默认审核严格度(lax / standard / strict), + // 仅对未单独设置档位的账号生效(见 moderation.StrictnessFor)。留空或非法值回退 + // strict(最严档),保证升级不会放宽拦截;默认拦截面可能较升级前略有扩大, + // 希望更少误伤则显式写 standard。 DefaultModerationStrictness string `ini:"default_moderation_strictness"` - // TextCensorSource 文本审核服务通道,取值: - // qiniu —— 只用七牛(凭据见 [app] qiniu_access_key / qiniu_access_secret) - // aliyun —— 只用阿里云(走内容安全 2.0 / 文本审核增强版 TextModeration; - // 开通只需实名认证,个人账号可用。凭据见 [app] aliyun_access_key / aliyun_access_key_secret) - // auto(默认,留空同义)—— 保持历史行为:七牛优先,未通过时用阿里云二次复核 - // 仅显式选定的通道会被调用;选定通道缺少凭据时启动即报错(见 conf.Init), - // 避免运行时审核必然失败 —— 审核失败是 fail-closed(拒绝提交), - // 会让发布/私信整站不可用,且现象隐蔽(只在日志里)。 + // TextCensorSource 文本审核通道:qiniu / aliyun(内容安全 2.0 TextModeration, + // 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。 + // 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)—— + // 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。 TextCensorSource string `ini:"text_censor_source"` SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送) } @@ -142,12 +124,11 @@ var ( } Mail struct { - Account string `ini:"account"` - Password string `ini:"password"` - Port int `ini:"port"` - SMTP string `ini:"smtp"` - // SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书) - SkipTLSVerify bool `ini:"skip_tls_verify"` + Account string `ini:"account"` + Password string `ini:"password"` + Port int `ini:"port"` + SMTP string `ini:"smtp"` + SkipTLSVerify bool `ini:"skip_tls_verify"` // 仅自签名 / 内网 SMTP 需要 } Service struct { @@ -155,8 +136,6 @@ var ( Prefix string `ini:"prefix"` ForwardURL string `ini:"forward_url"` } - // TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验 - // (仅内网后端使用自签名证书时需要,默认 false 校验证书) - TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` + TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"` // 仅内网自签名后端需要 } ) diff --git a/internal/db/conversation.go b/internal/db/conversation.go index a2b4fe2..3541609 100644 --- a/internal/db/conversation.go +++ b/internal/db/conversation.go @@ -223,7 +223,8 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error // 回退定量收集计数:删除私聊会话等价于少一条已收集内容, // 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。 if conv.OwnerID != 0 { - if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil { + // 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。 + if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil { logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID) } } diff --git a/internal/db/db.go b/internal/db/db.go index 4b2b955..03a4f02 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -42,21 +42,15 @@ func Init(typ, dsn string) (*gorm.DB, error) { return nil, errors.Wrap(err, "connect to database") } - // 配置连接池 sqlDB, err := db.DB() if err != nil { return nil, errors.Wrap(err, "get sql.DB from gorm") } - // 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。 - // 提问箱是低并发场景,10 个打开连接绰绰有余。 - // 最大打开连接数 + // 1C1G 低配环境:调低连接池避免单连接占用过多内存。提问箱低并发,10 个打开连接绰绰有余。 sqlDB.SetMaxOpenConns(10) - // 最大空闲连接数 sqlDB.SetMaxIdleConns(4) - // 连接最大存活时间(避免长连接被数据库服务端断开) - sqlDB.SetConnMaxLifetime(5 * time.Minute) - // 空闲连接最大存活时间 + sqlDB.SetConnMaxLifetime(5 * time.Minute) // 避免长连接被数据库服务端断开 sqlDB.SetConnMaxIdleTime(3 * time.Minute) // 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。 @@ -89,6 +83,12 @@ func Init(typ, dsn string) (*gorm.DB, error) { logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)") } + // 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价 + // 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。 + if err := normalizeHarassmentSetting(db); err != nil { + logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)") + } + // 启动期补充高频查询复合索引(幂等,适合既有库)。 // 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、 // 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。 diff --git a/internal/db/feature_control.go b/internal/db/feature_control.go index cde98a1..3085f58 100644 --- a/internal/db/feature_control.go +++ b/internal/db/feature_control.go @@ -66,29 +66,23 @@ type featureControls struct { *gorm.DB } -// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。 +// FeatureControl 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。 +// Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧); +// QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。 // -// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算: -// - Enabled:手动总开关; -// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧); -// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。 -// -// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮, -// 避免与定时开关叠加产生难以预期的自动恢复行为。 +// 计数不做定时重置:一轮结束后由箱主手动「重置计数」,避免与定时开关叠加产生 +// 难以预期的自动恢复行为。 type FeatureControl struct { dbutil.Model UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"` Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"` - // ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签: - // GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后 - // 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。 - Enabled bool `json:"enabled"` - // OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。 + // ⚠️ 不要给 Enabled 加 gorm:"default:true":GORM 在 Create 时会把零值 false 替换为 + // 默认值 true,导致「取消勾选保存」后库里仍是 true、复选框自动恢复勾选。 + Enabled bool `json:"enabled"` OpenAt *time.Time `json:"open_at"` CloseAt *time.Time `json:"close_at"` - // 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致), - // 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。 - // 两者都为空时表示未启用循环定时。 + // 循环定时(每周):ScheduleDays 为逗号分隔的周几(0=周日…6=周六),空表示每天都开放; + // ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示全天)。两者皆空即未启用。 ScheduleDays string `gorm:"size:31" json:"schedule_days"` ScheduleStart string `gorm:"size:5" json:"schedule_start"` ScheduleEnd string `gorm:"size:5" json:"schedule_end"` @@ -178,8 +172,8 @@ func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool { return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday())) } -// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。 -// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。 +// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。未设置时间段时视为全天; +// 配置非法时返回错误(调用方按「不限制」处理)。 func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) { if fc.ScheduleStart == "" && fc.ScheduleEnd == "" { return 0, 24 * 60, nil @@ -342,9 +336,8 @@ func startOfDay(t time.Time) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) } -// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。 -// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值, -// 避免历史账号在功能上线后被动开启私聊。 +// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。提问与投票默认开启 +// (与历史行为一致);私聊默认关闭,沿用 users.allow_chat 默认值,避免历史账号被动开启。 func DefaultFeatureControl(userID uint, feature string) *FeatureControl { return &FeatureControl{ UserID: userID, @@ -353,8 +346,8 @@ func DefaultFeatureControl(userID uint, feature string) *FeatureControl { } } -// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。 -// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。 +// FeatureStateOf 读取某用户某功能当前的综合状态。查询失败或无存储时回退默认配置, +// 保证单个依赖表异常不会让整站功能不可用。 func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState { if FeatureControls == nil { return DefaultFeatureControl(userID, feature).Evaluate(time.Now()) @@ -433,7 +426,7 @@ type UpdateFeatureControlOptions struct { QuotaLimit int64 } -// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列, +// Save 写入功能配置。用 ON CONFLICT 幂等写入并显式列出更新列, // 避免把 quota_count(已收集数量)误重置为 0。 func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error { now := time.Now() @@ -544,13 +537,12 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat // backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。 // -// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有 -// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。 -// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。 +// 私聊开关此前落在 users.allow_chat,提问/投票一直默认开启;功能上线后没有配置行时 +// Get 会回退默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭, +// 故在 AutoMigrate 之后按 users.allow_chat 回填私聊,其余按「开启」回填。 // -// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。 -// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为, -// 并保证并发启动(多实例同时启动)时不会重复插入。 +// 幂等:每轮只挑仍缺行的账号,分批 + ON CONFLICT DO NOTHING,避免不同数据库上 +// 「边插入边查同一张表」的差异行为,也保证多实例并发启动时不重复插入。 func backfillFeatureControls(db *gorm.DB) error { // backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。 const backfillBatchSize = 500 @@ -616,28 +608,20 @@ func backfillFeatureControls(db *gorm.DB) error { // reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。 // -// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为 -// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启; -// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。 +// 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时 +// false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 allow_chat 同步为 true。 // -// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行): -// - feature = 'chat' 且 enabled = true; -// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true); -// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空); -// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。 -// -// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。 -// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。 +// 纠偏条件(必须同时满足,只命中「纯 backfill 默认行」,绝不碰箱主配置过的行): +// feature='chat' 且 enabled=true;对应 users.allow_chat=false;该行无任何自定义配置 +// (open_at/close_at 为空、quota_limit=0、循环定时为空);updated_at <= created_at。 +// 满足即复位 enabled=false 并同步 users.allow_chat=false。幂等,已配置行不会重复命中。 func reconcileLegacyChatEnabled(db *gorm.DB) error { // reconcileBatchSize 每轮处理的账号数,控制单次事务体积。 const reconcileBatchSize = 500 - // pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行 - // (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同: - // - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景); - // - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报 - // missing FROM-clause entry for table "fc")。 - // 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。 + // 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同: + // FC 版带 "fc." 用于 SELECT;纯版不带前缀用于 UPDATE(GORM 不会为 UPDATE 自动加别名, + // 带前缀会报 missing FROM-clause entry for table "fc")。同时兼容旧行 NULL 与新行空串。 pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" + " AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" + " AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" + @@ -703,11 +687,9 @@ func reconcileLegacyChatEnabled(db *gorm.DB) error { } // normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。 -// -// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大), -// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本 -// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。 -// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。 +// 私聊不提供收集上限(会话是双向对话,限制「新建会话数」意义不大),UI 已隐藏该字段、 +// 保存时强制清零;此处在启动期兜底清零,避免达到残留上限后私聊「莫名关闭」。 +// 幂等,失败由调用方告警(不阻断启动)。 func normalizeChatQuota(db *gorm.DB) error { res := db.Model(&FeatureControl{}). Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat). diff --git a/internal/db/follow_ups.go b/internal/db/follow_ups.go index 6e88455..0c2dbec 100644 --- a/internal/db/follow_ups.go +++ b/internal/db/follow_ups.go @@ -11,39 +11,31 @@ import ( ) // MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。 -// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。 const MaxFollowUpCount = 3 -// 追问相关错误(供 handler 区分处理,避免把业务错误当 500) +// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。 var ( - // ErrFollowUpLimitReached 追问次数已达上限 - ErrFollowUpLimitReached = errors.New("follow up limit reached") - // ErrFollowUpNotFound 追问不存在或不属于该问题 - ErrFollowUpNotFound = errors.New("follow up not found") - // ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖) + ErrFollowUpLimitReached = errors.New("follow up limit reached") + ErrFollowUpNotFound = errors.New("follow up not found") ErrFollowUpAlreadyAnswered = errors.New("follow up already answered") ) var FollowUps FollowUpsStore type FollowUpsStore interface { - // CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。 - // 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。 - // 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。 + // CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建; + // 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。 CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error) GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error) // GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。 GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) - // GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。 - // 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。 + // GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。 GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error) CountByQuestionID(ctx context.Context, questionID uint) (int64, error) - // AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。 - // 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered, - // 不存在/不属于该问题返回 ErrFollowUpNotFound。 + // AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。 + // 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。 AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error - // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权), - // 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。 + // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。 // 不存在/不属于该问题返回 ErrFollowUpNotFound。 UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error // DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。 @@ -120,8 +112,7 @@ func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*F return followUps, nil } -// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条); -// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。 +// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。 func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) { var fu FollowUp err := db.WithContext(ctx). @@ -169,9 +160,8 @@ func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (in return count, nil } -// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。 -// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。 -// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。 +// AnswerByID 更新回答(带 question_id 防越权)。 +// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。 func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error { res := db.WithContext(ctx). Model(&FollowUp{}). diff --git a/internal/db/questions.go b/internal/db/questions.go index f02a255..8a42b22 100644 --- a/internal/db/questions.go +++ b/internal/db/questions.go @@ -92,7 +92,7 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q question := Question{ FromIP: opts.FromIP, UserID: opts.UserID, - Token: randstr.String(6), + Token: randstr.String(12), Content: opts.Content, ReceiveReplyEmail: opts.ReceiveReplyEmail, AskerUserID: opts.AskerUserID, @@ -401,17 +401,19 @@ func (db *questions) DeleteByID(ctx context.Context, id uint) error { } // 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。 - if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil { + // 直接用本 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) } - if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil { + 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) } // 回退定量收集计数:删除提问等价于少一条已收集内容, // 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。 if question.UserID != 0 { - if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil { + 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) } } diff --git a/internal/db/upload_images_test.go b/internal/db/upload_images_test.go index 9e783fe..b36c7d3 100644 --- a/internal/db/upload_images_test.go +++ b/internal/db/upload_images_test.go @@ -12,8 +12,9 @@ import ( ) func TestUploadImages(t *testing.T) { - t.Parallel() - + // 注意:各子测试都创建 question_id=1 的上传图,cleanup 会 truncate 并 RESTART IDENTITY, + // 并行子测试会拿到相同自增 id,撞 upload_image_questions 的复合主键 + // (upload_image_id, question_id);故顺序执行,使每个子测试独占全表。 db, cleanup := newTestDB(t) ctx := context.Background() @@ -29,7 +30,10 @@ func TestUploadImages(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { - if err := cleanup("upload_images"); err != nil { + // 必须一并清链接表:upload_image_questions 无 FK 级联依赖 upload_images, + // 仅 truncate upload_images 会残留 (upload_image_id, question_id) 链接行, + // 下一个子测试因身份重启拿到相同 id 而撞复合主键。 + if err := cleanup("upload_images", "upload_image_questions"); err != nil { t.Fatal(err) } }) diff --git a/internal/db/users.go b/internal/db/users.go index 56631ef..94a655d 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -86,7 +86,7 @@ type User struct { UID string `json:"-"` Name string `json:"name"` Password string `json:"-"` - Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"` + Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"` Avatar string `json:"avatar"` Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"` Background string `json:"background"` @@ -105,8 +105,12 @@ type User struct { Qrcodepdpcolor string `json:"qrcodepdpcolor"` Backgroundimagealpha string `json:"Backgroundimagealpha"` Notify NotifyType `json:"notify"` - HarassmentSetting HarassmentSettingType `json:"harassment_setting"` + HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"` BlockWords string `json:"-"` + // 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值), + // 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、 + // Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。 + // // ModerationStrictness 内容审核严格度:lax / standard / strict。 // 空值表示「跟随站点默认」([security] default_moderation_strictness), // 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档), @@ -244,6 +248,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error { Backgroundimagealpha: opts.Backgroundimagealpha, Qrcodepdpcolor: opts.Qrcodepdpcolor, Notify: NotifyTypeEmail, + HarassmentSetting: HarassmentSettingNone, Usernamecolor: opts.Usernamecolor, Introcolor: opts.Introcolor, Acfunlink: opts.Acfunlink, @@ -388,6 +393,23 @@ func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ Harassme return nil } +// normalizeHarassmentSetting 把 users.harassment_setting 的历史空值(空字符串)归一为 none。 +// 空串与 none 语义等价(均表示不做限制),归一后取值唯一,避免将来按 == none 判断时漏判。 +// 幂等:无空值时不做写入;失败由调用方告警(不阻断启动)。 +func normalizeHarassmentSetting(db *gorm.DB) error { + res := db.Model(&User{}). + Where("harassment_setting IS NULL OR harassment_setting = ''"). + Update("harassment_setting", HarassmentSettingNone) + if res.Error != nil { + return errors.Wrap(res.Error, "normalize harassment setting") + } + if res.RowsAffected > 0 { + logrus.WithField("count", res.RowsAffected). + Info("防骚扰设置已归一:历史空值已置为 none") + } + return nil +} + // UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。 // 只写 block_words 列,不动 harassment_setting。 func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error { diff --git a/internal/db/users_test.go b/internal/db/users_test.go index dabed95..4083d55 100644 --- a/internal/db/users_test.go +++ b/internal/db/users_test.go @@ -11,11 +11,26 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + + "github.com/tamakyi/TamaBox/internal/conf" ) -func TestUsers(t *testing.T) { - t.Parallel() +// requireUserEqual 比较期望与实际的 User。 +// bcrypt 哈希每次加盐不同,无法逐字节比较:先按明文校验密码,再把哈希字段对齐后比较其余字段。 +// Create 会按 conf.Limits.ChatMaxMessages 显式写入该字段,故期望值同步取该配置默认。 +func requireUserEqual(t *testing.T, want, got *User) { + t.Helper() + require.True(t, got.Authenticate(want.Password)) + want.Password = got.Password + want.ChatMaxMessages = conf.Limits.ChatMaxMessages + want.HarassmentSetting = HarassmentSettingNone // Create 显式写入 none + require.Equal(t, want, got) +} +func TestUsers(t *testing.T) { + // 注意:本测试各子测试创建用户均使用固定邮箱/域名(i@github.red / e99), + // 且每个子测试结束会 truncate 整张 users 表。若开启 t.Parallel(),并发子测试会在 + // 同一张表上同时插入相同唯一键导致"邮箱已注册"误报;故顺序执行,使每个子测试独占全表。 db, cleanup := newTestDB(t) ctx := context.Background() @@ -122,8 +137,7 @@ func testUsersGetByID(t *testing.T, ctx context.Context, db *users) { Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } - want.EncodePassword() - require.Equal(t, want, got) + requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { @@ -165,8 +179,7 @@ func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) { Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } - want.EncodePassword() - require.Equal(t, want, got) + requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { @@ -208,8 +221,7 @@ func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) { Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } - want.EncodePassword() - require.Equal(t, want, got) + requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { @@ -260,8 +272,7 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) { Intro: "Be cool, but also be warm!!", Notify: NotifyTypeNone, } - want.EncodePassword() - require.Equal(t, want, got) + requireUserEqual(t, want, got) }) } @@ -307,6 +318,10 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) { require.Nil(t, err) t.Run("normal", func(t *testing.T) { + // 先显式设定防骚扰类型,验证 UpdateBlockWords 不会误改该列(类型列默认为空串, + // 与 HarassmentSettingNone 等价但值不同,故显式设定后再断言更可靠)。 + require.Nil(t, db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly)) + err := db.UpdateBlockWords(ctx, 1, "广告,抽奖") require.Nil(t, err) @@ -314,7 +329,7 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) { require.Nil(t, err) require.Equal(t, "广告,抽奖", got.BlockWords) // 只应更新屏蔽词,类型列不受影响 - require.Equal(t, HarassmentSettingNone, got.HarassmentSetting) + require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting) }) t.Run("empty clears words", func(t *testing.T) { @@ -359,8 +374,7 @@ func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) { Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } - want.EncodePassword() - require.Equal(t, want, got) + requireUserEqual(t, want, got) } func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) { @@ -408,9 +422,9 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", - Email: "i@github.red", + Email: "deactivate@github.red", Avatar: "avater.png", - Domain: "e99", + Domain: "deactivate", Background: "background.png", Intro: "Be cool, but also be warm.", }) @@ -426,3 +440,33 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) { require.NotNil(t, err) }) } + +// TestHarassmentSettingNormalize 覆盖防骚扰设置取值归一: +// 新建用户即写入规范值 none;历史空值经启动期归一(normalizeHarassmentSetting)后也为 none。 +func TestHarassmentSettingNormalize(t *testing.T) { + db, _ := newTestDB(t) + ctx := context.Background() + usersStore := NewUsersStore(db) + + require.Nil(t, usersStore.Create(ctx, CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "normalize@github.red", + Avatar: "avater.png", + Domain: "normalize", + Background: "background.png", + Intro: "Be cool, but also be warm.", + })) + + got, err := usersStore.GetByID(ctx, 1) + require.Nil(t, err) + require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "新建用户应为规范值 none") + + // 模拟历史行:直接把列置为空串,再跑一次启动期归一。 + require.Nil(t, db.Exec("UPDATE users SET harassment_setting = '' WHERE id = 1").Error) + require.Nil(t, normalizeHarassmentSetting(db)) + + got, err = usersStore.GetByID(ctx, 1) + require.Nil(t, err) + require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none") +} diff --git a/internal/form/form.go b/internal/form/form.go index df6c37f..55e4934 100644 --- a/internal/form/form.go +++ b/internal/form/form.go @@ -31,25 +31,18 @@ type Error struct { } const ( - // multipartMemoryLimit 是 multipart 解析时驻留内存的上限,超出部分由 - // mime/multipart 落盘到临时文件。 - multipartMemoryLimit = 10 << 20 // 10 MiB + multipartMemoryLimit = 10 << 20 // 10 MiB:multipart 内存驻留上限,超出由 mime/multipart 落盘 - // MaxRequestBodySize 是单次请求体的**总**大小上限(含落盘部分)。 + // MaxRequestBodySize 请求体总大小上限(含落盘部分),略高于内存上限以留出开销。 // - // ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限地 - // 写入临时文件。匿名可达的提问接口即接受 multipart,若不加总量限制,攻击者可持续 - // POST 超大 body 撑满磁盘(http.Server 的 ReadTimeout 在高带宽下不足以形成约束)。 - // 略高于内存上限,为 multipart 边界与其他表单字段留出开销。 - // 导出供 internal/route 的全局 requestBodyLimitMiddleware 复用同一上限: - // 仅靠本 Bind 内的 MaxBytesReader 不够——Contexter 组级 CSRF 校验先于 Bind 执行, - // 无 X-CSRF-Token 头时它会整包惰性解析 body,超大 multipart 会在 Bind 限制生效前 - // 落盘;全局中间件把上限提前到一切解析之前(见 internal/route/bodylimit.go)。 + // ⚠️ 必须显式限制:ParseMultipartForm 的参数只约束内存缓冲,超出部分会无上限写入 + // 临时文件;匿名可达的提问接口即接受 multipart,无总量限制则可持续撑满磁盘。 + // 导出供 internal/route/bodylimit.go 的全局中间件复用——仅靠本 Bind 内限制不够, + // 组级 CSRF 校验先于 Bind 执行且会整包惰性解析 body,须把上限提前到一切解析之前。 MaxRequestBodySize = 12 << 20 // 12 MiB ) func Bind(model interface{}) flamego.Handler { - // Ensure not pointer. if reflect.TypeOf(model).Kind() == reflect.Ptr { panic("form: pointer can not be accepted as binding model") } @@ -60,11 +53,9 @@ func Bind(model interface{}) flamego.Handler { r := c.Request() - // 在任何解析之前封顶请求体总大小。 - // 注意:c.Request() 返回 flamego 的包装类型,其 Body 是方法而非字段, - // 必须取内嵌的 *http.Request 才能替换 Body。 - // MaxBytesReader 在超限时让后续 Read 返回错误,从而使 ParseForm / - // ParseMultipartForm 失败并走下方的反序列化错误分支,避免超大 body 落盘或读入内存。 + // 在任何解析之前封顶请求体总大小。c.Request() 是 flamego 包装类型(Body 是方法 + // 而非字段),须取内嵌 *http.Request 才能替换。超限后 Read 报错,使 ParseForm / + // ParseMultipartForm 失败并走反序列化错误分支,避免超大 body 落盘或读入内存。 httpReq := r.Request httpReq.Body = http.MaxBytesReader(c.ResponseWriter(), httpReq.Body, MaxRequestBodySize) @@ -72,11 +63,9 @@ func Bind(model interface{}) flamego.Handler { switch contentType { case "application/x-www-form-urlencoded", "": if err := r.ParseForm(); err != nil { - // ⚠️ 必须同时 SetError(写响应则在此提前中止):flamego 的 handler 链 - // 只要响应未写入就会自动执行下一 handler,仅 Map(Error) 不中断链, - // 零值结构体会照常进入业务动作(如把用户资料/屏蔽词静默清空、解除页面密码)。 - // SetError 置位 HasError,业务 action 开头若检查 ctx.HasError() 即会早退; - // 不检查的 action 也已在 route/user 各 handler 补了 abortOnFormError 守卫。 + // ⚠️ 必须 SetError:flamego 的 handler 链只要响应未写入就会自动执行下一 handler, + // 仅 Map(Error) 不中断链,零值结构体会照常进入业务动作(静默清空屏蔽词、解除 + // 页面密码等)。SetError 置位 HasError,action 开头的 abortOnFormError 守卫会早退。 c.SetError(err) c.Map(Error{Category: ErrorCategoryDeserialization, Error: err}) return @@ -89,7 +78,6 @@ func Bind(model interface{}) flamego.Handler { } } - // Bind the form data to the given struct. typ := reflect.TypeOf(obj.Interface()) val := reflect.ValueOf(obj.Interface()) if typ.Kind() == reflect.Ptr { @@ -131,11 +119,9 @@ func Bind(model interface{}) flamego.Handler { } } } else { - // 非 multipart 请求(application/x-www-form-urlencoded):仅绑定 string 字段。 - // 其余类型(如 []*multipart.FileHeader 图片字段)保持零值, - // 否则 reflect.Set 会因类型不匹配 panic(string → []*multipart.FileHeader)。 - // 此前提问表单因带 enctype="multipart/form-data" 走上面分支而从未触发; - // 独立私聊表单等纯文本提交会命中此路径,必须先做类型判断。 + // 非 multipart 请求仅绑定 string 字段,其余类型(如 []*multipart.FileHeader + // 图片字段)保持零值,否则 reflect.Set 会因类型不匹配 panic。 + // 带 enctype 的提问表单走上面分支从未触发,纯文本提交(如私聊)会命中此路径。 if val.Field(i).Kind() == reflect.String { val.Field(i).Set(reflect.ValueOf(r.Form.Get(fieldName))) } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 3f3cf8d..5c2af1d 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -12,10 +12,10 @@ import ( // Language 语言代码常量。 const ( - LangZhCN = "zh-CN" // 简体中文 - LangZhTW = "zh-TW" // 繁體中文 - LangEn = "en" // English - LangJa = "ja" // 日本語 + LangZhCN = "zh-CN" + LangZhTW = "zh-TW" + LangEn = "en" + LangJa = "ja" Default = LangZhCN ) @@ -26,7 +26,7 @@ type LangInfo struct { Name string // 语言自身名称,如 "简体中文" / "繁體中文" / "English" / "日本語" } -// supported 支持的语语言列表,顺序即切换按钮展示顺序。 +// supported 支持的语言列表,顺序即切换按钮展示顺序。 var supported = []LangInfo{ {Code: LangZhCN, Name: "简体中文"}, {Code: LangZhTW, Name: "繁體中文"}, diff --git a/internal/mail/mail.go b/internal/mail/mail.go index ab59511..c130b30 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -54,10 +54,11 @@ func SendPasswordRecoveryMail(email, code string) error { } // SendNewFollowUpMail 通知提问箱主人:有人对某条已回答的提问进行了追问。 -// 链接无需 token,主人登录后即可查看。 -func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string) error { +// 链接带 token,与 SendNewQuestionMail 对齐——主人于未登录设备(手机邮件客户端)点开私密提问时 +// 也能凭 token 查看,否则会被 Questioner 中间件重定向到首页。 +func SendNewFollowUpMail(email, domain string, questionID uint, questionContent string, followUpContent string, token string) error { params := map[string]string{ - "link": fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, domain, questionID), + "link": fmt.Sprintf("%s/_/%s/%d?t=%s", conf.App.ExternalURL, domain, questionID, token), "question": questionContent, "followup": followUpContent, } diff --git a/internal/route/csrf_binding_test.go b/internal/route/csrf_binding_test.go index 4a0c504..dd134fa 100644 --- a/internal/route/csrf_binding_test.go +++ b/internal/route/csrf_binding_test.go @@ -12,18 +12,10 @@ import ( ) // TestCSRFTokenBoundToUserSession 是 P1-1 的回归测试:CSRF token 必须绑定到登录用户的 -// 会话键(CSRFUserSessionKey = "uid"),而非 flamego/csrf 默认的 "userID"。 -// -// 背景:flamego/csrf 的校验是无状态 HMAC 重算(token = HMAC(secret, id:action:ts),不与 -// session 内存储的 token 比对)。一旦 SessionKey 与登录时写入的会话键(login.go 中的 -// Session.Set("uid", ...))不一致,token 内绑定的 id 恒为 "0",任意匿名访客从公开 /login -// 页面取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站请求伪造可自由发起。 -// -// 本测试用内存会话(无需数据库)重放「会话 B 携带会话 A 的 token 提交」这一跨会话复用攻击: -// - 会话 A(已登录 uid=42)携带自己的 token 提交 → 必须通过(200); -// - 会话 B(匿名)携带会话 A 的 token 跨会话提交 → 必须被拒绝(400)。 -// -// 若 CSRFUserSessionKey 被误改回 "userID",第二项断言会失败,CI 即报警。 +// 会话键(CSRFUserSessionKey="uid"),否则 flamego/csrf 的无状态 HMAC 重算会让匿名访客 +// 取到的 token 对所有已登录用户均有效 —— CSRF 退化为全站公开常量,跨站伪造可自由发起。 +// 测试用内存会话重放跨会话复用攻击:会话 A(uid=42)自带的 token 提交须 200,会话 B 跨会话 +// 复用会话 A 的 token 须 400。若 CSRFUserSessionKey 被误改回 "userID",第二项断言即失败报警。 func TestCSRFTokenBoundToUserSession(t *testing.T) { const secret = "test-secret-for-csrf-regression" @@ -49,14 +41,12 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) { SetHeader: true, // 便于测试从响应头取出 token })) - // 受保护路由:仅当 CSRF 校验通过才返回 200;失败由 csrf.Error 写 400。 + // 受保护路由:校验通过才 200;失败由 csrf.Error 写 400。 f.Post("/protected", func(ctx flamego.Context, x csrf.CSRF) { x.Validate(ctx) - // 校验失败时 x.Validate 已写入 400;此处 Write 在已写 400 时仅追加 body, - // 状态保持 400;校验通过时此处隐式返回 200。 + // 校验失败时 x.Validate 已写 400,此处 Write 仅追加 body,状态保持 400。 ctx.ResponseWriter().Write([]byte("ok")) }) - // 生成 token 的 GET 路由(token 在 GET 时生成,与真实应用一致)。 f.Get("/token", func(ctx flamego.Context) { ctx.ResponseWriter().Write([]byte("ok")) }) @@ -106,13 +96,11 @@ func TestCSRFTokenBoundToUserSession(t *testing.T) { clientA := newClient() tokenA := getToken(clientA, true) - // 同一会话携带自己的 token 提交 —— 必须通过(200)。 if code := postProtected(clientA, tokenA); code != http.StatusOK { t.Fatalf("会话 A 携带自身 token 提交,期望 200,实际 %d(修复后不应被拒绝)", code) } - // 会话 B:匿名访客,跨会话复用会话 A 的 token 提交 —— 必须被拒绝(400)。 - // 这是 P1-1 的核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配。 + // 核心回归点:token 绑定了 uid=42,会话 B 的 id 为 "0",HMAC 重算不匹配,须被拒绝(400)。 clientB := newClient() if code := postProtected(clientB, tokenA); code != http.StatusBadRequest { t.Fatalf("会话 B 跨会话复用会话 A 的 token,期望 400,实际 %d(CSRF 绑定失效!)", code) diff --git a/internal/route/gzip.go b/internal/route/gzip.go index 78917d9..508c284 100644 --- a/internal/route/gzip.go +++ b/internal/route/gzip.go @@ -13,15 +13,12 @@ import ( "strings" ) -// GzipHandler 返回一个压缩中间件,用于包装 http.Handler。 -// 它对可压缩的响应(HTML / CSS / JS / JSON 等文本类型)做 gzip 压缩, -// 自动跳过图片、视频、字体等已经压缩过的格式。 -// -// 由于 flamego 的 Context 无法替换底层 ResponseWriter,这里采用在 -// http.Server 最外层包装 Handler 的方式,保证对所有响应(含静态文件)生效。 +// GzipHandler 对可压缩的文本响应(HTML / CSS / JS / JSON 等)做 gzip 压缩, +// 自动跳过图片、视频、字体等已压缩格式。 +// flamego 的 Context 无法替换底层 ResponseWriter,故在 http.Server 最外层包装 +// Handler,保证对所有响应(含静态文件)生效。 func GzipHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 客户端不支持 gzip 时直接透传 if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { next.ServeHTTP(w, r) return @@ -32,8 +29,7 @@ func GzipHandler(next http.Handler) http.Handler { gz: gzip.NewWriter(w), } defer func() { - // 只有真正写入过 body 才关闭 gzip 流,避免 302/204 等 - // 无 body 响应被追加空的 gzip 尾块 + // 只有真正写入过 body 才关闭,避免 302/204 等无 body 响应被追加空的 gzip 尾块 if gw.compress && gw.wrote { _ = gw.gz.Close() } @@ -42,8 +38,8 @@ func GzipHandler(next http.Handler) http.Handler { }) } -// gzipResponseWriter 包装 http.ResponseWriter,在 WriteHeader 时根据 -// Content-Type 决定是否启用 gzip,保持接口兼容(Flusher / Hijacker / Pusher)。 +// gzipResponseWriter 在 WriteHeader 时按 Content-Type 决定是否启用 gzip, +// 并保持 Flusher / Hijacker / Pusher 接口兼容。 type gzipResponseWriter struct { http.ResponseWriter gz *gzip.Writer @@ -52,8 +48,8 @@ type gzipResponseWriter struct { wroteHeader bool } -// WriteHeader 记录状态码,并根据 Content-Type 决定是否压缩。 -// 由于 Content-Encoding 改变后 Content-Length 不再准确,需要删除该头。 +// WriteHeader 记录状态码并决定是否压缩;Content-Encoding 改变后 Content-Length +// 不再准确,需删除该头。 func (w *gzipResponseWriter) WriteHeader(status int) { if w.wroteHeader { return @@ -75,15 +71,14 @@ func (w *gzipResponseWriter) WriteHeader(status int) { w.ResponseWriter.WriteHeader(status) } -// Write 写入响应体。若尚未 WriteHeader(handler 依赖 net/http 自动嗅探 -// Content-Type 的情况),先用首个字节块探测类型再决策。 +// Write 写入响应体。若尚未 WriteHeader,先用首个字节块探测类型再决策。 func (w *gzipResponseWriter) Write(b []byte) (int, error) { if !w.wroteHeader { ct := w.Header().Get("Content-Type") if ct == "" { ct = http.DetectContentType(b) - // 将嗅探到的类型写回响应头,使 WriteHeader 的压缩判定能正确生效 - // (否则空 Content-Type 会被判定为"不压缩",导致本应压缩的纯文本响应未压缩)。 + // 嗅探结果须写回头,否则空 Content-Type 会被判为「不压缩」, + // 导致本应压缩的纯文本响应漏压。 w.Header().Set("Content-Type", ct) } w.WriteHeader(http.StatusOK) @@ -95,7 +90,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { return w.ResponseWriter.Write(b) } -// Flush 转发底层 Flusher,并在压缩模式下先刷新 gzip 缓冲。 +// Flush 转发底层 Flusher,压缩模式下先刷新 gzip 缓冲。 func (w *gzipResponseWriter) Flush() { if w.compress && w.gz != nil { _ = w.gz.Flush() @@ -121,24 +116,21 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error { return http.ErrNotSupported } -// shouldCompressContentType 判断响应 Content-Type 是否值得压缩。 -// 空类型、二进制图片/视频/音频/字体等已压缩格式直接跳过。 +// shouldCompressContentType 判断 Content-Type 是否值得压缩:空类型与已压缩的 +// 二进制格式(图片 / 视频 / 音频 / 字体 / 归档等)直接跳过。 func shouldCompressContentType(ct string) bool { ct = strings.ToLower(strings.TrimSpace(ct)) if ct == "" { return false } - // SVG 是文本格式,值得压缩;其余图片格式已是压缩格式 if strings.HasPrefix(ct, "image/") { - return strings.Contains(ct, "svg") + return strings.Contains(ct, "svg") // SVG 是文本格式 } - // 视频 / 音频 / 字体已是压缩格式 if strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "font/") { return false } - // 已知压缩或二进制格式 switch ct { case "application/zip", "application/gzip", "application/x-7z-compressed", "application/x-rar-compressed", "application/pdf", diff --git a/internal/route/route.go b/internal/route/route.go index 8af4f41..cc3dc46 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -49,13 +49,11 @@ import ( // 用于防止把路由参数直接拼进 embed FS 路径时发生路径穿越(如 "../robots.txt")。 var indexNowKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}\.txt$`) -// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致 -// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用 -// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token -// 对全部已登录用户均有效,CSRF 退化为全站公开常量。 -// -// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在 -// CI 失败,避免修复悄悄倒退。 +// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的键一致 +// (见 route/auth/login.go 的 ctx.Session.Set("uid", ...))。flamego/csrf 默认用 +// "userID",不对齐则 token id 恒为 "0",匿名访客从公开页取到的 token 对所有已登录 +// 用户均有效,CSRF 退化为全站公开常量。 +// 本常量被 csrf_binding_test.go 引用作为单一事实来源,误改会让回归测试失败。 const CSRFUserSessionKey = "uid" // securityHeaders adds security-related HTTP headers to every response. @@ -80,9 +78,8 @@ func New() *flamego.Flame { } // 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用, - // 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。 - // 404 是否参与统计由 ban_track_notfound 控制(默认 false:正常业务一律放行, - // 仅敏感扫描路径计数封禁,见 conf.Security.BanTrackNotFound)。 + // 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。 + // 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。 ab := autoban.New(autoban.Options{ Enabled: conf.Security.EnableAutoBan, TrackNotFound: conf.Security.BanTrackNotFound, @@ -98,10 +95,6 @@ func New() *flamego.Flame { return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes) }, }) - // 需要输出详细日志时配置如下 - // if conf.App.Production { - // flamego.SetEnv(flamego.EnvTypeProd) - // } templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"}) if err != nil { @@ -134,11 +127,9 @@ func New() *flamego.Flame { sessioner := session.Sessioner(session.Options{ Initer: initer, Config: sessionStorage, - // 显式声明 cookie 安全属性,不依赖库默认值: - // - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险 - // - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一) - // - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。 - // 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。 + // 显式声明 cookie 安全属性:HTTPOnly 防 XSS 窃取会话;SameSite=Lax 阻断跨站表单 + // POST;Secure 仅在 https 对外服务时下发,避免 http 本地开发下 cookie 不生效 + // (缺失 Secure 时,HSTS 尚未落地的首次访问可被 MITM 诱导明文请求窃取会话)。 Cookie: session.CookieOptions{ HTTPOnly: true, SameSite: http.SameSiteLaxMode, @@ -227,11 +218,11 @@ func New() *flamego.Flame { _, _ = io.Copy(c.ResponseWriter(), fs) }) - // IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex / Seznam - // 等支持 IndexNow 的搜索引擎校验站点所有权(提交 URL 时的 keyLocation 指向此处)。 - // ⚠️ flamego 的 Static 中间件 Prefix 为 "/static",不会接管本路径,必须在此显式注册, - // 否则请求会落到 f.NotFound 被 302 重定向到首页,搜索引擎校验必然失败。 - // 文件名用 indexNowKeyPattern 严格白名单校验,避免路径穿越读取 embed FS 内其它文件。 + // IndexNow 密钥文件:static/index-now/<32 位十六进制>.txt,供 Bing / Yandex 等 + // 搜索引擎校验站点所有权。 + // ⚠️ flamego Static 的 Prefix 是 "/static",不会接管本路径,必须显式注册, + // 否则会落到 f.NotFound 被 302 到首页,校验必然失败。 + // 文件名经 indexNowKeyPattern 白名单校验,避免路径穿越读 embed FS 内其它文件。 f.Get("/index-now/{key}", func(c context.Context) { key := c.Param("key") if !indexNowKeyPattern.MatchString(key) { @@ -282,7 +273,7 @@ func New() *flamego.Flame { f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp) }, question.Questioner) - // 新增:私聊路由(需要登录,使用 Token 标识对话) + // 私聊路由(需登录,Token 标识对话) f.Group("/chat/{token}", func() { f.Get("", chat.ConversationPage) f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage) @@ -300,16 +291,14 @@ func New() *flamego.Flame { f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit) }) - // 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化) - // 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效, - // 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。 + // 公开页语言切换:语言偏好写入 session 后跳回原页面。此前写独立 lang cookie + // 会被 session 中间件的 Set-Cookie 整体覆盖而失效,故托管在 session 中。 f.Get("/i18n/{lang}", func(c context.Context) { lang := i18n.Resolve(c.Param("lang")) - // 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。 c.Session.Set("lang", lang) to := c.Query("to") - // 仅允许站内相对路径,防开放重定向: - // 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。 + // 仅允许站内相对路径,防开放重定向:拒绝空、非 "/" 开头、协议相对路径 "//", + // 以及含反斜杠 / 控制字符的变体。 if to == "" || !strings.HasPrefix(to, "/") || strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") { to = "/" @@ -360,13 +349,10 @@ func New() *flamego.Flame { f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota) }) }) - // 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update) - // 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经 - // ?tab= 参数回跳到来源面板。 + // 防骚扰与全局屏蔽词拆为两个独立入口,避免同表单一次写两列时相互清空。 + // 保存后经 ?tab= 参数回跳到来源面板。 f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment) f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords) - // 内容审核严格度:与屏蔽词一样在提问/私聊/投票三个面板共用一份, - // 保存后经 ?tab= 参数回跳到来源面板。 f.Post("/moderation-strictness/update", form.Bind(form.UpdateModerationStrictness{}), user.UpdateModerationStrictness) f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags) // Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录) @@ -418,12 +404,11 @@ func New() *flamego.Flame { csrf.Csrfer(csrf.Options{ Secret: conf.Server.XSRFKey, Header: "X-CSRF-Token", - // ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。 - // flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID") - // 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算 - // (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的 - // /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。 - // 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。 + // ⚠️ 必须与 login.go 的 ctx.Session.Set("uid", ...) 一致。flamego/csrf 默认 + // SessionKey 为 "userID",不显式指定则 s.Get("userID") 恒为 nil、token 内 id + // 恒为 "0";校验是无状态 HMAC 重算(不与 session 内 token 比对),匿名访客从 + // 公开页取到的 token 会对所有已登录用户有效,CSRF 退化为全站公开常量。 + // 回归测试见 route/csrf_binding_test.go。 SessionKey: CSRFUserSessionKey, }), template.Templater(template.Options{ @@ -433,13 +418,12 @@ func New() *flamego.Flame { context.Contexter(), ) f.NotFound(func(ctx flamego.Context) { - // 真正的 404 探测在此记录(仅此处,不把业务 302→/ 误判为扫描)。 - // 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/", - // 因此这里在 Redirect 之前记录,确保状态为 404。 - // 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合), - // 否则 404 计数会记在另一个键上,导致封禁永不生效。 - // ⚠️ 该 404 计数受 ban_track_notfound 配置控制,默认关闭(正常业务一律放行, - // 仅敏感扫描路径计数封禁);Record 内部对未开启的 KindNotFound 直接忽略。 + // 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先 + // 写 404 状态再 Redirect,故须在 Redirect 前记录。 + // 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合), + // 否则计数落在另一个键上,封禁永不生效。 + // ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的 + // KindNotFound。 if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" { ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound) } diff --git a/internal/security/censor/aliyun.go b/internal/security/censor/aliyun.go index b2c289f..f0d6a7c 100644 --- a/internal/security/censor/aliyun.go +++ b/internal/security/censor/aliyun.go @@ -4,20 +4,14 @@ package censor -// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。 +// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」(TextModeration)。 // -// 迁移记录(2026-09):本通道原走「内容安全 1.0」(/green/text/scan + alibaba-cloud-sdk-go), -// 但 1.0 的开通前提是**企业实名认证**,个人认证账号无法开通,接口会永久返回 -// `code=596 You have not opened Yundun Content Moderation Service`。 -// 现改为 2.0(TextModeration + alibabacloud-go/green-20220302/v3):2.0 只要求实名认证 -// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。 +// 1.0(/green/text/scan)需企业实名认证,个人账号永久返回 code=596,故迁 2.0(仅需实名认证); +// 2.0 标签对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。官方参考: +// - API:https://help.aliyun.com/practice_detail/434034 +// - 标签:https://help.aliyun.com/practice_detail/465315 // -// 官方文档: -// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034 -// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315 -// -// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表: -// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。 +// ⚠️ SDK 里 TextModerationResponseBodyData.Labels 的内联注释是旧表且不完整(缺涉政标签),以官方为准。 import ( "context" @@ -35,38 +29,28 @@ import ( ) const ( - // aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出 - // green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。 + // aliyunRegionID 接入地域,SDK 据此解析出 green-cip.cn-shanghai.aliyuncs.com。 aliyunRegionID = "cn-shanghai" - // aliyunTextService 文本审核增强版的服务场景。 - // - // 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」, - // 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅) - // 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息; - // 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。 + // aliyunTextService 服务场景。选 comment_detection:官方定位「评论/弹幕/公聊/转发」, + // 覆盖涉黄/涉政/涉暴底线与广告导流。三条链路共用且接口层不带场景, + // 若要按场景细分(如私聊改 chat_detection)需给审核入口补 scene 参数。 aliyunTextService = "comment_detection" - // aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。 - // 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。 + // aliyunContentRuneLimit 单次 content 硬上限(官方 600),按 550 切分留安全余量。 aliyunContentRuneLimit = 550 - // aliyunChunkOverlapRunes 相邻分片的重叠字符数。 - // 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。 - // 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。 + // aliyunChunkOverlapRunes 相邻分片重叠字符数:防止风险词正好落在切分点而被漏判。 aliyunChunkOverlapRunes = 50 aliyunReadTimeoutMs = 10000 aliyunConnectTimeoutMs = 5000 - // aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计), - // 避免异常响应体过大时把日志刷屏。 + // aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),避免日志刷屏。 aliyunMaxRawLogBytes = 512 - // aliyunTextModerationAPI 归一化响应的格式标记。 - // - // 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应), - // 让 censor.go 的缓存回放能安全地退回一次实时调用, + // aliyunTextModerationAPI 归一化响应的格式标记:解析器据此拒绝非本格式载荷 + // (如迁移前 1.0 的响应),使 censor.go 的缓存回放能安全退回实时调用, // 而不是把无法识别的历史缓存误判成「通过」。 aliyunTextModerationAPI = "TextModeration" ) @@ -82,8 +66,7 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor { } } -// 包级复用的 client:审核请求高频且密钥在启动后不变, -// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。 +// 包级复用 client:密钥启动后不变,用 sync.Once 懒加载一次,避免每次审核都重建连接。 var ( aliyunGreenOnce sync.Once aliyunGreenClient *green20220302.Client @@ -93,11 +76,8 @@ var ( warnAliyunAccountIssueDoOnce sync.Once ) -// newAliyunClient 构造内容安全 2.0 客户端。 -// -// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai -// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒 -// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。 +// newAliyunClient 构造 2.0 客户端。设 RegionId 而非 Endpoint:SDK 的 EndpointMap 会解析出 +// green-cip.cn-shanghai.aliyuncs.com。超时单位为毫秒。 func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) { regionID := aliyunRegionID readTimeout := aliyunReadTimeoutMs @@ -123,10 +103,8 @@ func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) { return aliyunGreenClient, aliyunGreenErr } -// newAliyunTextModerationRequest 构造 TextModeration 请求。 -// -// 单独抽出便于测试断言请求形态:Service 必须是选定场景, -// ServiceParameters 是 JSON 字符串 {"content": ...}。 +// newAliyunTextModerationRequest 构造 TextModeration 请求(Service 为场景, +// ServiceParameters 为 JSON 串 {"content": ...})。单独抽出便于测试断言请求形态。 func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationRequest, error) { serviceParameters, err := json.Marshal(map[string]string{"content": text}) if err != nil { @@ -137,13 +115,10 @@ func newAliyunTextModerationRequest(text string) (*green20220302.TextModerationR SetServiceParameters(string(serviceParameters)), nil } -// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。 -// -// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为 -// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。 -// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。 -// -// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。 +// splitAliyunText 按 rune 切成不超过单次上限的分片。 +// 必须切分:2.0 单次硬上限 600 字符,而本站上限为 提问 1000 / 回答·追问·私聊 2000 / +// 投票描述 5000 字;超长直发会被拒,在 fail-closed 下等于「长内容一律发不出去」。 +// 分片带重叠以消除「风险词正好跨切分点」的漏判盲区。 func splitAliyunText(text string) []string { runes := []rune(text) if len(runes) <= aliyunContentRuneLimit { @@ -165,19 +140,16 @@ func splitAliyunText(text string) []string { return chunks } -// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。 -// -// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。 +// aliyunTextModerationReason 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。 type aliyunTextModerationReason struct { RiskLevel string `json:"riskLevel"` RiskTips string `json:"riskTips"` RiskWords string `json:"riskWords"` } -// parseAliyunReason 解析 Data.Reason。 -// -// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段, -// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。 +// parseAliyunReason 解析 Data.Reason。字段缺失或非法 JSON 时返回零值 —— +// 忽略解析错误是刻意的:Reason 是辅助字段,不应因其格式变化导致整条审核失败 +// (风险级别拿不到时的保守处理见 aliyunRiskLevelScore)。 func parseAliyunReason(raw string) aliyunTextModerationReason { var reason aliyunTextModerationReason if strings.TrimSpace(raw) == "" { @@ -254,11 +226,8 @@ func aliyunHighestRiskLevel(levels []string) string { return best } -// aliyunTextModerationRaw 归一化后的可落库响应。 -// -// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式: -// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser; -// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。 +// aliyunTextModerationRaw 归一化后的可落库响应:在线调用与缓存回放两条路径的唯一输入格式, +// 最终都交给 AliyunTextCensorParser 解析。 type aliyunTextModerationRaw struct { // API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。 API string `json:"api"` @@ -266,7 +235,7 @@ type aliyunTextModerationRaw struct { Code int32 `json:"code"` Message string `json:"message"` RequestIDs []string `json:"requestIds,omitempty"` - // Labels 已过滤掉 nonLabel / normal,且已去重。 + // Labels 已过滤 nonLabel / normal 并去重。 Labels []string `json:"labels,omitempty"` RiskLevel string `json:"riskLevel,omitempty"` Hint string `json:"hint,omitempty"` @@ -275,8 +244,7 @@ type aliyunTextModerationRaw struct { // aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。 var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`) -// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。 -// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。 +// aliyunChunkResult 单个分片结果的最小信息集,使「多分片合并」可脱离 SDK 单独测试。 type aliyunChunkResult struct { StatusCode int32 Code int32 @@ -288,9 +256,8 @@ type aliyunChunkResult struct { } // extractAliyunChunkResult 从 SDK 响应里提取分片结果。 -// -// code 非 200/0 表示请求被阿里云拒绝(服务未开通、限流、参数错误等), -// 必须报错而不是当成「未命中」—— 否则 fail-closed 会被悄悄变成 fail-open。 +// code 非 200/0 表示请求被拒(未开通、限流、参数错误等),必须报错而非当成「未命中」 +// —— 否则 fail-closed 会被悄悄变成 fail-open。 func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyunChunkResult, error) { if resp == nil || resp.Body == nil { return aliyunChunkResult{}, errors.New("text moderation returned an empty response body") @@ -338,12 +305,8 @@ func extractAliyunChunkResult(resp *green20220302.TextModerationResponse) (aliyu return result, nil } -// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。 -// -// 合并口径: -// - 标签取并集(任一分片命中即视为整条内容命中); -// - 风险级别取最高(high > medium > low > none),未知级别按最高处理; -// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。 +// mergeAliyunChunkResults 把多个分片结果合并成一份归一化响应:标签取并集、 +// 风险级别取最高(未知级别按最高处理)、Hint 取首个非空。 func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw { merged := aliyunTextModerationRaw{ API: aliyunTextModerationAPI, @@ -440,17 +403,14 @@ func aliyunRawDigest(raw []byte) string { return s } -// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。 -// -// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。 +// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse(在线调用与缓存回放共用)。 func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) { var parsed aliyunTextModerationRaw if err := json.Unmarshal(raw, &parsed); err != nil { return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw)) } - // 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。 - // 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用; + // 格式标记不符(典型是迁移前 1.0 落库的缓存)必须报错,交由 censor.go 退回实时调用; // 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。 if parsed.API != aliyunTextModerationAPI { return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)", @@ -460,14 +420,12 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) { // 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。 score := aliyunRiskLevelScore(parsed.RiskLevel) - // Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 —— - // 是否拦截由 policy.go 按箱主严格度决定。 + // Pass 只表示「未给出风险标签」,不是最终裁决(是否拦截由 policy.go 按严格度决定)。 pass := len(parsed.Labels) == 0 suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel) - // 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。 - // 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel), - // 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。 + // 与七牛一致:判为「非通过」却没给建议时补成 block(如命中尚未映射的新标签), + // 不补会被策略层当成未命中放行,与改造前的 fail-closed 行为不一致。 if suggestion == "" && !pass { suggestion = SuggestionBlock } @@ -480,8 +438,7 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) { for _, label := range parsed.Labels { mapped := formatAliyunForbiddenType(label) if mapped == "" { - // 未映射的标签不进入明细:它无法参与按维度裁决。 - // 原始标签仍完整保留在 RawResponse 中供审计。 + // 未映射的标签无法参与按维度裁决,不进明细(原始标签仍保留在 RawResponse 供审计)。 continue } @@ -506,25 +463,10 @@ func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) { }, nil } -// formatAliyunForbiddenType 把内容安全 2.0 的标签映射为本项目的 ForbiddenType。 -// -// 标签取值以官方《文本审核增强版标签处理建议参考》为准;括号内为官方释义与风险级别: -// -// political_content / regional 涉政 (高) -// violence 暴恐 (高) -// sexual_content / sexuality 色情 (高) -// contraband 违禁 (高) -// ad / PullinTraffic / ad_compliance 广告引流/广告法合规(中高) -// profanity 辱骂 (中高) -// religion 宗教 (中高) -// negative_content / cyberbullying 不良内容/网络暴力 (中) -// nonsense 无意义 (中) -// spam 垃圾信息 -// C_customized 自定义词库命中 -// -// 映射结果决定 policy.go 的裁决:政治/暴恐/色情/自定义属底线维度,任何档位都拦。 -// 注意 2.0 没有「灌水(flood)」维度(同类内容归入 spam),因此 ForbiddenTypeFlood -// 不会由本通道产生,七牛仍可能返回它。 +// formatAliyunForbiddenType 把 2.0 标签映射为 ForbiddenType(取值以官方 +// 《文本审核增强版标签处理建议参考》为准;SDK 内联的标签表是旧版且不完整,勿采信)。 +// 政治 / 暴恐 / 色情 / 自定义属底线维度,任何档位都拦。 +// 2.0 没有「灌水(flood)」维度(同类归入 spam),故 ForbiddenTypeFlood 不由本通道产生。 func formatAliyunForbiddenType(typ string) ForbiddenType { switch strings.ToLower(strings.TrimSpace(typ)) { case "political_content", "regional": @@ -553,10 +495,9 @@ func formatAliyunForbiddenType(typ string) ForbiddenType { } } -// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径: -// - 596 / "not opened":服务未开通 -// - Forbidden:RAM 未授权 -// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误 +// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本: +// 596 / "not opened" 服务未开通;Forbidden / no permission RAM 未授权; +// InvalidAccessKeyId / SignatureDoesNotMatch 密钥错误。 var aliyunAccountIssueSignals = []string{ "596", "not opened", @@ -592,12 +533,9 @@ func aliyunIsAccountIssue(err error) bool { return false } -// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。 -// -// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路 -// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道, -// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。 -// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。 +// warnAliyunAccountIssueOnce 首次出现「账号侧未就绪」失败时告警一次(sync.Once 避免刷屏)。 +// 这类问题(未开通 / RAM 未授权 / 密钥无效)不会自愈,会永久卡死 fail-closed 的审核链路, +// 表现为「全站内容发不出去」,而日志里原本只是一条普通 ERRO、极易被当成偶发网络抖动。 func warnAliyunAccountIssueOnce(ctx context.Context, err error) { if !aliyunIsAccountIssue(err) { return diff --git a/internal/security/censor/censor.go b/internal/security/censor/censor.go index 9d82b25..bb59225 100644 --- a/internal/security/censor/censor.go +++ b/internal/security/censor/censor.go @@ -24,13 +24,10 @@ var ( warnNoCensorCredentialsOnce sync.Once ) -// censorChain 返回按配置选定的审核通道列表,顺序即优先级: -// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。 -// -// 取值来源 [security] text_censor_source(见 conf.CensorSource*): -// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验) -// - aliyun → 只用阿里云(同上) -// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致 +// censorChain 返回按 [security] text_censor_source 选定的通道列表,顺序即优先级: +// 靠前通道判定通过即返回,未通过则交给后面的通道复核。 +// qiniu / aliyun 只用对应通道(凭据齐备性已由 conf.Init 启动校验); +// 其它取值(含留空 / auto)为七牛优先、阿里云兜底,与引入该配置前一致。 func censorChain() []TextCensor { qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret) aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret) @@ -45,11 +42,9 @@ func censorChain() []TextCensor { } } -// warnIfNoCensorCredentials 在「配置里一条可用凭据都没有」时告警一次。 -// -// 这种情况下审核调用必然失败,而调用方是 fail-closed(拒绝提交),表现为 -// 全站发不出内容/私信——现象隐蔽、只在日志可见,因此第一次触发就留下明确线索。 -// 显式选定通道但缺凭据的情况已由 conf.Init 直接挡住,这里兜的是 auto 的漏网情况。 +// warnIfNoCensorCredentials 在一条可用凭据都没有时告警一次:此时审核必然失败, +// 而调用方是 fail-closed(拒绝提交),表现为全站发不出内容/私信且只在日志可见。 +// 显式选定通道但缺凭据已由 conf.Init 挡住,这里兜的是 auto 的漏网情况。 func warnIfNoCensorCredentials(ctx context.Context) { qiniuReady := conf.App.QiniuAccessKey != "" && conf.App.QiniuAccessSecret != "" aliyunReady := conf.App.AliyunAccessKey != "" && conf.App.AliyunAccessKeySecret != "" @@ -62,35 +57,35 @@ func warnIfNoCensorCredentials(ctx context.Context) { }) } -// Text checks the text for sensitive content. -// It will save the censor log to the database and invoke the callback function. +// Text 检查文本是否含敏感内容,并把审核结果落库(31 天内可回放复用)。 func Text(ctx context.Context, text string) (*TextCensorResponse, error) { if !conf.Security.EnableTextCensor { return &TextCensorResponse{Pass: true}, nil } + // 缓存键与送审内容统一用归一化文本(已剔除可信域名链接):否则缓存按原文命中、 + // 实际送审的却是归一化结果 —— 审计对不上,且归一化后相同的原文会各占一条缓存、重复调 API。 + normalized := RemoveTrustedURL(text) + var responses []*TextCensorResponse warnIfNoCensorCredentials(ctx) for _, censor := range censorChain() { sourceName := censor.String() - // Try to get the censor from the database log. - censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, text, time.Now().Add(-censorCacheNoMoreThan)) + censorLog, err := db.CensorLogs.GetByText(ctx, sourceName, normalized, time.Now().Add(-censorCacheNoMoreThan)) if err != nil { if !errors.Is(err, db.ErrCensorLogsNotFound) { logrus.WithContext(ctx).WithError(err).Error("Failed to get censor log") } } else { raw := censorLog.RawResponse - // We got the previous censor log cache, seems like we saved money for the API call. switch censorLog.SourceName { case "qiniu": - // 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。 - // 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云, - // 这是引入通道配置前就有的行为)。 - // text_censor_source = qiniu 时链上只有七牛:continue 后循环结束, - // 仍会由函数末尾返回这份七牛结果,判定不会丢失。 + // 只有「通过」才直接复用(省一次 API 调用);未通过则交给链上下一个通道复核 + // (auto 模式下即阿里云,引入通道配置前就是如此)。 + // text_censor_source=qiniu 时链上只有七牛,continue 后循环结束, + // 仍由函数末尾返回这份结果,判定不会丢失。 response, err := QiniuTextCensorParser(raw) if err == nil { if response.Pass { @@ -102,14 +97,19 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) { } case "aliyun": - // 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。 - // 解析失败必须退回一次实时调用,而不是把错误往上抛: - // 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」, - // 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些 - // 历史文本在缓存过期(31 天)前一直发不出去。 + // 能解析就复用;解析失败必须退回实时调用而不是把错误往上抛: + // 最典型的失败源是迁到内容安全 2.0 前落库的 1.0 缓存,无法按 2.0 语义解释, + // 若在此报错,fail-closed 会让这些历史文本在缓存过期前一直发不出去。 + // 与七牛一致:仅「通过」才直接返回,未通过则交给链上下一个通道复核 + // (阿里云现为链尾,continue 后循环结束,仍由函数末尾返回同一份结果)。 response, err := AliyunTextCensorParser(raw) if err == nil { - return response, nil + if response.Pass { + return response, nil + } + + responses = append(responses, response) + continue } logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName). Warn("Failed to replay cached aliyun censor result; falling back to a live call") @@ -119,19 +119,16 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) { } } - // response, err := censor.Censor(ctx, text) - censorText := RemoveTrustedURL(text) - response, err := censor.Censor(ctx, censorText) + response, err := censor.Censor(ctx, normalized) if err != nil { logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).Error("Failed to censor text") continue } responses = append(responses, response) - // Save the censor log to the database. if err := db.CensorLogs.Create(ctx, db.CreateCensorLogOptions{ SourceName: sourceName, - Input: text, + Input: normalized, Pass: response.Pass, RawResponse: response.RawResponse, }); err != nil { diff --git a/internal/security/censor/policy.go b/internal/security/censor/policy.go index 78a3488..c60e0fe 100644 --- a/internal/security/censor/policy.go +++ b/internal/security/censor/policy.go @@ -5,40 +5,21 @@ package censor // Strictness 内容审核严格度档位。 -// -// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进, -// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准, -// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单), -// 唯一能控制的是「审核命中之后到底拦不拦」。 -// -// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决, -// 让箱主在「少误伤」与「少漏放」之间自行取舍。 +// 审核服务调不松、不可配白名单,可控的只有「命中后拦不拦」,故把拦截从 Pass 判定剥离、改由本档位裁决。 type Strictness string const ( - // StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。 + // StrictnessLax 宽松:仅拦底线维度,误伤最小、漏放最多。 StrictnessLax Strictness = "lax" - // StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。 + // StrictnessStandard 标准:底线 + 高置信度的辱骂/违禁。 StrictnessStandard Strictness = "standard" - // StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。 - // - // 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。 - // 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block), - // 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。 - // 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。 + // StrictnessStrict 严格:底线 + 全部非正常维度(不限置信度)+ 采信服务 block 建议。 + // 严格档 ⊇ 改造前行为(改造前为 !Pass),「至少与改造前一样严」,并非完全等价。 StrictnessStrict Strictness = "strict" ) -// DefaultStrictness 缺省档位。 -// -// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。 -// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号 -// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。 -// -// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着 -// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict): -// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。 -// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。 +// DefaultStrictness 缺省取 strict:部署不应悄悄放宽任何账号,「调松」必须是箱主显式动作; +// 想默认更少误伤可改 Standard。 const DefaultStrictness = StrictnessStrict // Suggestion 审核服务自身给出的管控建议取值。 @@ -48,14 +29,9 @@ const ( SuggestionPass = "pass" ) -// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。 -// -// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭, -// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。 -// -// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云) -// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质, -// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。 +// baselineBlockLabels 底线拦截维度:涉政/暴恐/色情/自定义词库。 +// 合规底线:任何档位强制生效、不受置信度约束、不可关闭、不可绕过(见 Decide 最先判定)。 +// Customized 归入底线:它是控制台显式配置的词库,要放开应改控制台词库而非调档位。 var baselineBlockLabels = map[ForbiddenType]struct{}{ ForbiddenTypePolitics: {}, ForbiddenTypeTerrorism: {}, @@ -63,35 +39,23 @@ var baselineBlockLabels = map[ForbiddenType]struct{}{ ForbiddenTypeCustomized: {}, } -// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。 -// -// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂), -// 因此标准档要求高置信度才拦截;严格档则为任意置信度。 -// -// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给 -// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值 -// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。 +// abuseScoreFloor 标准档下「辱骂」生效所需最低置信度。 +// 辱骂误伤最集中(吐槽常被判辱骂),故标准档要求高置信度;严格档不限。阿里云 2.0 只给 riskLevel,下语义为「只拦 high」。 const abuseScoreFloor = 0.9 // levelPolicy 单一档位的拦截规则。 type levelPolicy struct { - // blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。 - // 注意:底线维度不在此表中,由 Decide 无条件前置判定。 + // blockLabels 该档位额外拦截的维度 -> 生效最低置信度(0 表示不限)。底线维度不在此表,由 Decide 前置判定。 blockLabels map[ForbiddenType]float64 - // blockOnSuggestion 是否采信审核服务自身的 block 建议。 - // - // 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大, - // 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。 + // blockOnSuggestion 是否采信服务自身的 block 建议:不携带维度、无法收敛置信度,误伤面最大,仅严格档启用。 blockOnSuggestion bool } var strictnessPolicies = map[Strictness]levelPolicy{ - // 宽松:只留底线,其余全部放行。 StrictnessLax: { blockLabels: map[ForbiddenType]float64{}, blockOnSuggestion: false, }, - // 标准:底线 + 高置信度辱骂 + 违禁。 StrictnessStandard: { blockLabels: map[ForbiddenType]float64{ ForbiddenTypeAbuse: abuseScoreFloor, @@ -99,7 +63,6 @@ var strictnessPolicies = map[Strictness]levelPolicy{ }, blockOnSuggestion: false, }, - // 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。 StrictnessStrict: { blockLabels: map[ForbiddenType]float64{ ForbiddenTypeSpam: 0, @@ -127,8 +90,8 @@ const ( // Blocked 是否应当拒绝提交。 func (d Decision) Blocked() bool { return d == DecisionBlock } -// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。 -// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。 +// IsValidStrictness 校验档位取值合法性,供后台保存前校验; +// 非法值一律拒绝写入,避免脏数据把账号静默切到非预期档位。 func IsValidStrictness(s string) bool { switch Strictness(s) { case StrictnessLax, StrictnessStandard, StrictnessStrict: @@ -138,8 +101,8 @@ func IsValidStrictness(s string) bool { } } -// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。 -// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。 +// ParseStrictness 归一化严格度,未知/空值回退 DefaultStrictness(最严方向); +// 宁可沿用严格判定,也不要因识别失败静默放到最松档。 func ParseStrictness(s string) Strictness { if !IsValidStrictness(s) { return DefaultStrictness @@ -147,15 +110,9 @@ func ParseStrictness(s string) Strictness { return Strictness(s) } -// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。 -// -// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换: -// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉; -// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度; -// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。 -// -// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时, -// 只有开启 blockOnSuggestion 的档位会拦截。 +// Decide 按档位裁决一次审核结果,返回裁决与触发拦截的维度(无具体维度时 nil)。 +// 判定顺序固定不可调换:底线(无条件)→ 档位维度(带置信度门槛)→ 服务 block 建议(仅严格档)。 +// Details 为空时仅开启 blockOnSuggestion 的档位会拦截。 func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) { if r == nil { return DecisionAllow, nil @@ -163,7 +120,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) { policy := strictnessPolicies[ParseStrictness(strictness)] - // 1. 底线:合规底线,任何档位强制生效、不可关闭。 + // 1. 底线:任何档位强制生效、不可关闭。 for i := range r.Details { detail := &r.Details[i] if _, ok := baselineBlockLabels[detail.Label]; ok { @@ -171,7 +128,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) { } } - // 2. 档位维度:命中所配维度且达到该维度的置信度门槛。 + // 2. 档位维度:命中维度且达到置信度门槛。 for i := range r.Details { detail := &r.Details[i] scoreFloor, ok := policy.blockLabels[detail.Label] @@ -184,7 +141,7 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) { return DecisionBlock, detail } - // 3. 审核服务自身的 block 建议:仅严格档采信。 + // 3. 服务 block 建议:仅严格档采信。 if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock { return DecisionBlock, nil } @@ -192,12 +149,8 @@ func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) { return DecisionAllow, nil } -// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。 -// -// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现 -// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案 -// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层 -// 生成文案,这里是唯一事实来源。 +// BlockedLabels 返回指定档位最终拦截的全部维度(含底线)。 +// 不变量由 policy_test.go 守住(底线维度在每个档位都出现且不重复);设置页文案仍是 i18n 静态文本,未消费本函数。 func BlockedLabels(strictness string) []ForbiddenType { policy := strictnessPolicies[ParseStrictness(strictness)] diff --git a/internal/security/censor/text.go b/internal/security/censor/text.go index f43ec11..5212014 100644 --- a/internal/security/censor/text.go +++ b/internal/security/censor/text.go @@ -24,9 +24,8 @@ const ( ForbiddenTypeContraband ForbiddenType = "contraband" ForbiddenTypeMeaningless ForbiddenType = "meaningless" ForbiddenTypeHarmful ForbiddenType = "harmful" - // ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置, - // 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则, - // 在 policy.go 中被归入底线集合,任何档位都不放行。 + // ForbiddenTypeCustomized 命中服务侧自定义词库(阿里云控制台配置,七牛无服务端词库)。 + // 属运营方显式规则,在 policy.go 中归入底线集合,任何档位都不放行。 ForbiddenTypeCustomized ForbiddenType = "customized" ) @@ -46,16 +45,12 @@ func (f ForbiddenType) String() string { }[f] } -// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。 +// LabelScore 单个维度(label)的判定与置信度。过去只取首个非 normal 维度, +// 丢掉了其余维度信息,无法按维度差异化处置(例如只拦高置信度的辱骂)。 // -// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度, -// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。 -// -// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。 -// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel, -// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的 -// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。 -// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。 +// ⚠️ Score 统一归一化到 0~1:七牛本身即 0~1;阿里云 2.0 无逐标签置信度,只给整条内容的 +// riskLevel,由 AliyunTextCensorParser 折算(high=1 / medium=0.6 / low=0.3)后该响应内所有 +// 标签共用——有损近似。policy.go 的阈值因此对两家都成立(对阿里云语义为「按风险级别分级」)。 type LabelScore struct { Label ForbiddenType `json:"label"` Score float64 `json:"score"` @@ -68,11 +63,9 @@ type TextCensorResponse struct { Hint string `json:"hint"` Confidence float64 `json:"confidence"` RawResponse json.RawMessage `json:"raw_response"` - // Details 全部非 normal 维度的判定明细,供严格度策略按维度裁决。 - // 仅在回放缓存结果时为 nil(见 censor.go),此时 Pass 已足以表达判定。 - Details []LabelScore `json:"details,omitempty"` - // ProviderSuggestion 审核服务自身给出的管控建议(block / review / pass)。 - // 它不携带具体维度,无法做置信度收敛,因此只在严格档被采信(见 policy.go)。 + Details []LabelScore `json:"details,omitempty"` // 全部非 normal 维度,供严格度策略裁决;由各 parser 从 RawResponse 重建,缓存回放同样有值(故改档位对历史缓存也生效) + // ProviderSuggestion 服务方管控建议(block / review / pass)。不携带维度、无法做 + // 置信度收敛,因此只在严格档被采信(见 policy.go)。 ProviderSuggestion string `json:"provider_suggestion,omitempty"` } @@ -81,12 +74,9 @@ func (r *TextCensorResponse) ToJSON() []byte { return jsonBytes } -// ErrorMessageFor 生成针对指定命中维度的拒绝提示。 -// -// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的 -// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。 -// 因此拦截时统一用本方法,按真正命中的维度出文案。 -// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。 +// ErrorMessageFor 按真正命中的维度生成拒绝提示——已移除的 ErrorMessage 固定用首个非 +// normal 维度,而触发拦截的可能是另一维度(策略层按档位裁决),会导致文案对不上。 +// detail 为 nil(由服务方整体 block 建议触发)时退化为不带维度的基础文案。 func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string { errorMessage := "内容安全检查不通过" if detail != nil && detail.Label != "" { @@ -110,8 +100,7 @@ var TrustedHosts = []string{ "acfun.cn", "aixifan.com", "github.com", - // 自有域名:旧域 tama.guru 保留至 2026-12 到期前的过渡期结束, - // 期间用户分享的旧链接仍需视为可信(不被判为广告);新域 shiroko.one 为主域。 + // 旧域 tama.guru 保留至 2026-12 到期,过渡期内旧分享链接仍需视为可信。 "tama.guru", "shiroko.one", "337845818.best", @@ -119,10 +108,9 @@ var TrustedHosts = []string{ "bilibili.com", } -// RemoveTrustedURL removes the trusted URL from the text, -// in order to bypass the URL censoring. -// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于字符串结尾时才视为可信, -// 避免 github.com.evil.tld 被朴素替换误判为 github.com 之子域而漏审。 +// RemoveTrustedURL 移除可信域名以免链接被判为广告。 +// 仅当可信域名后紧跟路径分隔符(/ ? #)或位于串尾时才视为可信, +// 避免 github.com.evil.tld 被朴素替换误判为 github.com 子域而漏审。 func RemoveTrustedURL(str string) string { for _, host := range TrustedHosts { for _, protocol := range []string{"http://", "https://"} { diff --git a/internal/shared/ip.go b/internal/shared/ip.go index b5c7f2c..1ae1f1d 100644 --- a/internal/shared/ip.go +++ b/internal/shared/ip.go @@ -9,11 +9,8 @@ import ( "github.com/tamakyi/TamaBox/internal/conf" ) -// NormalizeIP 规范化 IP 字符串: -// - 去除两端空白 -// - 剥离端口("1.2.3.4:8080" / "[::1]:8080") -// - 校验合法性,非法返回空串 -// - IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单/限流精确匹配 +// NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串), +// 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。 func NormalizeIP(s string) string { s = strings.TrimSpace(s) if s == "" { @@ -33,21 +30,15 @@ func NormalizeIP(s string) string { } // LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。 +// IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址), +// 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。 // -// - IPv4:原样返回(每个地址独立计数)。 -// - IPv6:掩码到 /64 前缀后返回,形如 "2001:db8:1:2::/64"。 -// -// 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。 -// 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次, -// 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。 -// /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。 -// -// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续使用精确匹配(见 ipblacklist.go), -// 不要套用本函数,否则会把整个 /64 段一并拉黑。 +// ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go), +// 否则会把整个 /64 段一并拉黑。 func LimitKey(ip string) string { parsed := net.ParseIP(ip) if parsed == nil { - // 无法解析时原样返回,保持调用方行为可预期(空串由调用方自行判断)。 + // 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。 return ip } if v4 := parsed.To4(); v4 != nil { @@ -59,14 +50,11 @@ func LimitKey(ip string) string { // trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。 var trustedProxyNets []*net.IPNet -// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔的 IP / CIDR 列表), -// 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB), -// 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。 +// InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。 +// 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。 // -// 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。 -// 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或 -// 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时 -// CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。 +// 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull +// 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。 func InitTrustedProxies() error { trustedProxyNets = nil for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") { @@ -114,36 +102,22 @@ func isTrustedProxy(ipStr string) bool { // ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。 // -// 解析策略(不依赖上游 CDN 的庞大 CIDR 清单): -// 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。 -// 2. 开启 TrustProxy: -// a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP -// (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定 -// 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。 -// b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析 -// X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。 -// c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。 +// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头 +// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左 +// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。 // -// ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行 -// 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy -// 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源 -// 网段」等手段锁定源站,使伪造头无法抵达应用。 +// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造 +// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。 // -// 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束: -// 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经 -// Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 / -// 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段; -// ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带 -// 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。 -// Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可; -// EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。 +// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段; +// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份, +// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。 func ResolveClientIP(r *http.Request) string { if !conf.Security.TrustProxy { return NormalizeIP(r.RemoteAddr) } - // (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯 - // EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。 + // (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。 if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" { return cfIP } diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 7de1aaf..b9de464 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -109,24 +109,22 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as return "", errors.Wrap(err, "reset reader") } - // 4. 根据格式进行转换或直接使用原图 + // 4. 按格式转换或直接使用原图 switch format { case "gif": webpData, err := convertGifToAnimatedWebP(fileData) if err != nil { - // 转换失败则回退到原始 GIF - uploadData = fileData + uploadData = fileData // 转换失败回退原始 GIF contentType = "image/gif" } else { uploadData = webpData contentType = "image/webp" } default: - // 静态图片(JPEG / PNG / WebP 等)转换为静态 WebP + // 静态图片(JPEG / PNG / WebP 等)转静态 WebP webpData, err := ConvertStaticImageToWebP(fileReader) if err != nil { - // 转换失败时回退到原始图片 - uploadData = fileData + uploadData = fileData // 转换失败回退原图 contentType = mimeTypeFromFormat(format) } else { uploadData = webpData diff --git a/internal/template/markdown.go b/internal/template/markdown.go index deeee2f..1ebc8c6 100644 --- a/internal/template/markdown.go +++ b/internal/template/markdown.go @@ -15,10 +15,9 @@ import ( "golang.org/x/net/html" ) -// blockEndTags 与 reBlockEndBrList:删除块级元素闭合标签紧前的
。 -// HardLineBreak 会把块级元素末尾的换行也渲染成
,产生多余空行; -// 这里仅清除末尾的
(可含中间空白),行内真正的换行保留。 -// 注意:RE2 不支持 lookahead,故对每个闭合标签单独编译。 +// blockEndTags / reBlockEndBrList 删除块级元素闭合标签紧前的
:HardLineBreak 会把 +// 块级元素末尾的换行也渲染成
,产生多余空行(行内换行保留)。 +// RE2 不支持 lookahead,故对每个闭合标签单独编译。 var blockEndTags = []string{ "", "

", "", "", "", "", "", "", "", "", "", @@ -27,7 +26,6 @@ var blockEndTags = []string{ var reBlockEndBrList = func() []*regexp.Regexp { res := make([]*regexp.Regexp, 0, len(blockEndTags)) for _, tag := range blockEndTags { - // 捕获闭合标签本身,替换时用 $1 保留 res = append(res, regexp.MustCompile(`
[ \t\r\n]*(`+regexp.QuoteMeta(tag)+`)`)) } return res @@ -36,8 +34,7 @@ var reBlockEndBrList = func() []*regexp.Regexp { // reBlankLines 压缩连续换行为单个换行(MarkdownToPlainText 提取时块级/br 会产生多个 \n)。 var reBlankLines = regexp.MustCompile(`\n{2,}`) -// PlainText 渲染 Markdown 为纯文本并压缩连续空行为单个换行, -// 供列表/摘要等紧凑展示场景使用(保留文字与换行,不显示 Markdown 符号与排版)。 +// PlainText 渲染为纯文本并压缩连续空行,供列表/摘要等紧凑场景使用。 func PlainText(markdown string) string { if strings.TrimSpace(markdown) == "" { return "" @@ -45,15 +42,9 @@ func PlainText(markdown string) string { return reBlankLines.ReplaceAllString(MarkdownToPlainText(markdown), "\n") } -// MarkdownToHTML 将 Markdown 渲染为安全的 HTML 片段(供模板直接输出)。 -// -// 安全性:先用 blackfriday 渲染,再经白名单过滤器清洗: -// - 只保留常用排版标签与属性(a[href]/img[src,alt,title] 等) -// - 丢弃 script / style / iframe / object / embed / form 等危险标签 -// - href/src 仅允许 http/https/mailto,其余一律去除 -// - 剥离所有 on* 事件属性 -// -// 这样既支持 Markdown 排版,又不会引入存储型 XSS。 +// MarkdownToHTML 渲染为安全的 HTML 片段。先由 blackfriday 渲染,再经白名单过滤: +// 只保留常用排版标签与属性;丢弃 script / style / iframe / form 等危险标签; +// href/src 仅允许 http/https/mailto;剥离所有 on* 事件属性。避免存储型 XSS。 func MarkdownToHTML(markdown string) template.HTML { if strings.TrimSpace(markdown) == "" { return "" @@ -117,7 +108,6 @@ func extractPlainText(raw []byte) string { } } -// 允许的标签集合。 var allowedTags = map[string]bool{ "p": true, "br": true, "hr": true, "div": true, "span": true, "strong": true, "em": true, "b": true, "i": true, "u": true, "s": true, @@ -141,13 +131,12 @@ func sanitizeHTML(raw []byte) string { case html.ErrorToken: return out.String() case html.TextToken: - // Tokenizer.Text returns unescaped text. Escape it again before - // rebuilding HTML, otherwise input such as <script> can become - // executable markup in the template.HTML result. + // ⚠️ Tokenizer.Text 返回已反转义文本,重建 HTML 前必须重新转义, + // 否则 <script> 这类输入会在 template.HTML 结果中变成可执行标记。 out.WriteString(stdhtml.EscapeString(string(tokenizer.Text()))) case html.StartTagToken, html.SelfClosingTagToken: - // SelfClosingTagToken 覆盖 `
` 这类自闭合写法(blackfriday 的硬换行即此形式), - // 漏掉会导致
被丢弃、换行失效。 + // SelfClosingTagToken 覆盖 `
` 自闭合写法(blackfriday 硬换行即此形式), + // 漏掉会丢弃
、换行失效。 writeSanitizedTag(&out, tokenizer.Token(), false) case html.EndTagToken: writeSanitizedTag(&out, tokenizer.Token(), true) @@ -182,7 +171,6 @@ func writeSanitizedTag(out *bytes.Buffer, tok html.Token, isEnd bool) { key := strings.ToLower(attr.Key) val := strings.TrimSpace(attr.Val) - // 过滤事件属性与危险属性 if strings.HasPrefix(key, "on") { continue } @@ -247,8 +235,7 @@ func safeURL(u string) bool { lower := strings.ToLower(strings.TrimSpace(u)) // 必须先于下面的 "/" 前缀判断:协议相对 URL(//evil.com)同样以 "/" 开头, - // 若不单独拦截会被误判为站内相对路径,从而绕过"仅 http/https/mailto"的白名单本意, - // 使 markdown 中的 [x](//evil.com) 渲染成指向外站的链接(钓鱼 / 外链埋点)。 + // 不单独拦截会被误判为站内相对路径,[x](//evil.com) 即可指向外站(钓鱼 / 外链埋点)。 // 反斜杠变体(\\evil.com、/\evil.com)在部分浏览器中等价于 //,一并拒绝。 if strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, `\\`) || strings.HasPrefix(lower, `/\`) { diff --git a/route/question/follow_up.go b/route/question/follow_up.go index 2838785..075ea58 100644 --- a/route/question/follow_up.go +++ b/route/question/follow_up.go @@ -128,7 +128,7 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi // 邮件通知提问箱主人(开启邮件提醒时) shared.SafeGo(func() { if pageUser.Notify == db.NotifyTypeEmail { - if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content); err != nil { + if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content, question.Token); err != nil { logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user") } } diff --git a/route/user/harassment.go b/route/user/harassment.go index 08c6e13..006be5c 100644 --- a/route/user/harassment.go +++ b/route/user/harassment.go @@ -15,24 +15,17 @@ import ( "github.com/tamakyi/TamaBox/internal/i18n" ) -// returnTab 表单提交后回跳的账号设置面板:屏蔽词设置已分散展示在 -// 提问/私聊/投票三个面板,action 携带 ?tab= 来源参数,保存后停留原面板。 +// returnTab 表单提交后回跳的面板:action 携带 ?tab= 来源参数,保存后停留原面板。 func returnTab(ctx context.Context) string { return profileURLWithTab(ProfileTab(ctx.Query("tab"))) } -// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入。 +// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入,不触碰既有配置。 // -// 背景:form.Bind 在反序列化失败(如请求体超限、body 畸形)时只置位错误并继续执行 -// handler 链(flamego 语义:响应未写即自动续链),零值结构体会照常进入业务动作—— -// 对不检查 ctx.HasError() 的 handler,会把用户既有配置静默清空/关闭,例如: -// - UpdateHarassment:RegisterOnly="" → 防骚扰被改回 none(关闭"仅允许注册用户"); -// - UpdateBlockWords:BlockWords="" → 全部屏蔽词被清空; -// - UpdateTags:Tags="" → 标签被清空、ShowInHome 被关; -// - UpdatePagePassword / UpdateAskPassword:Password="" → 访问/提问密码被解除。 -// -// 错误信息已由 Bind 经 context.SetError 写入 Data["Error"](govalid 校验错误已按界面 -// 语言翻译,解析错误为原文),此处转入 flash,保证 PRG 重定向后仍能向用户展示。 +// ⚠️ form.Bind 反序列化失败(请求体超限、body 畸形)时只置位错误并继续走 handler 链 +// (flamego 语义:响应未写即自动续链),零值结构体会进入业务动作,把用户既有配置静默清空: +// 防骚扰被改回 none、屏蔽词/标签被清空、访问与提问密码被解除。 +// 错误信息已由 Bind 写入 Data["Error"],此处转入 flash 以便 PRG 重定向后仍可展示。 func abortOnFormError(ctx context.Context, redirectTo string) bool { if !ctx.HasError() { return false @@ -44,12 +37,11 @@ func abortOnFormError(ctx context.Context, redirectTo string) bool { return true } -// UpdateHarassment 仅更新「防骚扰类型」(none / register_only,即"仅允许注册用户向我提问")。 -// 只写 harassment_setting 列;全局屏蔽词已拆分为独立入口 UpdateBlockWords, -// 二者互不覆盖(此前同一个表单一次写两列,面板拆分后单侧保存会误清空另一侧)。 +// UpdateHarassment 仅更新防骚扰类型(none / register_only)。只写 harassment_setting 列: +// 全局屏蔽词已拆分为 UpdateBlockWords,二者互不覆盖(同表单一次写两列时, +// 面板拆分后单侧保存会误清空另一侧)。 func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) { - // 反序列化失败(如请求体超限)时零值 RegisterOnly="" 会把防骚扰静默改回 none - // (若此前为 register_only)——先中止,不触碰现有设置。 + // 零值 RegisterOnly="" 会把防骚扰静默改回 none(见 abortOnFormError)。 if abortOnFormError(ctx, returnTab(ctx)) { return } @@ -70,11 +62,10 @@ func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) { ctx.Redirect(returnTab(ctx)) } -// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票 -// 提交的开放文本共用同一份词表(users.block_words)。 +// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票提交的 +// 开放文本共用同一份词表(users.block_words)。 func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) { - // 反序列化失败(如请求体超限)时零值 BlockWords="" 会经 sanitizeBlockWords 得到 - // 空词表,静默清空用户全部屏蔽词——先中止,不触碰现有词表。 + // 零值 BlockWords="" 会经 sanitizeBlockWords 得到空词表,静默清空全部屏蔽词。 if abortOnFormError(ctx, returnTab(ctx)) { return } @@ -101,12 +92,9 @@ func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) { ctx.Redirect(returnTab(ctx)) } -// sanitizeBlockWords 把输入的屏蔽词串规整为去重后的词表: -// - 全角逗号转半角;逐词 TrimSpace;跳过空词与重复词。 -// - 单词长度按字符(rune)计数,超限(conf.Limits.MaxBlockWordLen)返回超长提示; -// - 词总数超限(conf.Limits.MaxBlockWords)返回数量提示。 -// -// 成功时 errKey 为空,errWord 为触发超长的具体词(供 flash 展示)。 +// sanitizeBlockWords 把输入词串规整为去重词表:全角逗号转半角、逐词 TrimSpace、 +// 跳过空词与重复词;单词长度超限(conf.Limits.MaxBlockWordLen)或总数超限 +// (conf.Limits.MaxBlockWords)返回对应提示。成功时 errKey 为空,errWord 为超长的具体词。 func sanitizeBlockWords(raw string) (words []string, errKey string, errWord string) { blockWords := strings.ReplaceAll(raw, ",", ",") blockWords = strings.TrimSpace(blockWords) @@ -123,9 +111,8 @@ func sanitizeBlockWords(raw string) (words []string, errKey string, errWord stri } wordSet[word] = struct{}{} - // 按字符(rune)计数:早期用 len(word) 按字节计数,中文一词 3 字节, + // 按 rune 计数:早期用 len(word) 按字节算,中文一词 3 字节, // 导致中文屏蔽词实际只能输 3 个字,与界面文案「最大长度为 10」不符。 - // 上限取自 conf.Limits.MaxBlockWordLen([limits] 配置段)。 if len([]rune(word)) > conf.Limits.MaxBlockWordLen { return nil, "admin.flash.blockWordTooLong", word } diff --git a/route/user/vote.go b/route/user/vote.go index 781506e..e92561f 100644 --- a/route/user/vote.go +++ b/route/user/vote.go @@ -30,12 +30,10 @@ import ( votepkg "github.com/tamakyi/TamaBox/route/vote" ) -// voteTextRepliesCache 箱主表单投票"回复明细"(逐条文本)的短 TTL 缓存。 -// 该视图仅在箱主结果页展示,且需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)), -// 缓存可显著降低重算开销;结果页非强一致,30s 陈旧可接受。 +// voteTextRepliesCache 表单投票「回复明细」的短 TTL 缓存:该视图需逐条 json.Unmarshal +// 全部提交记录(O(N)),结果页非强一致,30s 陈旧可接受。 var voteTextRepliesCache = cache.NewTTLCache(64) -// voteTextRepliesCacheTTL 回复明细缓存有效期。 const voteTextRepliesCacheTTL = 30 * time.Second // ---------------- 表单解析 ---------------- @@ -58,19 +56,15 @@ type voteForm struct { censorMetadata datatypes.JSON // 标题审核结果 } -// censorCheck 对单段文本做内容审核。 -// -// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由 -// 投票发起人本人的「审核严格度」档位决定。 -// fail-closed:审核服务异常时拒绝发布;未开启 EnableTextCensor 时底层直接放行。 +// censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫, +// 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。 func censorCheck(ctx context.Context, text string) error { _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), text) return err } -// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空字符串返回 nil。 -// 使用 time.Local 解析:datetime-local 提交的是无时区信息的时间串,按服务端本地时区解释, -// 与 Evaluate 中 time.Now() 的比较基准保持一致(旧的 time.Parse 会解析成 UTC,产生时区偏移)。 +// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空串返回 nil。 +// 用 time.Local 而非 time.Parse:后者会解析成 UTC,与 time.Now() 比较时产生时区偏移。 func parseTimeInput(ctx context.Context, s string) (*time.Time, error) { s = strings.TrimSpace(s) if s == "" { @@ -83,11 +77,9 @@ func parseTimeInput(ctx context.Context, s string) (*time.Time, error) { return &t, nil } -// parseVoteForm 手动解析投票表单(完整:元信息 + 类型 + 选项/字段)。 -// 说明:不使用 form.Bind —— Bind 的反射绑定会把 string 直接赋给 int 字段导致 panic, -// 且选项/表单字段数组需要 JSON 解析,手动解析更安全。 -// draft=true 时放宽“选项/字段数量”与内容审核要求(详见 parseVoteMeta/parseOptions/parseFormFields), -// 使草稿可随时暂存、不丢失已填内容。 +// parseVoteForm 手动解析投票表单(元信息 + 类型 + 选项/字段)。 +// 不用 form.Bind:其反射绑定会把 string 直接赋给 int 字段导致 panic, +// 且选项/字段数组需 JSON 解析。draft=true 时放宽数量与审核要求,便于随时暂存。 func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) { form, err := parseVoteMeta(ctx, draft) if err != nil { @@ -114,7 +106,6 @@ func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) { } form.opts.MaxChoices = maxChoices - // 选项 / 表单字段解析与校验 if voteType == db.VoteTypeForm { if err := parseFormFields(ctx, form, draft); err != nil { return nil, err @@ -134,9 +125,8 @@ func parseVoteMetaForm(ctx context.Context) (*voteForm, error) { return parseVoteMeta(ctx, false) } -// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。 -// draft=true 时放宽校验并跳过内容审核:草稿为私有内容,保存时不应因审核服务抖动而丢失; -// 审核统一在发布(VotePublish)时执行,确保对外公开内容仍受管控。 +// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。draft=true 时放宽校验并跳过 +// 内容审核:草稿为私有内容,不应因审核服务抖动而丢失;审核统一在发布时补齐。 func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) { get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) } @@ -193,8 +183,7 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) { // 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启,关闭复选框无法解除。 requireCaptcha := true - // 该策略由业务规则固定为一人一票;不从客户端读取已废弃的配置字段, - // 避免旧页面或手工请求写入与数据库约束冲突的多票语义。 + // 一人一票由业务规则固定;不从客户端读取已废弃字段,避免与数据库约束冲突。 maxVotes := 1 startAt, err := parseTimeInput(ctx, get("start_at")) @@ -358,8 +347,7 @@ func parseFormFields(ctx context.Context, form *voteForm, draft bool) error { if o == db.CustomFieldMarker { return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label)) } - // 单个选项长度与单选/多选选项同规则(vote-form.html 前端 maxlength 同源, - // 此前仅有前端软限制,这里补齐后端兜底校验) + // 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验) if len([]rune(o)) > conf.Limits.VoteOptionLen { return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen)) } @@ -414,10 +402,9 @@ func getOwnedVote(ctx context.Context, voteIDStr string) (*db.Vote, error) { // ---------------- 处理器 ---------------- -// VoteList 投票管理列表(含每票参与数统计)。 // effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断): -// published 且未到开始时间 → 未开始;published 且已过结束时间 → 已结束; -// 其余与 status 字段一致。管理列表据此展示,避免"已过结束时间仍显示进行中"。 +// published 且未到开始时间 → 未开始;已过结束时间 → 已结束。 +// 避免「已过结束时间仍显示进行中」。 func effectiveVoteStatus(v *db.Vote, now time.Time) string { if v.Status != db.VoteStatusPublished { return v.Status @@ -466,10 +453,8 @@ type voteItem struct { } // buildVoteItems 将投票列表转换为管理面板所需的富信息条目。 -// QuestionList 的「我的提问箱 · 投票」面板与此前独立的投票管理列表共用,避免重复统计逻辑。 -// -// 性能:参与数与选项数通过 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN (...)), -// 替代原先对每票各发 2 次 COUNT 的 N+1 查询,N 张投票从 2N 条 SQL 降为 2 条。 +// 性能:参与数与选项数经 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN), +// 替代原先每票各发 2 次 COUNT 的 N+1 查询(2N 条 SQL 降为 2 条)。 func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem { lang := i18n.Default if l, ok := ctx.Data["Lang"].(string); ok && l != "" { @@ -479,7 +464,6 @@ func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem { now := time.Now() items := make([]*voteItem, 0, len(votes)) - // 批量预取参与数与选项数(避免 N+1) ids := make([]uint, 0, len(votes)) for _, v := range votes { ids = append(ids, v.ID) @@ -544,13 +528,11 @@ func VoteNew(ctx context.Context) { ctx.Success("user/vote-form") } -// renderVoteFormWithInput 在保存失败时重新渲染投票表单并回填用户已提交的内容, -// 避免校验/审核/数据库错误导致整页输入被清空(原先 Redirect("/user/votes/new") 会丢失全部数据)。 +// renderVoteFormWithInput 在保存失败时重新渲染表单并回填已提交内容,避免整页输入被清空。 // isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。 // -// flashMsg/internal 用于在同请求内直接写入 c.Data 的错误提示(而非 session flash): -// 本项目的 flash 在请求开始时读取进 c.Data,随后写入 session 的 flash 不会被本次渲染读到, -// 且会残留到下一次请求造成"陈旧错误"串页;故错误回填采用直接写 c.Data 的方式。 +// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data, +// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。 func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) { ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS) get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) } @@ -631,9 +613,9 @@ func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flas ctx.Success("user/vote-form") } -// VoteCreate 创建投票(默认草稿状态)。 -// 草稿保存放宽校验并跳过内容审核(见 parseVoteForm/parseVoteMeta);任何错误均回填表单而非清空输入。 -// action=publish 时:先按草稿保存,再执行发布校验(结构 + 审核),通过则直接发布;失败则保留草稿并停留在编辑页,避免重复创建。 +// VoteCreate 创建投票(默认草稿状态)。草稿保存放宽校验并跳过内容审核; +// 任何错误均回填表单而非清空输入。action=publish 时先存草稿再做发布校验, +// 失败则保留草稿并停留在编辑页,避免重复创建。 func VoteCreate(ctx context.Context) { action := strings.TrimSpace(ctx.Request().FormValue("action")) @@ -673,7 +655,6 @@ func VoteCreate(ctx context.Context) { editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit" - // action=publish:已保存为草稿,再做发布校验;失败则保留草稿并停留在编辑页,避免重复创建 if action == "publish" { if err := validateForPublish(ctx, vote); err != nil { ctx.SetErrorFlash(err.Error()) @@ -717,9 +698,8 @@ func VoteEdit(ctx context.Context) { // 发布后的投票锁定类型与选项/字段(避免破坏历史投票数据归因),仅可编辑元信息 ctx.Data["LockedContent"] = vote.Status == db.VoteStatusPublished - // 选项 / 表单字段回填:传结构化数据,模板经 toJSON(template.JS)注入