aliyun sensor server
This commit is contained in:
@@ -38,3 +38,298 @@
|
||||
- 修订原则:**显式规则硬拦,算法推断只标记**。箱主自定义屏蔽词+高危三类(politics/terrorism/porn)硬拦;abuse/spam/ad/flood/contraband/meaningless 只打标记照常送达;审核不可用改 fail-open+标记待审。
|
||||
- 落地:questions/messages 加标记列(AutoMigrate 自动加列)+ 后台折叠/筛选 + 设置页「审核严格度」三档(严格/宽松(默认)/仅标记)+ 用 `detail.Score` 自建阈值(默认偏高)。
|
||||
- 该路线零延迟零外部依赖,顺带消掉 SSRF/Key 明文/Key 被刷/邮件轰炸 4 个 P0。
|
||||
|
||||
---
|
||||
|
||||
## 「审核严格度」实现(已落地,构建/vet/测试全绿)
|
||||
|
||||
**备份(回滚锚点)**:`C:/OtherFiles/Github/TamaBox-v4-mainline-pre-moderation-strictness-20260910.tar.gz`
|
||||
(git 仓库损坏,用户指示「不要动 git」,改用 tar.gz;304 条目,sha256 `c6cfce2b...`)。回滚 = 解压覆盖回工作目录。
|
||||
|
||||
**核心设计:把「是否拦截」从审核服务自身的 Pass 判定中剥离**,新增策略层 `internal/security/censor/policy.go`。
|
||||
- 档位:`lax`(仅底线)/ `standard`(底线 + abuse≥0.9 + contraband)/ `strict`(底线 + 全部非 normal 维度不限分 + 采信服务 block 建议)。
|
||||
- 底线 `baselineBlockLabels`:politics / terrorism / porn / **customized**;任何档位无条件拦截、不可关闭(`Decide` 中排第一、无条件 return)。
|
||||
- 判定顺序固定:底线 → 档位维度(带分数门槛)→ 服务建议(仅 strict)。
|
||||
- 缺省 `DefaultStrictness = strict`;非法值 `ParseStrictness` 一律回退 strict(保守)。
|
||||
|
||||
**⚠️ 重要事实修正(本次发现)**:strict 档 **并非**「与升级前等价」,而是 **⊇ 升级前**。
|
||||
- 升级前拦截条件 = `!Pass` = (politics|terrorism|porn) 或 服务 suggestion==block(见备份 `route/question/page.go:443`)。
|
||||
- strict 额外把 abuse/spam/ad/flood/contraband/meaningless/harmful 全部纳入(不限置信度)→ 比升级前更严。
|
||||
- 已把散落各处「升级不改行为 / 行为等价」的错误注释全部改正(policy.go / moderation.go / conf/static.go / app.sample.ini / db/users.go / i18n 文案)。
|
||||
- **待用户拍板的取舍**:DefaultStrictness 是否维持 strict。若用户首要诉求是「减少误伤」,应改为 standard(一行常量);维持 strict 则部署后默认拦截面略扩。
|
||||
|
||||
**阿里云兼容(用户本轮要求,已完成)**:
|
||||
- **rate 量纲 bug(关键)**:阿里云 `rate` 是 0~100,七牛 `score` 是 0~1;不归一化会让 `abuseScoreFloor=0.9` 在阿里云下恒成立、standard 档分级失效。已加 `normalizeAliyunRate()`(>1 则 /100,clamp 0~1)。
|
||||
- 补映射 `harmful→ForbiddenTypeHarmful`(覆盖用户最初想要的「负面情绪」,仅在 strict 档生效)、`customized→ForbiddenTypeCustomized`(进底线)。
|
||||
- 未映射 label 不进 `Details`(无法按维度裁决);`results` 为空时 `ProviderSuggestion=block`(对齐 fail-closed)。
|
||||
- 结论:**可直接复用,无需改调用方**;只需上述 parser 层归一化/映射,三家通道(七牛/阿里云)共用同一 `Decide`。
|
||||
|
||||
**统一入口**:`internal/security/moderation/moderation.go`
|
||||
- `Check(ctx, strictness, lang, text)` = 调审核 → `censor.Decide` → 返回 err(本地化)。审核不可用仍 fail-closed。
|
||||
- `StrictnessFor(raw)` = 账号设置优先,空则跟随 `conf.Security.DefaultModerationStrictness`,再回退 strict。
|
||||
- 三场景入口已全部改走 `moderation.Check`(grep 可复核):
|
||||
- 提问/回答:`route/question/page.go:441,574`、`question.go:170`、`follow_up.go:105,164`
|
||||
- 投票:`route/vote/vote.go:72`、`route/user/vote.go:67,159,1012`
|
||||
- 私聊:`internal/route/chat/chat_handler.go:259`(用 `pageUser`=箱主,非访客)
|
||||
|
||||
**改动文件清单**:
|
||||
- 新增:`internal/security/censor/policy.go`、`policy_test.go`、`internal/security/moderation/moderation.go`、`route/user/moderation.go`、`templates/user/moderation-strictness.html`
|
||||
- 修改:`censor/{text,qiniu,aliyun}.go`、`conf/static.go`、`db/users.go`、`form/user.go`、`internal/route/route.go`、`route/question/{page,follow_up,question}.go`、`internal/route/chat/chat_handler.go`、`route/vote/vote.go`、`route/user/vote.go`、`i18n/dict_{zh_cn,zh_tw,en,ja}.go`、`templates/user/profile.html`、`templates/render_test.go`、`conf/app.sample.ini`
|
||||
- 设置页表单分别挂在 profile 的 提问/私聊/投票 三个面板。
|
||||
|
||||
**验证方式(Windows 沙箱无 libwebp/gcc,需 overlay)**:
|
||||
```
|
||||
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||||
go test -overlay="..." ./internal/security/... ./internal/i18n/... ./templates/... ./route/... ./internal/route/...
|
||||
```
|
||||
结果:build 0、`go vet ./...` 0、上述测试全 ok。新增 6 个阿里云/策略用例(normalizeAliyunRate / AliyunParserFeedsPolicy / CustomizedHitsBaseline / NoResultsFailsClosed / UnmappedLabelSkipped)。
|
||||
注:`internal/db` 测试需真实 DB,环境缺失,与本次改动无关;`censor.go`、`text_test.go` 存在**升级前就有**的空格缩进(gofmt -l 会列出),未动以避噪音。
|
||||
|
||||
---
|
||||
|
||||
## 第二轮:改动回归审阅(与备份全量 diff 逐项复核)
|
||||
|
||||
方法:把备份 tar.gz 解到 `%TEMP%/tb-review/base`,对工作区做 `diff -rq` 拿到精确变更集,再逐项查 bug。
|
||||
**结论:无 P0(无安全/正确性回归)**。关键安全性质已核实:strict 档拦截条件 **⊇ 升级前**,故默认档下不可能出现「以前拦、现在放」。`censor.Text` 的 31 天缓存命中后**重新走 parser**(`censor.go:50/61`),`Details`/`ProviderSuggestion` 会被重建,不存在「缓存命中导致档位判定失效」。`EnableTextCensor=false` 时 parser 返回 `{Pass:true}` 且 Details 为空 → `Decide` 放行,开发环境不受影响。
|
||||
|
||||
### 已修(本轮)
|
||||
|
||||
- **🔴 P1 设置页档位回显错误(真实 bug)**:`moderation_strictness` 为空(存量账号,语义=跟随站点默认)时,模板三个 `option` 的 `eq` 全为假 → 浏览器默认选中第一个选项「宽松」,而实际生效是站点默认(通常 strict)。界面显示的档位与真实拦截行为**相反**,直接违背「让用户在切换前就知道会被拦什么」的需求。
|
||||
- 修法:新增模板函数 `moderationStrictness`(`internal/template/template.go`,内部调 `moderation.StrictnessFor`,与 policy 同源),模板改用 `$current := moderationStrictness $root.LoggedUser.ModerationStrictness` 回显。
|
||||
- 未用「在 Profile handler 注入 data key」的方案:全仓有 **16 处** `ctx.Success("user/profile")`(各 Update* 校验失败回显路径),漏一处就会让模板读到缺失 key。
|
||||
- 补 `TestRenderProfileStrictnessReflectsEffectiveTier`(未设置/lax/standard/strict/非法值 5 例)。
|
||||
- **🟠 P2 `censorVoteForPublish` 档位口径不一致**:同一函数内 `vote.Title` 用 `voteOwnerStrictness(ctx, vote.UserID)`,而 `Description`/选项/字段标签走 `censorCheck`(内部读 `ctx.User`)。当前因 `getOwnedVote` 强校验 `vote.UserID == ctx.User.ID` 而**不可达**(3 个调用路径 678/907/944 都归属校验过),属潜在缺陷。已改为函数开头解析一次 `strictness` 并全程复用。
|
||||
- **🟠 P2 i18n 字典 diff 污染**:插入新 flash 键时切断了原有对齐组,导致 `admin.flash.deactivated`/`admin.export.errUser`/`admin.export.errQuestion` 三行在 4 个字典里被 gofmt 重新对齐(3 行×4 文件)。已把新键移到该组之后、独立成组,恢复原 3 行。**教训:往 gofmt 对齐组中间插键会把「拆分后的剩余组」重新对齐;应插在组末尾或独立成组。**
|
||||
- **🟠 P2 死代码**:`TextCensorResponse.ErrorMessage()` 生产与测试**均无引用**(已被 `ErrorMessageFor` 取代),且其「取首个维度」的语义正是本轮要修的错误来源,留着易被误用 → 已删除。`BlockedLabels()` 仅测试使用,但注释声称「供设置页展示」→ 已改为如实描述(UI 目前用 i18n 静态文案)。
|
||||
|
||||
### 已核查为非问题(勿重复排查)
|
||||
|
||||
- `internal/cmd/censor.go:57,68` 仍直接调 `censor.Text`:这是 `tamabox censor` **回填 CLI**,只写 `content_censor_metadata` 审计字段、不做拦截放行判定,不属三场景提交闸门 → 无需改走 `moderation.Check`。
|
||||
- 全仓已无「直接判 `resp.Pass` 决定放行」的残留(仅 `db/censor_log.go` 存字段)。
|
||||
- `LoggedUser` 每个请求都由 `authenticatedUser()` 回源 `db.Users.GetByID`(`internal/context/auth.go:22`),保存后重定向读到的一定是新值,不存在「保存后下拉框还是旧值」。
|
||||
- `route/user/moderation.go` 用 `ctx.User.ID` 且挂在 `reqUserSignIn` 组内(`internal/route/route.go:374`)→ 越权改他人档位不可行;与 `UpdateHarassment/UpdateBlockWords` 同构(同样无 rateLimit,保持一致)。
|
||||
|
||||
### 待用户处理 / 决策
|
||||
|
||||
1. **`templates/user/moderation-strictness.html` 被注入 `data-page-node-id` 属性**(全仓仅此文件有,备份/其它 40+ 模板均无 → 非项目约定,疑为页面编辑器标注)。它会**污染生产 HTML**,并让 `render_test.go` 的精确子串断言失败。我未擅自删除(可能属你侧编辑器状态);但已把断言改为正则 `selectedStrictnessOptions()`,不再耦合属性顺序 → 现在两种情况下测试都稳。**请确认是否要清掉这些属性。**
|
||||
2. `DefaultStrictness` 维持 strict 还是改 standard(见上一节):待拍板,一行常量。
|
||||
3. CHANGELOG 未记入:新功能属用户可见变更,按项目惯例应在 `dev/changelog-source.md` 顶部加条目并跑 `python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。**刻意留着等你定了档位口径再写文案**(严格/标准会改变措辞)。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮:用户三项指示
|
||||
|
||||
1. **已删除** `templates/user/moderation-strictness.html` 的 `data-page-node-id` 属性(全仓无任何读取方,纯渲染噪声)。
|
||||
- ⚠️ **重要机制发现**:**用 Write/Edit 工具写这个文件会被自动重新注入该属性**(且 id 每次是新的一批);用 **shell(sed)修改则不会被注入**。判断注入方是「文件工具触发的后处理钩子」,不是文件监听。→ **该文件今后一律用 shell 修改**,不要用 Write/Edit。
|
||||
- 同时把 `render_test.go` 里原本精确匹配 `<option value="standard" selected>` 的断言改成不依赖属性顺序的 `selectedStrictnessOptions()`(正则),这样属性在不在都不会误报。
|
||||
2. **`DefaultStrictness` 维持 strict**(用户已拍板)。仅更新了 `policy.go` 的注释记录该决定与理由,代码不变。
|
||||
3. **新增 `[security] text_censor_source`** —— 此前通道链是硬编码的 `[qiniu, aliyun]`,无法选择。
|
||||
- 取值:`qiniu` / `aliyun` / `auto`(**留空同 auto,即历史行为**:七牛优先、未通过则阿里云二次复核)。
|
||||
- 常量定义在 `conf`(`CensorSourceAuto/Qiniu/Aliyun`),供 `conf.Init` 校验与 `censor.censorChain()` 共用,避免两处字符串漂移。
|
||||
- `conf.Init` 启动校验(纯函数 `validateTextCensorSource`,便于单测):
|
||||
- 审核关闭 → 不校验;留空/auto → 通过(两者都没凭据时仍不阻断启动,否则 sample 原样拷贝无法启动);
|
||||
- 显式选 qiniu/aliyun 但缺凭据 → **启动即报错**(否则运行期审核必然失败,而失败是 fail-closed,会让整站发布/私信不可用且现象隐蔽);
|
||||
- 其它取值 → 报错,**不静默回退**(拼错若被当成 auto,运维会误以为已只用七牛)。
|
||||
- `censor.Text`:循环改为 `for _, censor := range censorChain()`;新增一次性告警 `warnIfNoCensorCredentials`(sync.Once,兜 auto 无凭据的漏网情况,避免按请求刷屏)。
|
||||
- 顺带把原「HACK: Qiniu's API response is not accurate…」注释改写为如实描述链式复核语义,并说明 qiniu-only 时 `continue` 不会丢判定。
|
||||
- 文档:`conf/app.sample.ini` 在 `enable_text_censor` 下新增带注释的 `text_censor_source`。
|
||||
- 测试:新增 `internal/conf/text_censor_source_test.go`(12 例)、`internal/security/censor/censor_source_test.go`(6 例),全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第四轮:阿里云通道「response data is empty」排查
|
||||
|
||||
用户报障日志:
|
||||
```
|
||||
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
|
||||
ERRO Failed to censor text error="no censor source available" strictness=lax
|
||||
```
|
||||
(两条同时出现 = 两个通道都没给出结论 → fail-closed 拒签,即整站发布不可用。)
|
||||
|
||||
### 根因取证(读 SDK 源码 + 比对官方文档,未凭猜测)
|
||||
|
||||
1. 我们的 `aliyun.go` 与**上游 NekoBox `wuhan005/NekoBox` 的 aliyun.go 逐字节一致**,
|
||||
也与**阿里云官方 Go 示例一致**(`green.CreateTextScanRequest()` + `SetContent(json)`,
|
||||
`{"scenes":["antispam"],"tasks":[{"content":...}]}`)。→ 请求构造不是本项目引入的问题。
|
||||
2. 走通了 SDK 调用链,确认「HTTP 2xx + 合法 JSON + `data` 为空」是唯一能产出该错误的组合:
|
||||
- `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走
|
||||
`errors.Wrap(err, "text scan")`,不会是这条消息;
|
||||
- `response.go:58` 只有 `AcceptFormat == JSON` 才尝试解析进 `TextScanResponse`(空结构体,必然成功);
|
||||
- 我们的 parser 用的是 `resp.GetHttpContentBytes()`,body 非 JSON 会报 `unmarshal response`。
|
||||
3. 顺带确认真实端点解析正确:`green.EndpointMap` **不含 `cn-shanghai`**,但会一路回落到
|
||||
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`(正确)。
|
||||
4. 找到一处**确定的代码缺陷**(不是根因,但让根因不可见):旧 parser 在 `len(Data)==0` 时
|
||||
只报 `response data is empty`,把顶层 `code` / `msg` / `requestId` 全丢了 → 用户拿不到任何线索。
|
||||
|
||||
### 已落地改动(`internal/security/censor/aliyun.go` + `policy_test.go`)
|
||||
|
||||
- 抽出 `newTextScanRequest(text)`(便于测试断言请求形态);**显式 `req.SetContentType(requests.Json)`**。
|
||||
SDK 的 ROA 默认值是 `application/octet-stream`(`roa_signature_composer.go:88`)。此改动属
|
||||
**按接口契约对齐**(对已正常工作的环境无副作用),**是否为本例根因未证实**,以下一次日志为准。
|
||||
- 顶层 `code != 0 && != 200` → 全新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..`
|
||||
(与旧「data 为空」同为错误返回,控制流/语义不变)。
|
||||
- `data` 为空 / JSON 解析失败 / 请求体回显:错误里带上 `code/msg/requestId` 与原始响应摘要
|
||||
(`aliyunRawDigest`,按字符截断 ≤512,避免切坏多字节)。
|
||||
- `Censor()` 里 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body。
|
||||
- `data[].code != 200` 仍 fail-closed 置 block(**语义未改**),但在真正决定 fail-closed 的位置补了
|
||||
一条 Warn(code/msg/taskId/requestId)——否则「审核失败」会伪装成「命中违规」而无迹可循。
|
||||
- 顺手修掉 `Censor()` 上错误的文档链接(原指向七牛文档 → 改为阿里云 70439)。
|
||||
- 新增测试:`TestNewTextScanRequestShape`、`TestAliyunRejectedRequestSurfacesCodeAndMsg`、
|
||||
`TestAliyunEmptyDataErrorCarriesRawResponse`、`TestAliyunInvalidJSONErrorCarriesRawResponse`、
|
||||
`TestAliyunRawDigestTruncates`。`gofmt`/`go build`(overlay)/`go vet`/`go test ./internal/security/...` 全绿。
|
||||
|
||||
### 下一步(等用户)
|
||||
|
||||
重新部署后重试一次,新日志会直接显示阿里云返回的 `code/msg/requestId` 与原始响应。
|
||||
账号侧首要排查项:①内容安全 1.0 是否已开通按量付费;②RAM 子账号是否有 Green 权限;③是否欠费停服;
|
||||
④如需跨区可在 `getClient()` 写死 `cn-shanghai` 之外调整(该处目前硬编码)。
|
||||
|
||||
### 待决策
|
||||
|
||||
- 「显式选定通道坏掉 → 整站 fail-closed 不可用」是否要加**启动预检**(启动时发一次探活请求,
|
||||
失败则告警/拒启)。成本:按条计费 + 启动依赖外部网络;收益:把「静默全站拒签」提前暴露。
|
||||
|
||||
### ✅ 根因已确认(同日 15:50,用户回传新日志)
|
||||
|
||||
诊断改动生效后一条日志即定案:
|
||||
|
||||
```
|
||||
error="text scan (request body={"scenes":["antispam"],"tasks":[{"content":"测试!"}]}):
|
||||
aliyun rejected the request: code=596
|
||||
msg="You have not opened Yundun Content Moderation Service, please go to the page
|
||||
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console"
|
||||
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
|
||||
raw={"code":596,"httpStatusCode":200,"msg":"...","success":false}"
|
||||
```
|
||||
|
||||
- **根因 = 阿里云账号未开通「内容安全 1.0」**(账号侧,非代码缺陷)。
|
||||
- 关键实证:响应体里 `httpStatusCode: 200` + `success: false` + 无 `data` → **阿里云「请求被拒也返回 HTTP 200」**,
|
||||
所以旧代码只会报「data 为空」这句废话。这一条坐实了第二节的推断。
|
||||
- **`SetContentType(requests.Json)` 被证伪**,不是根因;保留但已把代码注释改为「接口契约对齐」而非「修 bug」。
|
||||
- 处置:开通 https://www.aliyun.com/product/lvwang(勾选协议点「立即开通」);RAM 子账号需 Green 权限;
|
||||
**开通完成前先切回 `auto`/`qiniu` 恢复线上可用性**。
|
||||
- 防呆:`conf/app.sample.ini` 的 `text_censor_source` 注释补上「配好凭据 ≠ 可用」+ code=596 的完整说明。
|
||||
- 报告已更新为确认版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`。
|
||||
- 仍待决策:①启动预检(或折中=首次账号级拒绝时打一次性带处置指引的告警);②CHANGELOG 条目。
|
||||
|
||||
### ✅✅ 最终根因(同日 16:05,用户开通后仍报 596)
|
||||
|
||||
用户按我给的地址开了「AI 安全护栏」,仍报同样的 `code=596`。查证后确认**我上一轮给的地址是错的,且漏了硬门槛**:
|
||||
|
||||
| 服务 | 开通前提(官方原文) | 个人认证 |
|
||||
| --- | --- | --- |
|
||||
| **内容安全 1.0**(`green` 2018-05-09,本项目在用) | **需账号完成企业认证**(`document_detail/69806.html`,2026-07-23 更新) | ❌ 开不了 |
|
||||
| 内容安全增强版 2.0(`green-cip` 2022-03-02) | 需完成**实名认证**(`document_detail/477720.html`) | ✅ 可开 |
|
||||
|
||||
- 1.0 真正开通入口:`https://common-buy.aliyun.com/?commodityCode=cdi#/open`;
|
||||
`aliyun.com/product/lvwang` 只是产品介绍页(现为「AI 安全护栏」聚合页),点不出「一键开通」。
|
||||
- 所以:**个人实名认证账号 + 1.0 API = 596 永久复现**,与 RAM 权限无关
|
||||
(权限不足返回 403/Forbidden,不是 596;596 是「服务未开通」)。
|
||||
- 已修正 `conf/app.sample.ini` 的注释(换成正确入口 + 写明企业认证前提 + 三条出路)。
|
||||
- 报告更新为最终版:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md`(含 A/B/C 三方案对比表)。
|
||||
- 三条出路:**A** 迁增强版 2.0(个人可开,但要换 `green-cip` + `TextModeration` + 新 SDK,**风险标签体系完全不同,`policy.go` 维度清单需重写**);
|
||||
**B** 账号升级企业实名认证(代码零改动);**C** 放弃阿里云只用七牛(`auto`/`qiniu`,损失「七牛误报二次复核」能力)。
|
||||
- 已就方向征询用户(AskUserQuestion)。
|
||||
|
||||
### ✅✅✅ 已落地:阿里云通道迁移到「内容安全增强版 2.0」
|
||||
|
||||
用户通过 AskUserQuestion 选定:**迁移增强版 2.0** + **加一次性告警**(即原「启动预检」的折中方案)。当日完成编码,构建/vet/测试全绿。
|
||||
|
||||
**⚠️ 关键事实修正(最容易踩的坑)**:
|
||||
`green-20220302` SDK 中 `TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
|
||||
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》(`practice_detail/465315`)为准,
|
||||
2.0 通用服务**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
|
||||
`C_customized`(自定义词库)→ **底线四件套可完整覆盖,`policy.go` 的 `baselineBlockLabels` 无需放宽**。
|
||||
|
||||
**2.0 标签 → ForbiddenType 映射**(`formatAliyunForbiddenType`):
|
||||
`political_content`/`regional`→politics(底线);`violence`→terrorism(底线);`sexual_content`/`sexuality`→porn(底线);
|
||||
`contraband`→contraband;`ad`/`PullinTraffic`/`ad_compliance`→ad;`profanity`→abuse;`religion`→harmful;
|
||||
`negative_content`/`cyberbullying`→harmful;`nonsense`→meaningless;`spam`→spam;`C_customized`→customized(底线)。
|
||||
⚠️ 2.0 无「灌水(flood)」维度(同类归入 spam),`ForbiddenTypeFlood` 不再由阿里云产生(七牛仍会返回)。
|
||||
|
||||
**其他关键事实**:
|
||||
- 2.0 通用服务单次 `content` **硬上限 600 字符**;站点内容上限为 提问 1000 / 回答·追问·私聊 2000 /
|
||||
投票描述 5000 字 → 超长必然超限,故**按 550 字分段、段间重叠 50 字**,标签取并集、风险级别取最高。
|
||||
- 2.0 **不返回逐标签置信度**,只有整条内容的 `riskLevel` → 折算 high=1 / medium=0.6 / low=0.3,
|
||||
该响应内所有标签共用同一分值。副作用:`abuseScoreFloor=0.9` 在阿里云下的实际语义变成
|
||||
「标准档只拦 riskLevel=high 的辱骂」。
|
||||
- 场景固定 `comment_detection`(公聊评论;官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流)。
|
||||
- SDK:`alibabacloud-go/green-20220302/v3 v3.5.4`;`openapiutil.Config{RegionId:"cn-shanghai"}` 由 SDK 的
|
||||
EndpointMap 解析出 `green-cip.cn-shanghai.aliyuncs.com`;`ReadTimeout` / `ConnectTimeout` **单位毫秒**,
|
||||
直接设在 Config 上即可(不必用 RuntimeOptions)。
|
||||
- SDK 错误经 `dara.TeaSDKError` 包成 `*dara.SDKError`(有 `GetCode()` 与 `StatusCode` 字段);
|
||||
账号级故障用「结构化 code + 消息文本」双重识别(`aliyunIsAccountIssue`)。
|
||||
- 2.0 开通前提:**仅需实名认证,个人账号可开通**(`document_detail/477720.html`);
|
||||
1.0 才是「需企业实名认证」,个人账号 596 永久复现。
|
||||
|
||||
**两个设计取舍(容易被后人误删,务必保留)**:
|
||||
1. 归一化响应带 `api:"TextModeration"` 格式标记,解析器拒绝一切非该格式的载荷;
|
||||
`censor.go` 阿里云缓存回放解析失败时**退回实时调用**(原来是直接把错误返回上层)。
|
||||
作用:迁移前 1.0 落库的缓存既不会被误判成「通过」(那会成为绕过审核的后门),
|
||||
也不会因 fail-closed 让这些历史文本在 31 天缓存过期前一直发不出去。
|
||||
2. 首次账号级失败(596 / Forbidden / InvalidAccessKeyId …)打**一条**带开通指引的 WARN
|
||||
(`warnAliyunAccountIssueOnce` + `sync.Once`),之后静默。
|
||||
|
||||
**改动文件**:`internal/security/censor/aliyun.go`(整文件重写)、`aliyun_test.go`(新增测试套件)、
|
||||
`censor.go`(缓存回退)、`text.go` / `policy.go`(注释同步)、`policy_test.go`(删除 1.0 格式旧测试)、
|
||||
`conf/app.sample.ini` + `internal/conf/static.go`(改为 2.0 开通指引);
|
||||
`go mod tidy` 已移除不再引用的 `github.com/aliyun/alibaba-cloud-sdk-go v1.62.26`。
|
||||
|
||||
**校验**:`go build -overlay=... ./...` 通过;`go vet ./internal/...` 通过;
|
||||
`go test ./...` 仅 `internal/db` 失败(信息均为 `Unknown database type: ""`,需真实 PostgreSQL/MySQL,与本改动无关),
|
||||
其余全绿(含 `internal/security/censor`)。
|
||||
`gofmt -l` 仅列 `censor.go` / `text_test.go`(改动前既有的空格缩进,按约定未动)。
|
||||
|
||||
**报告已更新**:`outputs/aliyun-censor-empty-data-diagnosis-2026-09-10.md` 文首新增「最终落地」章节。
|
||||
|
||||
**仍需用户处理**:①到内容安全 2.0 控制台开通「内容审核增强版」按量付费,
|
||||
RAM 子账号授予 `AliyunYundunGreenWebFullAccess`;②CHANGELOG 条目仍未记入
|
||||
(`dev/changelog-source.md` + `python dev/gen_changelog.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 第五轮:私聊审核拦截 UX 修复(用户实测反馈)
|
||||
|
||||
用户实测 2.0 功能正常,但发现**私聊页面被审核拦截后「无任何提示 + 输入框内容消失」**。
|
||||
|
||||
### 根因(两处)
|
||||
|
||||
1. **无提示**:`templates/chat/conversation.html` 没挂 `{{template "base/alert" .}}`,
|
||||
导致 `SendMessage` 里所有 `SetErrorFlash`(过长/被拉黑/审核拦截…)在这页都渲染不出来。
|
||||
2. **内容消失**:审核失败走 `SetErrorFlash` + `Redirect`,重渲染的 `ConversationPage`
|
||||
从不回填 `content`,输入直接清空。
|
||||
|
||||
### 改动
|
||||
|
||||
- `internal/route/chat/chat_handler.go`:把 `ConversationPage` 拆出共享渲染函数
|
||||
`renderConversation(ctx, domain, token, prefillContent, modErr)`;`ConversationPage` 改为其薄封装。
|
||||
私聊审核失败分支由 `SetErrorFlash + Redirect` 改为 `renderConversation(..., content, err)`
|
||||
——**就地重渲染**:`canSend` 为真时把 `prefillContent` 写入 `ctx.Data["Content"]`,
|
||||
并把 `modErr` 写成 `ctx.Data["HasError"]/["Error"]`(与提问链路 `ctx.SetError` 同源)。
|
||||
- `templates/chat/conversation.html`:①加 `{{template "base/alert" .}}`(容器开头);
|
||||
②`<textarea>` 内填充 `{{ .Content }}` 回填已输入内容(html/template 自动转义,安全)。
|
||||
|
||||
### 验证(Windows 沙箱用 overlay 跑,仅 `internal/db` 因缺真实 DB 失败、与本次无关)
|
||||
|
||||
- `go build -overlay=... ./...` 0;`go vet ./internal/...` 0;
|
||||
- `internal/route`、`internal/template`(模板解析)、`internal/conf` 全 ok;
|
||||
- **审核严格度仍生效**:`TestDecideBaselineAlwaysBlocks` / `TestDecideByLevel` /
|
||||
`TestDecideUnknownLevelFallsBackToStrict` / `TestBlockedLabelsIncludesBaselineAtEveryLevel` 全过
|
||||
(底线四件套任何档位强制拦截、未知档回退 strict,未受 2.0 改动影响);
|
||||
- **2.0 迁移未破坏**:`TestAliyunParser*` / `TestExtractAliyunChunkResult` / `TestMergeAliyunChunkResults`
|
||||
/ `TestAliyunIsAccountIssue*` 全过。
|
||||
|
||||
### 备注
|
||||
|
||||
- 新增 `base/alert` 后,私聊页**其它** `SetErrorFlash` 提示(过长/被拉黑/每日上限…)现在也会显示;
|
||||
但那些仍走 Redirect,输入内容仍会丢失。本次只修了用户反馈的审核拦截路径,其余校验失败保留内容属可后续优化项。
|
||||
|
||||
---
|
||||
|
||||
## 环境事实(用户更正,长期有效)
|
||||
|
||||
- **真实运行环境是 Debian**,不要为 Windows 相关问题花时间:本机沙箱才需要 `go build -overlay=...`
|
||||
绕开 libwebp/gcc(cgo),Debian 生产/开发机 `go build ./...` 原生即可,不需要 overlay。
|
||||
- 沙箱内若要用 overlay,注意 **overlay.json 的 Replace 键盘符必须小写**(`c:/...`),
|
||||
大写 `C:/...` 会被 Go 规范化成 `c:\` 后匹配不上、报 `deleted in overlay`;stub 在
|
||||
`dev/s3stub/s3_stub.go`。本次用 `C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-overlay2.json` 跑通。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# TamaBox-v4 项目长期笔记
|
||||
|
||||
## 审计/失败分级惯例(沿用用户偏好)
|
||||
|
||||
- 结论分级:P0/P1/P2,配 🔴(阻断级)/🟠(高)。给修复建议前先做根因取证与并行诊断。
|
||||
- 改代码前先出「方案/变更清单」获批准,再分批落地;用户倾向方案 A 或明确指定方向。
|
||||
- 工作场景用 `@scene#N:"名称"` 标记(如 `@scene#15:"日常开发"`)。
|
||||
- 用户常说「你先继续,后面我发现问题再和你说」→ 可自主推进,但重大问题仍要显式指出。
|
||||
|
||||
## 构建与测试(Windows 沙箱)
|
||||
|
||||
- **本机无 libwebp / GCC**,而 `chai2010/webp`(cgo)会挡住 `go build ./...`。
|
||||
必须用 overlay 把 `internal/storage/s3.go` 换成 `dev/s3stub/s3_stub.go`:
|
||||
```
|
||||
go build -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||||
go test -overlay="C:/Users/Tamakyi Gekka/AppData/Local/Temp/tb-webpcheck/overlay.json" ./...
|
||||
```
|
||||
(`dev/overlay.json` 里的路径指向 TAMAKY~1 短名,可能失效;用上面 Temp 里的那份。)
|
||||
- `internal/db` 的测试需要真实 PostgreSQL/MySQL(否则报 `Unknown database type: ""`),
|
||||
与业务改动无关,可跳过;`go vet` 已能覆盖其编译。
|
||||
- **`proxy.golang.org` 在本沙箱返回 Bad Gateway**:拉新依赖 / `go mod tidy` 前先
|
||||
`export GOPROXY=https://goproxy.cn,direct`。
|
||||
- `internal/security/censor/censor.go`、`internal/security/censor/text_test.go` 存在**升级前就有**的
|
||||
空格缩进(`gofmt -l` 会列出)。除非专门整理,否则不要顺手 gofmt,避免污染 diff。
|
||||
|
||||
## 内容审核架构(简洁版)
|
||||
|
||||
- 线上生效的审核通道是**七牛云**(`ai.qiniuapi.com/v3/text/censor`,scene=antispam);阿里云通道代码存在但 key 留空、不生效。
|
||||
- **通道可选**:`[security] text_censor_source = qiniu | aliyun | auto`(留空同 auto=七牛优先、阿里云兜底,即历史行为)。
|
||||
取值常量在 `conf`(`CensorSource*`),装配在 `censor.censorChain()`,启动校验在 `conf.validateTextCensorSource`。
|
||||
显式选通道却缺凭据会**启动报错**(避免运行期 fail-closed 导致整站不可用)。
|
||||
- 关键事实:七牛 `IsPass()` 只硬拦 politics/terrorism/porn,`review` 一律放行;
|
||||
七牛文本审核**不支持白名单**、自定义词库需提工单 → 服务端「拦得准不准」不可控。
|
||||
- 因此本项目把「是否拦截」从服务商 Pass 判定剥离,收敛到策略层:
|
||||
`internal/security/censor/policy.go`(档位裁决)+ `internal/security/moderation/moderation.go`(唯一入口 `Check`)。
|
||||
- **任何新增的文本审核调用点都必须走 `moderation.Check`**,不要再直接调 `censor.Text`,
|
||||
否则会绕过「审核严格度」档位。三场景:提问/回答、投票、私聊。
|
||||
- 语义约定:`LabelScore.Score` 统一归一化 0~1(七牛本身即 0~1;阿里云 2.0 无逐标签置信度,
|
||||
按 `riskLevel` 折算 high=1 / medium=0.6 / low=0.3,该响应内所有标签共用同一分值)。
|
||||
- 审核服务不可用时 **fail-closed**(拒绝提交)—— 这是既有行为,未经批准不要改成 fail-open。
|
||||
⚠️ 推论:**显式选定的通道一旦坏掉(账号未开通/欠费/权限不足),整站发布+私信会全部被拒**,现象隐蔽(只在日志)。
|
||||
|
||||
### 阿里云 green 通道(`/green/text/scan`, 2018-05-09)
|
||||
- 实现与上游 NekoBox 及阿里云官方 Go 示例**逐字节一致**;`TextScanRequest` 无独立 `Content` 字段,
|
||||
body 走内嵌 `RoaRequest.Content`。**SDK 的 ROA 默认 Content-Type 是 `application/octet-stream`**
|
||||
(`sdk/auth/roa_signature_composer.go:88`),现已显式 `SetContentType(requests.Json)` 对齐接口契约。
|
||||
- 已知故障:HTTP 200 + `data` 为空 → `"response data is empty"`。**该错误此前会吞掉顶层 code/msg/requestId**
|
||||
(唯一的根因线索),现已把 code/msg/requestId + 原始响应摘要(≤512 字符)写进错误。
|
||||
✅ 2026-09-10 实证根因:`code=596 "You have not opened Yundun Content Moderation Service"` ——
|
||||
阿里云回的是 **HTTP 200 + `success:false` + 无 data**,即「请求被拒也返回 200」。
|
||||
⚠️ **硬门槛:「内容安全 1.0」开通前提是账号完成企业实名认证**(官方 69806),个人认证账号**永远开不了**。
|
||||
开通入口是 `common-buy.aliyun.com/?commodityCode=cdi#/open`,不是产品介绍页 `aliyun.com/product/lvwang`。
|
||||
替代方案:迁「内容安全增强版 2.0」(`green-cip`,个人认证可开,但标签体系完全不同、需改代码),
|
||||
或退回 `auto`/`qiniu`。RAM 用户需 `AliyunYundunGreenWebFullAccess`;欠费停服同理。
|
||||
- `data[].code != 200`(任务级失败)仍 fail-closed 置 block,但现在会打 Warn ——
|
||||
否则「审核失败」与「真的违规」在日志里无法区分。
|
||||
- ⚠️ `SetContentType(requests.Json)` 已加,但**已证伪与上述故障无关**,保留原因仅是接口契约对齐。
|
||||
|
||||
## 文件工具陷阱(重要)
|
||||
|
||||
- `templates/user/moderation-strictness.html`:**用 Write/Edit 写它会被自动注入 `data-page-node-id` 属性**
|
||||
(每次一批新 id);**用 shell(sed)改则不会**。注入方是「文件工具触发的后处理钩子」,不是文件监听。
|
||||
→ **该文件今后只用 shell 修改**。相关断言(`templates/render_test.go`)已改成不依赖属性顺序的正则。
|
||||
- 往 gofmt 对齐组**中间**插 map 键,会把「拆分后剩余组」整组重新对齐 → 污染 diff。
|
||||
新键应插在组末尾或独立成组(前后留空行)。i18n 字典已因此返工过一次。
|
||||
|
||||
## 仓库状态
|
||||
- **git 仓库已损坏**(`.git/refs` 缺失、index 截断、对象库近乎空),用户明确要求「不要动 git」。
|
||||
备份/回滚一律用 tar.gz(排除 `.git`、`.workbuddy`),放在项目同级目录:
|
||||
`C:/OtherFiles/Github/TamaBox-v4-mainline-<描述>-<日期>.tar.gz`。
|
||||
@@ -16,6 +16,25 @@ font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件
|
||||
|
||||
[security]
|
||||
enable_text_censor = true
|
||||
text_censor_source = auto ; 文本审核服务通道:qiniu(七牛)/ aliyun(阿里云)/ auto(七牛优先、阿里云兜底)。
|
||||
; 留空等同于 auto,即引入该配置前的历史行为。
|
||||
; 显式选 qiniu / aliyun 时必须在 [app] 配好对应凭据,否则启动即报错
|
||||
; —— 避免运行期审核必然失败(审核失败是 fail-closed,拒绝提交,
|
||||
; 会让发布/私信整站不可用,且现象隐蔽、只在日志可见)。
|
||||
; 凭据位置:[app] 的 qiniu_access_key / qiniu_access_secret、
|
||||
; aliyun_access_key / aliyun_access_key_secret。
|
||||
; ⚠️ 配好凭据 ≠ 可用:阿里云通道走的是「内容安全 2.0 / 文本审核增强版」
|
||||
; (接口 TextModeration,接入点 green-cip.cn-shanghai.aliyuncs.com),
|
||||
; 开通前提只要求【实名认证,个人账号即可】,不需要企业认证。
|
||||
; 开通入口:内容安全 2.0 控制台 → 开通「内容审核增强版」按量付费
|
||||
; (开通本身不收费,按实际调用量出账;文本审核标准 7.5 元/万次)。
|
||||
; 用 RAM 子账号调用需授予 AliyunYundunGreenWebFullAccess;欠费停服同理。
|
||||
; 审核场景固定为 comment_detection(公聊评论检测,覆盖涉政/涉暴/涉黄等底线维度)。
|
||||
; 单次 content 上限 600 字符,超长文本由代码分段审核(段间带重叠,避免跨段漏判)。
|
||||
; ⚠️ 不要再去开通「内容安全 1.0」:它要求企业实名认证,个人账号无法开通,
|
||||
; 调用会永久返回 code=596 "You have not opened Yundun Content Moderation Service"。
|
||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位;
|
||||
; 首次出现账号级失败(未开通 / 未授权 / 密钥错误)还会额外打印一条一次性告警。
|
||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
@@ -52,6 +71,13 @@ ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值
|
||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
||||
default_moderation_strictness = ; 站点默认「内容审核严格度」:lax(宽松)/ standard(标准)/ strict(严格)。
|
||||
; 仅对未单独设置档位的账号生效(账号在设置页选择后以账号设置为准)。
|
||||
; 留空回退 strict(最严档):保证升级不会放宽任何拦截。
|
||||
; 严格档是三个档位中最严的(拦截面不小于升级前),因此部署后默认拦截面可能略有扩大;
|
||||
; 若希望默认更少误伤,改为 standard 即可,无需改代码。
|
||||
; 三档的拦截范围见 internal/security/censor/policy.go,
|
||||
; 适用于提问、投票、私聊三类内容。
|
||||
|
||||
[server]
|
||||
port = 80
|
||||
|
||||
@@ -5,7 +5,9 @@ go 1.25.0
|
||||
replace golang.org/x/image => golang.org/x/image v0.13.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
|
||||
github.com/alibabacloud-go/tea v1.5.2
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.1
|
||||
@@ -48,7 +50,7 @@ require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.56.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/ini.v1 v1.66.2
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/datatypes v1.0.7
|
||||
gorm.io/driver/mysql v1.3.6
|
||||
gorm.io/driver/postgres v1.4.5
|
||||
@@ -58,6 +60,10 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alecthomas/participle/v2 v2.0.0 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
|
||||
@@ -81,6 +87,7 @@ require (
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/log v0.2.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.3.2 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -90,8 +97,8 @@ require (
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
@@ -102,7 +109,6 @@ require (
|
||||
github.com/jackc/pgx/v4 v4.17.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
@@ -114,7 +120,6 @@ require (
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/onsi/gomega v1.29.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.2 // indirect
|
||||
github.com/projectdiscovery/retryabledns v1.0.115 // indirect
|
||||
@@ -124,11 +129,11 @@ require (
|
||||
github.com/richardlehane/msoleps v1.0.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/smartystreets/assertions v1.0.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.6.4 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.1.16 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelutil v0.1.16 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2 // indirect
|
||||
|
||||
@@ -50,10 +50,54 @@ github.com/alecthomas/participle/v2 v2.0.0/go.mod h1:rAKZdJldHu8084ojcWevWAL8KmE
|
||||
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
|
||||
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26 h1:WvhbWN/8MqYY20LVi6vqkZ1ZHUibvnUoV9IF4jKA/Kw=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.62.26/go.mod h1:Api2AkmMgGaSUAhmk76oaFObkoeCPc/bKAqcyplPODs=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
|
||||
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
|
||||
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible h1:cD1bK/FmYTpL+r5i9lQ9EU6ScAjA173EVsii7gAc6SQ=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
@@ -125,6 +169,8 @@ github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgk
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
@@ -210,7 +256,6 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
@@ -281,10 +326,12 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 h1:lLT7ZLSzGLI08vc9cpd+tYmNWjdKDqyr/2L+f6U12Fk=
|
||||
@@ -356,12 +403,7 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@@ -410,6 +452,7 @@ github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDETo
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
@@ -419,6 +462,7 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
@@ -433,8 +477,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
|
||||
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -494,8 +536,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
@@ -534,10 +576,9 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
|
||||
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs=
|
||||
github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM=
|
||||
github.com/uptrace/opentelemetry-go-extra/otelgorm v0.1.16 h1:WBsTsLkHGaURNRsEGpJY3FxfNOId6u9fl7H/ST6UwBc=
|
||||
@@ -572,6 +613,7 @@ github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -613,8 +655,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
@@ -632,7 +672,10 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
@@ -642,6 +685,13 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -678,6 +728,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -709,6 +762,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
@@ -718,6 +772,15 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -738,6 +801,9 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -772,6 +838,7 @@ golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -792,12 +859,29 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -808,7 +892,11 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -856,6 +944,7 @@ golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjs
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -865,6 +954,8 @@ golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -969,6 +1060,7 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gG
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
@@ -976,8 +1068,9 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.66.2 h1:XfR1dOYubytKy4Shzc2LHrrGhU0lDCfDGG1yLPmpgsI=
|
||||
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -985,7 +1078,6 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -42,6 +42,11 @@ func Init() error {
|
||||
return errors.Wrap(err, "map 'security'")
|
||||
}
|
||||
|
||||
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
|
||||
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := File.Section("server").MapTo(&Server); err != nil {
|
||||
return errors.Wrap(err, "map 'server'")
|
||||
}
|
||||
@@ -103,3 +108,40 @@ func Init() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
|
||||
//
|
||||
// 单独抽成纯函数(只依赖入参、不读全局)是为了能直接被单测覆盖,也让 Init 保持轻薄。
|
||||
//
|
||||
// 规则:
|
||||
// - 审核整体关闭(enabled=false)→ 不校验,此时不需要任何凭据。
|
||||
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都没配凭据时
|
||||
// 维持既有行为(运行期 fail-closed),不阻断启动 —— 否则 app.sample.ini 原样拷贝
|
||||
// (enable_text_censor = true 且凭据留空)将无法启动,破坏开箱流程。
|
||||
// - 显式选 qiniu / aliyun → 必须配齐该通道的凭据,否则启动即失败。
|
||||
// 理由:缺凭据时运行期审核必然失败,而审核失败是 fail-closed(拒绝提交),
|
||||
// 会让发布/私信整站不可用,且现象隐蔽(只在日志可见)——不如启动就报错。
|
||||
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被悄悄当成 auto,
|
||||
// 运维会以为「已经只用七牛了」,实际仍在走双通道。
|
||||
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "", CensorSourceAuto:
|
||||
return nil
|
||||
case CensorSourceQiniu:
|
||||
if qiniuKey == "" || qiniuSecret == "" {
|
||||
return errors.New("security.text_censor_source = qiniu requires app.qiniu_access_key and app.qiniu_access_secret")
|
||||
}
|
||||
return nil
|
||||
case CensorSourceAliyun:
|
||||
if aliyunKey == "" || aliyunSecret == "" {
|
||||
return errors.New("security.text_censor_source = aliyun requires app.aliyun_access_key and app.aliyun_access_key_secret")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.Errorf("security.text_censor_source must be one of qiniu / aliyun / auto, got %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
+35
-6
@@ -12,6 +12,19 @@ var (
|
||||
BuildCommit = "dev"
|
||||
)
|
||||
|
||||
// 文本审核服务通道取值([security] text_censor_source)。
|
||||
//
|
||||
// 定义在 conf 侧是为了让「配置校验」(conf.Init)与「通道装配」
|
||||
// (internal/security/censor)引用同一份字面量,避免两边各写一份字符串而漂移。
|
||||
const (
|
||||
// CensorSourceAuto 七牛优先、阿里云兜底(历史行为,留空同义)。
|
||||
CensorSourceAuto = "auto"
|
||||
// CensorSourceQiniu 只用七牛。
|
||||
CensorSourceQiniu = "qiniu"
|
||||
// CensorSourceAliyun 只用阿里云。
|
||||
CensorSourceAliyun = "aliyun"
|
||||
)
|
||||
|
||||
var (
|
||||
App struct {
|
||||
Production bool `ini:"production"`
|
||||
@@ -51,12 +64,28 @@ var (
|
||||
// (域名/题目不存在等均走 302 业务跳转,不产生计数)一律放行,仅命中后台扫描特征
|
||||
// 路径(KindSensitive)才参与统计封禁。避免共享出口(CDN 边缘 / CGNAT NAT444)下
|
||||
// 正常用户被 404 探测流量连坐误伤。如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
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"` // 封禁时长(分钟)
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
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 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 string `ini:"text_censor_source"`
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
|
||||
Server struct {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestValidateTextCensorSource 覆盖 [security] text_censor_source 与 [app] 凭据的搭配校验。
|
||||
//
|
||||
// 关注两类真实故障:
|
||||
// 1. 显式选定通道却缺凭据 —— 运行期审核必然失败,而审核失败是 fail-closed,
|
||||
// 会让整站发布/私信不可用且现象隐蔽,必须在启动阶段拦住;
|
||||
// 2. 取值拼写错误 —— 若被静默回退成 auto,运维会误以为「已经只用七牛了」。
|
||||
func TestValidateTextCensorSource(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
source string
|
||||
qk, qs string
|
||||
ak, as string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "审核关闭:不校验任何通道配置", enabled: false, source: "aliyun"},
|
||||
{name: "审核关闭:非法取值也不报错", enabled: false, source: "qinui"},
|
||||
|
||||
{name: "留空:通过(历史默认行为)", enabled: true, source: ""},
|
||||
{name: "auto:通过", enabled: true, source: "auto"},
|
||||
{name: "auto:无任何凭据也通过(不阻断开箱启动)", enabled: true, source: "auto"},
|
||||
|
||||
{name: "qiniu:凭据齐备通过", enabled: true, source: "qiniu", qk: "k", qs: "s"},
|
||||
{name: "qiniu:缺 access key 报错", enabled: true, source: "qiniu", qs: "s", wantErr: true},
|
||||
{name: "qiniu:缺 access secret 报错", enabled: true, source: "qiniu", qk: "k", wantErr: true},
|
||||
|
||||
{name: "aliyun:凭据齐备通过", enabled: true, source: "aliyun", ak: "k", as: "s"},
|
||||
{name: "aliyun:缺凭据报错", enabled: true, source: "aliyun", wantErr: true},
|
||||
|
||||
{name: "大小写与空白不敏感", enabled: true, source: " QINIU ", qk: "k", qs: "s"},
|
||||
|
||||
{name: "非法取值:报错而非静默回退为 auto", enabled: true, source: "qinui", qk: "k", qs: "s", wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateTextCensorSource(tc.enabled, tc.source, tc.qk, tc.qs, tc.ak, tc.as)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -37,6 +37,9 @@ type UsersStore interface {
|
||||
// 提问/私聊/投票三个面板共用一份),必须分开写,避免单侧保存误清空另一侧。
|
||||
UpdateHarassmentType(ctx context.Context, id uint, typ HarassmentSettingType) error
|
||||
UpdateBlockWords(ctx context.Context, id uint, words string) error
|
||||
// UpdateModerationStrictness 更新内容审核严格度(lax / standard / strict,空值表示跟随站点默认)。
|
||||
// 只写 moderation_strictness 列,不影响防骚扰与屏蔽词。
|
||||
UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error
|
||||
Authenticate(ctx context.Context, email, password string) (*User, error)
|
||||
ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error
|
||||
UpdatePassword(ctx context.Context, id uint, newPassword string) error
|
||||
@@ -104,9 +107,15 @@ type User struct {
|
||||
Notify NotifyType `json:"notify"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
|
||||
BlockWords string `json:"-"`
|
||||
Tags string `json:"tags" gorm:"type:text"`
|
||||
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
|
||||
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
|
||||
// ModerationStrictness 内容审核严格度:lax / standard / strict。
|
||||
// 空值表示「跟随站点默认」([security] default_moderation_strictness),
|
||||
// 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档),
|
||||
// 保证引入该字段这一动作不会放宽任何账号的拦截面。
|
||||
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
|
||||
ModerationStrictness string `json:"-" gorm:"column:moderation_strictness;type:varchar(16);default:''"`
|
||||
Tags string `json:"tags" gorm:"type:text"`
|
||||
ShowInHome bool `json:"show_in_home" gorm:"default:false;index"`
|
||||
UseCustomQRColor bool `json:"use_custom_qr_color" gorm:"default:false"`
|
||||
|
||||
PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"`
|
||||
AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"`
|
||||
@@ -390,6 +399,23 @@ func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateModerationStrictness 仅更新内容审核严格度。
|
||||
// 只写 moderation_strictness 列,不动 harassment_setting 与 block_words,
|
||||
// 避免三个独立设置面板互相覆盖。
|
||||
//
|
||||
// 取值合法性由上层(route/user)在写入前校验。此处不再二次校验是有意为之:
|
||||
// 本包不能 import internal/security/censor(后者要读 censor_logs,反向依赖本包,
|
||||
// 会形成 import 环)。即便绕过上层写入脏值也不会有安全问题——解析层的
|
||||
// censor.ParseStrictness 对未知取值回退「严格」档,方向是保守的(见 policy.go)。
|
||||
func (db *users) UpdateModerationStrictness(ctx context.Context, id uint, strictness string) error {
|
||||
if err := db.WithContext(ctx).Model(&User{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"ModerationStrictness": strictness,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(err, "update user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *users) Authenticate(ctx context.Context, email, password string) (*User, error) {
|
||||
u, err := db.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,6 +37,13 @@ type UpdateBlockWords struct {
|
||||
BlockWords string `label:"屏蔽词"`
|
||||
}
|
||||
|
||||
// UpdateModerationStrictness 内容审核严格度(lax / standard / strict)。
|
||||
// 与屏蔽词一样是独立的设置入口:两者分处不同的表单,必须分开写,
|
||||
// 否则单侧保存会把另一侧的值清空(见 route/user/harassment.go 顶部注释)。
|
||||
type UpdateModerationStrictness struct {
|
||||
Strictness string `form:"strictness"`
|
||||
}
|
||||
|
||||
type UpdateTags struct {
|
||||
Tags string `form:"tags"`
|
||||
ShowInHome string `form:"show_in_home"`
|
||||
|
||||
@@ -215,6 +215,22 @@ var enDict = map[string]string{
|
||||
"admin.globalBlockWords": "Global blocked words",
|
||||
"admin.globalBlockWordsHint": "Blocked words apply to questions, chat messages sent to you, and vote submissions. This single setting is shared across the question / chat / vote panels and takes effect immediately from any of them.",
|
||||
"admin.updateBlockWords": "Save blocked words",
|
||||
|
||||
// Moderation strictness: shared by questions / polls / chat. Decision rules live in internal/security/censor/policy.go.
|
||||
"admin.moderationStrictness": "Moderation strictness",
|
||||
"admin.moderationStrictnessHint": "Applies to questions, polls and chat messages alike. This controls whether content is blocked once the moderation service flags it - it does not affect blocked words, which are always rejected outright.",
|
||||
"admin.moderationStrictnessBaseline": "Baseline (always enforced, cannot be disabled): political, terrorism and pornographic content, plus hits on the moderation service's custom wordlists.",
|
||||
"admin.moderationStrictnessColumnLevel": "Level",
|
||||
"admin.moderationStrictnessColumnScope": "Blocked when",
|
||||
"admin.moderationStrictnessOption.lax": "Lenient",
|
||||
"admin.moderationStrictnessOption.standard": "Standard",
|
||||
"admin.moderationStrictnessOption.strict": "Strict",
|
||||
"admin.moderationStrictnessScope.lax": "Only baseline content is blocked; everything else is allowed (fewest false positives).",
|
||||
"admin.moderationStrictnessScope.standard": "Baseline content, plus abuse and contraband with a confidence of 90% or higher.",
|
||||
"admin.moderationStrictnessScope.strict": "Baseline content, plus abuse, contraband, spam, ads, flooding, meaningless and harmful content at any confidence; also trusts the moderation service's overall block recommendation. The strictest tier — its block scope is never smaller than before this setting existed.",
|
||||
"admin.moderationStrictnessUnsetHint": "Falls back to the site default when not set.",
|
||||
"admin.updateModerationStrictness": "Save moderation strictness",
|
||||
|
||||
"admin.allowChat": "Allow registered users to start a chat with me",
|
||||
"admin.dailyLimitHint": "0 means unlimited",
|
||||
"admin.updateChat": "Update chat settings",
|
||||
@@ -226,7 +242,7 @@ var enDict = map[string]string{
|
||||
"admin.askPasswordPlaceholder": "Enter ask password",
|
||||
"admin.account": "Account settings",
|
||||
"admin.exportHint": "You can export all your personal data from %s, including your basic information, received questions and answers.",
|
||||
"admin.exportJSON": "Export all data (JSON)",
|
||||
"admin.exportJSON": "Export JSON",
|
||||
"admin.exportJSONHint": "Includes questions, answers, follow-ups and chats for full backup or migration.",
|
||||
"admin.deactivate": "Deactivate my account",
|
||||
"admin.deactivateHint": "You can deactivate your account at any time. After deactivation, your account can no longer log in, your question box page and questions will be inaccessible, and others won't be able to send you new questions.<b>This action cannot be undone. Please be careful!</b>",
|
||||
@@ -707,6 +723,9 @@ var enDict = map[string]string{
|
||||
"admin.export.errUser": "Export failed: could not get user information",
|
||||
"admin.export.errQuestion": "Export failed: could not get question information",
|
||||
|
||||
"admin.flash.moderationStrictnessUpdated": "Moderation strictness updated",
|
||||
"admin.flash.moderationStrictnessInvalid": "Invalid moderation strictness value, nothing was saved",
|
||||
|
||||
// Vote management: owner visible (P1)
|
||||
"vote.err.invalidID": "Invalid vote ID",
|
||||
"vote.err.notExist": "The vote does not exist",
|
||||
|
||||
@@ -215,6 +215,22 @@ var jaDict = map[string]string{
|
||||
"admin.globalBlockWords": "共通ブロックワード",
|
||||
"admin.globalBlockWordsHint": "ブロックワードは質問・チャット(相手からあなたへのメッセージ)・投票の投稿内容に適用されます。この設定は質問/チャット/投票の3つのタブで共有され、どこで変更してもすぐに反映されます。",
|
||||
"admin.updateBlockWords": "ブロックワードを保存",
|
||||
|
||||
// 審核の厳格度:質問 / 投票 / チャットで共通。判定規則は internal/security/censor/policy.go。
|
||||
"admin.moderationStrictness": "審核の厳格度",
|
||||
"admin.moderationStrictnessHint": "質問・投票・チャットの3種類すべてに適用されます。ここで調整するのは「審核が検知した後にブロックするかどうか」であり、ブロックワードには影響しません(ブロックワードは常に拒否されます)。",
|
||||
"admin.moderationStrictnessBaseline": "下限(全段階で強制適用・無効化不可):政治・テロ・ポルノ、および審核サービスのカスタム辞書へのヒット。",
|
||||
"admin.moderationStrictnessColumnLevel": "段階",
|
||||
"admin.moderationStrictnessColumnScope": "ブロック対象",
|
||||
"admin.moderationStrictnessOption.lax": "緩和",
|
||||
"admin.moderationStrictnessOption.standard": "標準",
|
||||
"admin.moderationStrictnessOption.strict": "厳格",
|
||||
"admin.moderationStrictnessScope.lax": "下限の内容のみブロックし、それ以外はすべて許可します(誤検知が最も少ない)。",
|
||||
"admin.moderationStrictnessScope.standard": "下限の内容に加え、信頼度 90% 以上の辱罵・違禁コンテンツ。",
|
||||
"admin.moderationStrictnessScope.strict": "下限の内容に加え、辱罵・違禁・スパム・広告・連投・無意味・有害コンテンツ(信頼度不問)。さらに審核サービス全体のブロック推奨も採用します。最も厳しい段階で、ブロック範囲は本設定導入前を下回りません。",
|
||||
"admin.moderationStrictnessUnsetHint": "未設定の場合はサイトの既定段階に従います。",
|
||||
"admin.updateModerationStrictness": "審核の厳格度を保存",
|
||||
|
||||
"admin.allowChat": "登録ユーザーからのチャットを許可",
|
||||
"admin.dailyLimitHint": "0 は無制限",
|
||||
"admin.updateChat": "チャット設定を更新",
|
||||
@@ -226,7 +242,7 @@ var jaDict = map[string]string{
|
||||
"admin.askPasswordPlaceholder": "質問パスワードを入力",
|
||||
"admin.account": "アカウント設定",
|
||||
"admin.exportHint": "%s のすべての個人データ(基本情報、受け取った質問と回答を含む)をエクスポートできます。",
|
||||
"admin.exportJSON": "全データをエクスポート(JSON)",
|
||||
"admin.exportJSON": "全JSONをエクスポート",
|
||||
"admin.exportJSONHint": "質問・回答・追加質問・チャットを含む完全バックアップ。",
|
||||
"admin.deactivate": "アカウントを停止",
|
||||
"admin.deactivateHint": "いつでもアカウントを停止できます。停止後はログインできなくなり、質問箱のページや質問にもアクセスできなくなります。新しい質問も受け付けられません。<b>この操作は取り消せません。ご注意ください!</b>",
|
||||
@@ -707,6 +723,9 @@ var jaDict = map[string]string{
|
||||
"admin.export.errUser": "エクスポート失敗:ユーザー情報の取得に失敗しました",
|
||||
"admin.export.errQuestion": "エクスポート失敗:質問情報の取得に失敗しました",
|
||||
|
||||
"admin.flash.moderationStrictnessUpdated": "審核の厳格度を更新しました",
|
||||
"admin.flash.moderationStrictnessInvalid": "審核の厳格度の値が不正なため、保存しませんでした",
|
||||
|
||||
// 投票管理:所有者に表示(P1)
|
||||
"vote.err.invalidID": "無効な投票 ID",
|
||||
"vote.err.notExist": "投票が存在しません",
|
||||
|
||||
@@ -215,6 +215,22 @@ var zhCNDict = map[string]string{
|
||||
"admin.globalBlockWords": "全局屏蔽词",
|
||||
"admin.globalBlockWordsHint": "屏蔽词对提问、私聊(对方发给你的消息)与投票提交内容生效;三个设置页同步显示同一份设置,任意一处修改即时生效。",
|
||||
"admin.updateBlockWords": "保存屏蔽词",
|
||||
|
||||
// 审核严格度:提问 / 投票 / 私聊共用一份,判定规则见 internal/security/censor/policy.go
|
||||
"admin.moderationStrictness": "审核严格度",
|
||||
"admin.moderationStrictnessHint": "对提问、投票、私聊三类内容同时生效。这里调节的是「审核命中之后是否拦截」,不影响屏蔽词——屏蔽词始终直接拒绝。",
|
||||
"admin.moderationStrictnessBaseline": "底线拦截(所有档位强制生效,不可关闭):涉政、暴恐、色情,以及审核服务自定义词库的命中。",
|
||||
"admin.moderationStrictnessColumnLevel": "档位",
|
||||
"admin.moderationStrictnessColumnScope": "拦截范围",
|
||||
"admin.moderationStrictnessOption.lax": "畅所欲言",
|
||||
"admin.moderationStrictnessOption.standard": "正常水平",
|
||||
"admin.moderationStrictnessOption.strict": "不听坏话",
|
||||
"admin.moderationStrictnessScope.lax": "仅拦截底线内容,其余一律放行(误伤最少)。",
|
||||
"admin.moderationStrictnessScope.standard": "底线内容,以及置信度不低于 90% 的辱骂、违禁内容。",
|
||||
"admin.moderationStrictnessScope.strict": "底线内容,以及辱骂、违禁、垃圾信息、广告、灌水、无意义、不良场景内容(不限置信度);同时采信审核服务给出的整体拦截建议。最严格档,拦截面不小于升级前。",
|
||||
"admin.moderationStrictnessUnsetHint": "未设置时跟随站点默认档位。",
|
||||
"admin.updateModerationStrictness": "保存审核严格度",
|
||||
|
||||
"admin.allowChat": "允许注册用户向我发起私聊",
|
||||
"admin.dailyLimitHint": "0 表示不限制",
|
||||
"admin.updateChat": "更新私聊设置",
|
||||
@@ -226,7 +242,7 @@ var zhCNDict = map[string]string{
|
||||
"admin.askPasswordPlaceholder": "输入提问密码",
|
||||
"admin.account": "账号设置",
|
||||
"admin.exportHint": "您可以导出您在 %s 中的所有个人数据,包括你的基本信息、收到的问题以及回答。",
|
||||
"admin.exportJSON": "导出全部数据(JSON)",
|
||||
"admin.exportJSON": "导出JSON",
|
||||
"admin.exportJSONHint": "包含提问、回答、追问与私聊记录,可完整备份或迁移。",
|
||||
"admin.deactivate": "停用我的账号",
|
||||
"admin.deactivateHint": "您随时可以选择停用您的账号。停用后,您的账号将无法登录,您的提问箱页面以及提问将无法访问,其他人也无法再给您发送新的提问。<b>该操作无法撤销!请谨慎操作!</b>",
|
||||
@@ -707,6 +723,9 @@ var zhCNDict = map[string]string{
|
||||
"admin.export.errUser": "导出失败:获取用户信息失败",
|
||||
"admin.export.errQuestion": "导出失败:获取问题信息失败",
|
||||
|
||||
"admin.flash.moderationStrictnessUpdated": "审核严格度已更新",
|
||||
"admin.flash.moderationStrictnessInvalid": "审核严格度取值无效,未保存",
|
||||
|
||||
// 投票管理:箱主可见(P1)
|
||||
"vote.err.invalidID": "无效的投票 ID",
|
||||
"vote.err.notExist": "投票不存在",
|
||||
|
||||
@@ -215,6 +215,22 @@ var zhTWDict = map[string]string{
|
||||
"admin.globalBlockWords": "全局屏蔽詞",
|
||||
"admin.globalBlockWordsHint": "屏蔽詞對提問、私聊(對方傳送給你的訊息)與投票提交內容生效;三個設定頁同步顯示同一份設定,任意一處修改即時生效。",
|
||||
"admin.updateBlockWords": "儲存屏蔽詞",
|
||||
|
||||
// 審核嚴格度:提問 / 投票 / 私聊共用一份,判定規則見 internal/security/censor/policy.go
|
||||
"admin.moderationStrictness": "審核嚴格度",
|
||||
"admin.moderationStrictnessHint": "對提問、投票、私聊三類內容同時生效。這裡調節的是「審核命中之後是否攔截」,不影響屏蔽詞——屏蔽詞一律直接拒絕。",
|
||||
"admin.moderationStrictnessBaseline": "底線攔截(所有檔位強制生效,無法關閉):涉政、暴恐、色情,以及審核服務自訂詞庫的命中。",
|
||||
"admin.moderationStrictnessColumnLevel": "檔位",
|
||||
"admin.moderationStrictnessColumnScope": "攔截範圍",
|
||||
"admin.moderationStrictnessOption.lax": "暢所欲言",
|
||||
"admin.moderationStrictnessOption.standard": "正常水平",
|
||||
"admin.moderationStrictnessOption.strict": "不聽壞話",
|
||||
"admin.moderationStrictnessScope.lax": "僅攔截底線內容,其餘一律放行(誤傷最少)。",
|
||||
"admin.moderationStrictnessScope.standard": "底線內容,以及信心度不低於 90% 的辱罵、違禁內容。",
|
||||
"admin.moderationStrictnessScope.strict": "底線內容,以及辱罵、違禁、垃圾訊息、廣告、洗版、無意義、不良場景內容(不限信心度);同時採信審核服務給出的整體攔截建議。最嚴格檔,攔截面不小於升級前。",
|
||||
"admin.moderationStrictnessUnsetHint": "未設定時跟隨站點預設檔位。",
|
||||
"admin.updateModerationStrictness": "儲存審核嚴格度",
|
||||
|
||||
"admin.allowChat": "允許註冊使用者向我發起私聊",
|
||||
"admin.dailyLimitHint": "0 表示不限制",
|
||||
"admin.updateChat": "更新私聊設定",
|
||||
@@ -226,7 +242,7 @@ var zhTWDict = map[string]string{
|
||||
"admin.askPasswordPlaceholder": "輸入提問密碼",
|
||||
"admin.account": "帳號設定",
|
||||
"admin.exportHint": "您可以匯出您在 %s 中的所有個人資料,包括你的基本資訊、收到的問題以及回答。",
|
||||
"admin.exportJSON": "導出全部資料(JSON)",
|
||||
"admin.exportJSON": "導出JSON",
|
||||
"admin.exportJSONHint": "包含提問、回答、追問與私聊記錄,可完整備份或遷移。",
|
||||
"admin.deactivate": "停用我的帳號",
|
||||
"admin.deactivateHint": "您隨時可以選擇停用您的帳號。停用後,您的帳號將無法登入,您的提問箱頁面以及提問將無法訪問,其他人也無法再給您發送新的提問。<b>該操作無法撤銷!請謹慎操作!</b>",
|
||||
@@ -707,6 +723,9 @@ var zhTWDict = map[string]string{
|
||||
"admin.export.errUser": "匯出失敗:獲取使用者資訊失敗",
|
||||
"admin.export.errQuestion": "匯出失敗:獲取問題資訊失敗",
|
||||
|
||||
"admin.flash.moderationStrictnessUpdated": "審核嚴格度已更新",
|
||||
"admin.flash.moderationStrictnessInvalid": "審核嚴格度取值無效,未儲存",
|
||||
|
||||
// 投票管理:箱主可見(P1)
|
||||
"vote.err.invalidID": "無效的投票 ID",
|
||||
"vote.err.notExist": "投票不存在",
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/storage"
|
||||
)
|
||||
@@ -27,10 +27,18 @@ const (
|
||||
MaxMessageImageSize = 5 * 1024 * 1024 // 5MB
|
||||
)
|
||||
|
||||
// ConversationPage 私聊会话页(GET 入口)。审核拦截后的就地重渲染由 renderConversation 统一处理。
|
||||
func ConversationPage(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
token := ctx.Param("token")
|
||||
renderConversation(ctx, ctx.Param("domain"), ctx.Param("token"), "", nil)
|
||||
}
|
||||
|
||||
// renderConversation 渲染私聊会话页,并可选地回填审核拦截提示与已输入内容。
|
||||
//
|
||||
// prefillContent 非空时回填到消息输入框:审核拦截(以及其它服务端校验失败)后保留用户已输入内容,
|
||||
// 避免 Redirect 重渲染导致输入丢失。modErr 非空时作为错误提示展示(与提问链路 ctx.SetError
|
||||
// 一致的就地重渲染,而非 flash + Redirect),提示通过 templates/base/alert 渲染。
|
||||
// 函数内部在所有出口都会发起 Redirect 或 Success 渲染,调用方在调用后应直接 return。
|
||||
func renderConversation(ctx context.Context, domain, token, prefillContent string, modErr error) {
|
||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
@@ -119,8 +127,19 @@ func ConversationPage(ctx context.Context) {
|
||||
// 单条消息字数上限,供发送 / 回复 textarea 的 maxlength 与后端校验同源
|
||||
// (conf.Limits.ChatMessageLen,[limits] 配置段)
|
||||
ctx.Data["ChatMessageLimit"] = conf.Limits.ChatMessageLen
|
||||
ctx.Data["CanSend"] = !conv.IsClosed && !conv.IsDeletedByOwner &&
|
||||
canSend := !conv.IsClosed && !conv.IsDeletedByOwner &&
|
||||
((isOwner && conv.MessageCount < conv.MaxMessages) || (isAsker && conv.MessageCount < conv.MaxMessages && !isBlocked))
|
||||
ctx.Data["CanSend"] = canSend
|
||||
|
||||
// 回填审核拦截时已输入内容(仅当对话仍可发送,避免关闭/删除场景下回填到隐藏输入框)。
|
||||
if canSend {
|
||||
ctx.Data["Content"] = prefillContent
|
||||
}
|
||||
// 审核拦截等错误提示:就地通过 base/alert 渲染,不再走 flash + Redirect(否则内容丢失且无提示)。
|
||||
if modErr != nil {
|
||||
ctx.Data["HasError"] = true
|
||||
ctx.Data["Error"] = modErr.Error()
|
||||
}
|
||||
ctx.Success("chat/conversation")
|
||||
}
|
||||
|
||||
@@ -254,18 +273,12 @@ func SendMessage(ctx context.Context, cache cache.Cache) {
|
||||
}
|
||||
|
||||
// 内容安全检测(仅文本)
|
||||
// 统一守卫:与提问 / 投票共用同一份档位判定,箱主的「审核严格度」决定放行与否。
|
||||
if content != "" {
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发送而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetErrorFlash(censorResponse.ErrorMessage())
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token))
|
||||
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), content); err != nil {
|
||||
// 审核拦截:就地重渲染会话页,回填已输入内容并展示拦截原因。
|
||||
// 不复用 SetErrorFlash + Redirect:那会导致输入框内容在刷新后丢失(用户已确认的无提示+内容消失问题)。
|
||||
renderConversation(ctx, domain, token, content, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +365,9 @@ func New() *flamego.Flame {
|
||||
// ?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 风险(第三方图片/链接可触发退出登录)
|
||||
f.Post("/logout", auth.Logout)
|
||||
|
||||
+533
-112
@@ -4,14 +4,71 @@
|
||||
|
||||
package censor
|
||||
|
||||
// 阿里云文本审核通道 —— 内容安全 2.0「文本审核增强版」。
|
||||
//
|
||||
// 迁移记录(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 只要求实名认证
|
||||
// (个人可开通),且标签体系对底线维度(涉政/暴恐/色情/自定义词库)覆盖完整。
|
||||
//
|
||||
// 官方文档:
|
||||
// - 文本审核增强版通用服务 API:https://help.aliyun.com/practice_detail/434034
|
||||
// - 标签处理建议参考: https://help.aliyun.com/practice_detail/465315
|
||||
//
|
||||
// ⚠️ 不要把 SDK 里 `TextModerationResponseBodyData.Labels` 的内联注释当作权威标签表:
|
||||
// 那份列表是旧的且不完整(没有 political 相关标签),以官方《标签处理建议参考》为准。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/aliyun/alibaba-cloud-sdk-go/services/green"
|
||||
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
|
||||
green20220302 "github.com/alibabacloud-go/green-20220302/v3/client"
|
||||
"github.com/alibabacloud-go/tea/dara"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// aliyunRegionID 内容安全 2.0 的接入地域,SDK 据此解析出
|
||||
// green-cip.cn-shanghai.aliyuncs.com(见绿网 SDK 的 EndpointMap)。
|
||||
aliyunRegionID = "cn-shanghai"
|
||||
|
||||
// aliyunTextService 文本审核增强版的服务场景。
|
||||
//
|
||||
// 选 comment_detection(公聊评论内容检测):官方定位「评论/弹幕/公聊/转发」,
|
||||
// 明确覆盖涉黄/涉政/涉暴等底线违规与广告导流,是本站(公开问答为主、私聊为辅)
|
||||
// 最通用的单一场景。本通道被提问/回答、投票、私聊三条链路共用,接口层不带场景信息;
|
||||
// 若要按场景细分(例如私聊改用 chat_detection),需给审核入口补 scene 参数。
|
||||
aliyunTextService = "comment_detection"
|
||||
|
||||
// aliyunContentRuneLimit 2.0 通用服务单次 content 的硬上限。
|
||||
// 官方文档:content 限定在 600 字符以内。这里按 550 切分以留出安全余量。
|
||||
aliyunContentRuneLimit = 550
|
||||
|
||||
// aliyunChunkOverlapRunes 相邻分片的重叠字符数。
|
||||
// 切分点可能正好落在一个风险词中间,两段各自都看不出问题;重叠可消除该盲区。
|
||||
// 代价是少量重复计费(文本审核标准 7.5 元/万次,可忽略)。
|
||||
aliyunChunkOverlapRunes = 50
|
||||
|
||||
aliyunReadTimeoutMs = 10000
|
||||
aliyunConnectTimeoutMs = 5000
|
||||
|
||||
// aliyunMaxRawLogBytes 错误信息里回显的原始响应长度上限(按字符计),
|
||||
// 避免异常响应体过大时把日志刷屏。
|
||||
aliyunMaxRawLogBytes = 512
|
||||
|
||||
// aliyunTextModerationAPI 归一化响应的格式标记。
|
||||
//
|
||||
// 解析器据此拒绝「非本格式」的载荷(例如迁移前内容安全 1.0 的响应),
|
||||
// 让 censor.go 的缓存回放能安全地退回一次实时调用,
|
||||
// 而不是把无法识别的历史缓存误判成「通过」。
|
||||
aliyunTextModerationAPI = "TextModeration"
|
||||
)
|
||||
|
||||
type AliyunTextCensor struct {
|
||||
@@ -25,170 +82,534 @@ func NewAliyunTextCensor(accessKey, accessKeySecret string) *AliyunTextCensor {
|
||||
}
|
||||
}
|
||||
|
||||
// 包级复用的 green.Client:审核请求高频且密钥在启动后不变,
|
||||
// 包级复用的 client:审核请求高频且密钥在启动后不变,
|
||||
// 用 sync.Once 懒加载一次并设置超时,避免每次审核都重建连接与对象。
|
||||
var (
|
||||
aliyunGreenOnce sync.Once
|
||||
aliyunGreenClient *green.Client
|
||||
aliyunGreenClient *green20220302.Client
|
||||
aliyunGreenErr error
|
||||
|
||||
// warnAliyunAccountIssueDoOnce 保证「账号侧未就绪」的告警只打一次,避免按请求刷屏。
|
||||
warnAliyunAccountIssueDoOnce sync.Once
|
||||
)
|
||||
|
||||
func (c *AliyunTextCensor) getClient() (*green.Client, error) {
|
||||
// newAliyunClient 构造内容安全 2.0 客户端。
|
||||
//
|
||||
// 不设置 Endpoint 而设置 RegionId:SDK 内置 EndpointMap 会把 cn-shanghai
|
||||
// 解析成 green-cip.cn-shanghai.aliyuncs.com。超时单位是毫秒
|
||||
// (见 darabonba-openapi 的 client.ReadTimeout → 请求参数 readTimeout)。
|
||||
func newAliyunClient(accessKey, accessKeySecret string) (*green20220302.Client, error) {
|
||||
regionID := aliyunRegionID
|
||||
readTimeout := aliyunReadTimeoutMs
|
||||
connectTimeout := aliyunConnectTimeoutMs
|
||||
|
||||
client, err := green20220302.NewClient(&openapiutil.Config{
|
||||
AccessKeyId: &accessKey,
|
||||
AccessKeySecret: &accessKeySecret,
|
||||
RegionId: ®ionID,
|
||||
ReadTimeout: &readTimeout,
|
||||
ConnectTimeout: &connectTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "new content moderation client")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *AliyunTextCensor) getClient() (*green20220302.Client, error) {
|
||||
aliyunGreenOnce.Do(func() {
|
||||
aliyunGreenClient, aliyunGreenErr = green.NewClientWithAccessKey("cn-shanghai", c.accessKey, c.accessKeySecret)
|
||||
if aliyunGreenErr == nil {
|
||||
aliyunGreenClient.SetConnectTimeout(5 * time.Second)
|
||||
aliyunGreenClient.SetReadTimeout(10 * time.Second)
|
||||
}
|
||||
aliyunGreenClient, aliyunGreenErr = newAliyunClient(c.accessKey, c.accessKeySecret)
|
||||
})
|
||||
return aliyunGreenClient, aliyunGreenErr
|
||||
}
|
||||
|
||||
type AliyunTextCensorResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data []struct {
|
||||
Code int `json:"code"`
|
||||
Content string `json:"content"`
|
||||
FilteredContent string `json:"filteredContent"`
|
||||
Msg string `json:"msg"`
|
||||
Results []struct {
|
||||
Details []struct {
|
||||
Contexts []struct {
|
||||
Context string `json:"context"`
|
||||
Positions []struct {
|
||||
EndPos int `json:"endPos"`
|
||||
StartPos int `json:"startPos"`
|
||||
} `json:"positions"`
|
||||
} `json:"contexts"`
|
||||
Label string `json:"label"`
|
||||
} `json:"details"`
|
||||
Label string `json:"label"`
|
||||
Rate float64 `json:"rate"`
|
||||
Scene string `json:"scene"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
} `json:"results"`
|
||||
TaskId string `json:"taskId"`
|
||||
} `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
RequestId string `json:"requestId"`
|
||||
// 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 {
|
||||
return nil, errors.Wrap(err, "marshal service parameters")
|
||||
}
|
||||
return (&green20220302.TextModerationRequest{}).
|
||||
SetService(aliyunTextService).
|
||||
SetServiceParameters(string(serviceParameters)), nil
|
||||
}
|
||||
|
||||
func (r *AliyunTextCensorResponse) IsPass() bool {
|
||||
if len(r.Data) == 0 || len(r.Data[0].Results) == 0 {
|
||||
return false
|
||||
// splitAliyunText 把待审文本切成不超过 2.0 单次上限的分片(按 rune 切,避免切坏多字节字符)。
|
||||
//
|
||||
// 为什么必须切分:2.0 通用服务单次 content 硬上限 600 字符,而本站内容上限为
|
||||
// 提问 1000 / 回答·追问·私聊 2000 / 投票描述 5000 字,长文本必然超限。
|
||||
// 把超长文本直接发给接口会被拒,进而在 fail-closed 下变成「长内容一律发不出去」。
|
||||
//
|
||||
// 分片带 aliyunChunkOverlapRunes 的重叠,消除「风险词正好跨切分点」的漏判盲区。
|
||||
func splitAliyunText(text string) []string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= aliyunContentRuneLimit {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
// ⚠️ Right now, we allow `review` and `pass` to pass the censor.
|
||||
// 任一 scene 判定为 block 即不通过(此前只检查首个 result,存在漏审)。
|
||||
for _, result := range r.Data[0].Results {
|
||||
if result.Suggestion == "block" {
|
||||
return false
|
||||
step := aliyunContentRuneLimit - aliyunChunkOverlapRunes
|
||||
chunks := make([]string, 0, len(runes)/step+1)
|
||||
for start := 0; start < len(runes); start += step {
|
||||
end := start + aliyunContentRuneLimit
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunks = append(chunks, string(runes[start:end]))
|
||||
if end == len(runes) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
return chunks
|
||||
}
|
||||
|
||||
// Censor censors text with Aliyun API.
|
||||
// https://developer.qiniu.com/censor/7260/api-text-censor
|
||||
func (c *AliyunTextCensor) Censor(_ context.Context, text string) (*TextCensorResponse, error) {
|
||||
// aliyunTextModerationReason 是 2.0 响应中 Data.Reason 的结构(该字段本身是 JSON 字符串)。
|
||||
//
|
||||
// 只声明用到的字段:riskLevel 风险级别、riskTips 子标签、riskWords 命中词。
|
||||
type aliyunTextModerationReason struct {
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
RiskTips string `json:"riskTips"`
|
||||
RiskWords string `json:"riskWords"`
|
||||
}
|
||||
|
||||
// parseAliyunReason 解析 Data.Reason。
|
||||
//
|
||||
// 字段缺失或非法 JSON 时返回零值 —— 忽略解析错误是刻意的:Reason 是辅助字段,
|
||||
// 不应因为它的格式变化导致整条审核失败。风险级别拿不到时的保守处理见 aliyunRiskLevelScore。
|
||||
func parseAliyunReason(raw string) aliyunTextModerationReason {
|
||||
var reason aliyunTextModerationReason
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return reason
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &reason)
|
||||
return reason
|
||||
}
|
||||
|
||||
// aliyunRiskLevelScore 把 2.0 的 riskLevel 折算成 0~1 分,供 policy.go 的置信度门槛复用。
|
||||
//
|
||||
// ⚠️ 这是有损的近似:2.0 通用服务**不返回逐标签置信度**,只返回整条内容的 riskLevel,
|
||||
// 因此同一响应内所有标签共用这一个分值(1.0 是按维度给 rate,改造后不具备该信息了)。
|
||||
// 折算后 abuseScoreFloor=0.9 的实际语义变成「标准档只拦 riskLevel=high 的辱骂」,
|
||||
// 与改造前的设计意图(标准档只拦高置信度辱骂)一致。
|
||||
func aliyunRiskLevelScore(level string) float64 {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "high":
|
||||
return 1
|
||||
case "medium":
|
||||
return 0.6
|
||||
case "low":
|
||||
return 0.3
|
||||
case "none":
|
||||
return 0
|
||||
default:
|
||||
// 标签存在却拿不到风险级别:取 1(保守)。
|
||||
// 宁可让它够到辱骂门槛被判拦截,也不要因为字段缺失把命中标签静默降权后放行。
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// aliyunRiskLevelSuggestion 把 riskLevel 映射成审核服务自身的管控建议,
|
||||
// 供 policy.go 中「严格档采信 block 建议」的分支使用。
|
||||
func aliyunRiskLevelSuggestion(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "high":
|
||||
return SuggestionBlock
|
||||
case "medium":
|
||||
return SuggestionReview
|
||||
case "low":
|
||||
return SuggestionPass
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// aliyunRiskLevelRank riskLevel 的高低排序,用于多分片取最高。
|
||||
var aliyunRiskLevelRank = map[string]int{
|
||||
"none": 0,
|
||||
"low": 1,
|
||||
"medium": 2,
|
||||
"high": 3,
|
||||
}
|
||||
|
||||
// aliyunHighestRiskLevel 取多分片中最高的风险级别(high > medium > low > none)。
|
||||
// 任一分片命中高风险,整条内容即按高风险处置;未知级别按最高处理(方向保守)。
|
||||
func aliyunHighestRiskLevel(levels []string) string {
|
||||
best := ""
|
||||
bestRank := -1
|
||||
for _, level := range levels {
|
||||
normalized := strings.ToLower(strings.TrimSpace(level))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
rank, ok := aliyunRiskLevelRank[normalized]
|
||||
if !ok {
|
||||
normalized, rank = "high", aliyunRiskLevelRank["high"]
|
||||
}
|
||||
if rank > bestRank {
|
||||
best, bestRank = normalized, rank
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// aliyunTextModerationRaw 归一化后的可落库响应。
|
||||
//
|
||||
// 它是「在线调用」与「缓存回放」两条路径的唯一输入格式:
|
||||
// Censor 把(可能多分片的)2.0 响应合并成它并序列化,随后交给 AliyunTextCensorParser;
|
||||
// censor.go 命中落库缓存时,也把这段 JSON 交给同一个 parser。
|
||||
type aliyunTextModerationRaw struct {
|
||||
// API 格式标记,取值恒为 aliyunTextModerationAPI,供解析器甄别旧格式载荷。
|
||||
API string `json:"api"`
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
Code int32 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestIDs []string `json:"requestIds,omitempty"`
|
||||
// Labels 已过滤掉 nonLabel / normal,且已去重。
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
RiskLevel string `json:"riskLevel,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// aliyunEmptyCleanRaw 空内容对应的归一化响应(无标签 → 通过)。见 Censor 的空内容分支。
|
||||
var aliyunEmptyCleanRaw = []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK"}`)
|
||||
|
||||
// aliyunChunkResult 单个分片审核结果里我们关心的最小信息集。
|
||||
// 抽出这一层是为了让「多分片合并」这段逻辑可以脱离 SDK 与网络单独测试。
|
||||
type aliyunChunkResult struct {
|
||||
StatusCode int32
|
||||
Code int32
|
||||
Message string
|
||||
RequestID string
|
||||
Labels []string
|
||||
RiskLevel string
|
||||
Hint string
|
||||
}
|
||||
|
||||
// extractAliyunChunkResult 从 SDK 响应里提取分片结果。
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
result := aliyunChunkResult{
|
||||
StatusCode: dara.Int32Value(resp.StatusCode),
|
||||
Code: dara.Int32Value(resp.Body.Code),
|
||||
Message: dara.StringValue(resp.Body.Message),
|
||||
RequestID: dara.StringValue(resp.Body.RequestId),
|
||||
}
|
||||
|
||||
if result.Code != 0 && result.Code != 200 {
|
||||
return result, errors.Errorf("aliyun rejected the request: code=%d message=%q request_id=%s",
|
||||
result.Code, result.Message, result.RequestID)
|
||||
}
|
||||
if result.StatusCode != 0 && result.StatusCode/100 != 2 {
|
||||
return result, errors.Errorf("unexpected http status: status=%d message=%q request_id=%s",
|
||||
result.StatusCode, result.Message, result.RequestID)
|
||||
}
|
||||
|
||||
data := resp.Body.Data
|
||||
if data == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, label := range strings.Split(dara.StringValue(data.Labels), ",") {
|
||||
label = strings.TrimSpace(label)
|
||||
// nonLabel / normal 是「无风险」的占位标签,不进入明细。
|
||||
if label == "" || label == "nonLabel" || label == "normal" {
|
||||
continue
|
||||
}
|
||||
result.Labels = append(result.Labels, label)
|
||||
}
|
||||
|
||||
reason := parseAliyunReason(dara.StringValue(data.Reason))
|
||||
result.RiskLevel = reason.RiskLevel
|
||||
// Hint 优先取命中词(与 1.0 时代回显命中上下文的行为对齐),缺失时退回子标签。
|
||||
if reason.RiskWords != "" {
|
||||
result.Hint = reason.RiskWords
|
||||
} else {
|
||||
result.Hint = reason.RiskTips
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeAliyunChunkResults 把多个分片的结果合并成一份归一化响应。
|
||||
//
|
||||
// 合并口径:
|
||||
// - 标签取并集(任一分片命中即视为整条内容命中);
|
||||
// - 风险级别取最高(high > medium > low > none),未知级别按最高处理;
|
||||
// - Hint 取首个非空,与「首个命中维度决定提示文案」的既有语义保持一致。
|
||||
func mergeAliyunChunkResults(results []aliyunChunkResult) aliyunTextModerationRaw {
|
||||
merged := aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
StatusCode: 200,
|
||||
Code: 200,
|
||||
Message: "OK",
|
||||
}
|
||||
|
||||
levels := make([]string, 0, len(results))
|
||||
seen := make(map[string]struct{}, 4)
|
||||
|
||||
for _, result := range results {
|
||||
merged.RequestIDs = append(merged.RequestIDs, result.RequestID)
|
||||
if result.RiskLevel != "" {
|
||||
levels = append(levels, result.RiskLevel)
|
||||
}
|
||||
if merged.Hint == "" {
|
||||
merged.Hint = result.Hint
|
||||
}
|
||||
for _, label := range result.Labels {
|
||||
if _, ok := seen[label]; ok {
|
||||
continue
|
||||
}
|
||||
seen[label] = struct{}{}
|
||||
merged.Labels = append(merged.Labels, label)
|
||||
}
|
||||
}
|
||||
|
||||
merged.RiskLevel = aliyunHighestRiskLevel(levels)
|
||||
return merged
|
||||
}
|
||||
|
||||
// Censor censors text with Aliyun Content Moderation 2.0 (TextModeration).
|
||||
// 接口文档:https://help.aliyun.com/practice_detail/434034
|
||||
func (c *AliyunTextCensor) Censor(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
client, err := c.getClient()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "new client with access key")
|
||||
return nil, errors.Wrap(err, "new content moderation client")
|
||||
}
|
||||
|
||||
content, err := json.Marshal(
|
||||
map[string]interface{}{
|
||||
"scenes": []string{"antispam"},
|
||||
"tasks": []map[string]interface{}{
|
||||
{"content": text},
|
||||
},
|
||||
},
|
||||
)
|
||||
// 空内容(例如原文只是一条可信链接,已被 RemoveTrustedURL 整段移除)无可审核内容,
|
||||
// 直接按「未命中」处理:既省一次调用,也避免空 content 被判为参数错误后
|
||||
// 在 fail-closed 下把这类提交一并拒掉。
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return AliyunTextCensorParser(aliyunEmptyCleanRaw)
|
||||
}
|
||||
|
||||
chunks := splitAliyunText(text)
|
||||
results := make([]aliyunChunkResult, 0, len(chunks))
|
||||
for i, chunk := range chunks {
|
||||
req, err := newAliyunTextModerationRequest(chunk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.TextModeration(req)
|
||||
if err != nil {
|
||||
// 非 2xx / 签名错误 / RAM 权限不足 / 欠费停服 / 服务未开通:SDK 直接返回错误。
|
||||
warnAliyunAccountIssueOnce(ctx, err)
|
||||
return nil, errors.Wrapf(err, "text moderation (chunk %d/%d len=%d)",
|
||||
i+1, len(chunks), utf8.RuneCountInString(chunk))
|
||||
}
|
||||
|
||||
result, err := extractAliyunChunkResult(resp)
|
||||
if err != nil {
|
||||
warnAliyunAccountIssueOnce(ctx, err)
|
||||
return nil, errors.Wrapf(err, "text moderation (chunk %d/%d len=%d)",
|
||||
i+1, len(chunks), utf8.RuneCountInString(chunk))
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(mergeAliyunChunkResults(results))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "marshal content")
|
||||
return nil, errors.Wrap(err, "marshal merged response")
|
||||
}
|
||||
|
||||
req := green.CreateTextScanRequest()
|
||||
req.SetContent(content)
|
||||
resp, err := client.TextScan(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "text scan")
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, errors.New("response is not success")
|
||||
}
|
||||
|
||||
return AliyunTextCensorParser(resp.GetHttpContentBytes())
|
||||
return AliyunTextCensorParser(raw)
|
||||
}
|
||||
|
||||
func (*AliyunTextCensor) String() string {
|
||||
return "aliyun"
|
||||
}
|
||||
|
||||
// aliyunRawDigest 返回原始响应的可读摘要(超长截断),供错误诊断使用。
|
||||
func aliyunRawDigest(raw []byte) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) > aliyunMaxRawLogBytes {
|
||||
return string(runes[:aliyunMaxRawLogBytes]) + "...(truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// AliyunTextCensorParser 把归一化响应转成通用的 TextCensorResponse。
|
||||
//
|
||||
// 在线调用与缓存回放共用本函数(见 aliyunTextModerationRaw 的说明)。
|
||||
func AliyunTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
var responseJSON AliyunTextCensorResponse
|
||||
if err := json.Unmarshal(raw, &responseJSON); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal response")
|
||||
var parsed aliyunTextModerationRaw
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, errors.Wrapf(err, "unmarshal response (raw=%s)", aliyunRawDigest(raw))
|
||||
}
|
||||
|
||||
if len(responseJSON.Data) == 0 {
|
||||
return nil, errors.New("response data is empty")
|
||||
// 格式标记不符:典型是迁移前内容安全 1.0 落库的缓存(形如 {"code":200,"data":[...]})。
|
||||
// 这种载荷无法按 2.0 语义解释,必须报错,交由 censor.go 退回一次实时调用;
|
||||
// 若当成「无标签 → 通过」,历史缓存就会变成绕过审核的后门。
|
||||
if parsed.API != aliyunTextModerationAPI {
|
||||
return nil, errors.Errorf("unsupported aliyun response format (api=%q raw=%s)",
|
||||
parsed.API, aliyunRawDigest(raw))
|
||||
}
|
||||
|
||||
var hint string
|
||||
var label string
|
||||
var confidence float64
|
||||
for _, result := range responseJSON.Data[0].Results {
|
||||
if result.Label == "normal" {
|
||||
// 2.0 不返回逐标签置信度,只有整条内容的 riskLevel,所有标签共用折算后的分值。
|
||||
score := aliyunRiskLevelScore(parsed.RiskLevel)
|
||||
|
||||
// Pass 取「审核服务未给出任何风险标签」。注意它不是最终裁决 ——
|
||||
// 是否拦截由 policy.go 按箱主严格度决定。
|
||||
pass := len(parsed.Labels) == 0
|
||||
|
||||
suggestion := aliyunRiskLevelSuggestion(parsed.RiskLevel)
|
||||
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block。
|
||||
// 典型场景是命中了尚未映射的新标签(既没有明细维度、也没有 riskLevel),
|
||||
// 不补的话策略层会把它当成未命中放行,与改造前的 fail-closed 行为不一致。
|
||||
if suggestion == "" && !pass {
|
||||
suggestion = SuggestionBlock
|
||||
}
|
||||
|
||||
details := make([]LabelScore, 0, len(parsed.Labels))
|
||||
var (
|
||||
forbiddenType ForbiddenType
|
||||
confidence float64
|
||||
)
|
||||
for _, label := range parsed.Labels {
|
||||
mapped := formatAliyunForbiddenType(label)
|
||||
if mapped == "" {
|
||||
// 未映射的标签不进入明细:它无法参与按维度裁决。
|
||||
// 原始标签仍完整保留在 RawResponse 中供审计。
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the first context as the hint, forbidden type, confidence.
|
||||
for _, detail := range result.Details {
|
||||
for _, context := range detail.Contexts {
|
||||
hint = context.Context
|
||||
}
|
||||
}
|
||||
details = append(details, LabelScore{Label: mapped, Score: score})
|
||||
|
||||
label = result.Label
|
||||
confidence = result.Rate
|
||||
break
|
||||
// 保留既有语义:首个命中的标签决定 ForbiddenType / Confidence。
|
||||
if forbiddenType == "" {
|
||||
forbiddenType = mapped
|
||||
confidence = score
|
||||
}
|
||||
}
|
||||
|
||||
return &TextCensorResponse{
|
||||
SourceName: "aliyun",
|
||||
Pass: responseJSON.IsPass(),
|
||||
ForbiddenType: formatAliyunForbiddenType(label),
|
||||
Hint: hint,
|
||||
Confidence: confidence,
|
||||
RawResponse: raw,
|
||||
SourceName: "aliyun",
|
||||
Pass: pass,
|
||||
ForbiddenType: forbiddenType,
|
||||
Hint: parsed.Hint,
|
||||
Confidence: confidence,
|
||||
RawResponse: raw,
|
||||
Details: details,
|
||||
ProviderSuggestion: suggestion,
|
||||
}, 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
|
||||
// 不会由本通道产生,七牛仍可能返回它。
|
||||
func formatAliyunForbiddenType(typ string) ForbiddenType {
|
||||
switch typ {
|
||||
case "spam":
|
||||
return ForbiddenTypeSpam
|
||||
case "ad":
|
||||
return ForbiddenTypeAd
|
||||
case "politics":
|
||||
switch strings.ToLower(strings.TrimSpace(typ)) {
|
||||
case "political_content", "regional":
|
||||
return ForbiddenTypePolitics
|
||||
case "terrorism":
|
||||
case "violence":
|
||||
return ForbiddenTypeTerrorism
|
||||
case "abuse":
|
||||
return ForbiddenTypeAbuse
|
||||
case "porn":
|
||||
case "sexual_content", "sexuality":
|
||||
return ForbiddenTypePorn
|
||||
case "flood":
|
||||
return ForbiddenTypeFlood
|
||||
case "contraband":
|
||||
return ForbiddenTypeContraband
|
||||
case "meaningless":
|
||||
return ForbiddenTypeMeaningless
|
||||
case "harmful":
|
||||
case "ad", "pullintraffic", "ad_compliance":
|
||||
return ForbiddenTypeAd
|
||||
case "profanity":
|
||||
return ForbiddenTypeAbuse
|
||||
case "negative_content", "cyberbullying", "religion":
|
||||
return ForbiddenTypeHarmful
|
||||
case "nonsense":
|
||||
return ForbiddenTypeMeaningless
|
||||
case "spam":
|
||||
return ForbiddenTypeSpam
|
||||
case "c_customized", "customized":
|
||||
// 命中运营方在阿里云控制台配置的自定义词库(属显式规则,见 policy.go 的底线集合)。
|
||||
return ForbiddenTypeCustomized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// aliyunAccountIssueSignals 「账号侧未就绪」的特征信号(小写匹配),覆盖结构化 code 与消息文本两条路径:
|
||||
// - 596 / "not opened":服务未开通
|
||||
// - Forbidden:RAM 未授权
|
||||
// - InvalidAccessKeyId / SignatureDoesNotMatch:密钥错误
|
||||
var aliyunAccountIssueSignals = []string{
|
||||
"596",
|
||||
"not opened",
|
||||
"forbidden",
|
||||
"invalidaccesskeyid",
|
||||
"signaturedoesnotmatch",
|
||||
"no permission",
|
||||
}
|
||||
|
||||
// aliyunIsAccountIssue 判断一次失败是否属于「账号侧未就绪」。
|
||||
// 先看 SDK 的结构化 code(稳定),再退回错误消息文本匹配(兼容 code 缺失的情况)。
|
||||
func aliyunIsAccountIssue(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
candidates := []string{strings.ToLower(err.Error())}
|
||||
var sdkErr *dara.SDKError
|
||||
if stderrors.As(err, &sdkErr) {
|
||||
candidates = append(candidates, strings.ToLower(dara.StringValue(sdkErr.GetCode())))
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, signal := range aliyunAccountIssueSignals {
|
||||
if strings.Contains(candidate, signal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// warnAliyunAccountIssueOnce 在首次出现「账号侧未就绪」类失败时告警一次。
|
||||
//
|
||||
// 这类问题(服务未开通 / RAM 未授权 / 密钥无效)不会自愈,会把 fail-closed 的审核链路
|
||||
// 永久卡死:外部表现是「全站内容都发不出去」,或(auto 模式下)内容静默落到下一个通道,
|
||||
// 但日志里原本只是一条普通 ERRO,极易被当成偶发网络抖动而忽略。
|
||||
// 因此第一次遇到就打出可操作的指引,并用 sync.Once 约束为只打一次,避免按请求刷屏。
|
||||
func warnAliyunAccountIssueOnce(ctx context.Context, err error) {
|
||||
if !aliyunIsAccountIssue(err) {
|
||||
return
|
||||
}
|
||||
warnAliyunAccountIssueDoOnce.Do(func() {
|
||||
logrus.WithContext(ctx).WithError(err).
|
||||
Warn("Aliyun text moderation looks unavailable due to an account-level issue " +
|
||||
"(service not activated / RAM not authorized / invalid AccessKey). " +
|
||||
"Fix: activate 内容安全 2.0「文本审核增强版」(pay-as-you-go) at the Content Moderation " +
|
||||
"console, and grant AliyunYundunGreenWebFullAccess to the RAM user. " +
|
||||
"Note: 内容安全 1.0 requires an enterprise-verified Aliyun account and can never be " +
|
||||
"activated by an individual account (code=596 recurs forever), while 2.0 only requires " +
|
||||
"real-name verification. Until this is fixed the censor chain keeps failing closed.")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package censor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
green20220302 "github.com/alibabacloud-go/green-20220302/v3/client"
|
||||
"github.com/alibabacloud-go/tea/dara"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewAliyunTextModerationRequestShape(t *testing.T) {
|
||||
// 2.0(TextModeration)的接口契约:Service 指明审核场景,
|
||||
// ServiceParameters 是 JSON 字符串 {"content": ...}。
|
||||
req, err := newAliyunTextModerationRequest("测试文本")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, aliyunTextService, dara.StringValue(req.Service))
|
||||
|
||||
var serviceParameters map[string]string
|
||||
assert.NoError(t, json.Unmarshal([]byte(dara.StringValue(req.ServiceParameters)), &serviceParameters))
|
||||
assert.Equal(t, "测试文本", serviceParameters["content"])
|
||||
}
|
||||
|
||||
func TestSplitAliyunText(t *testing.T) {
|
||||
// 未超限时原样返回,不做无意义的切分。
|
||||
assert.Equal(t, []string{"短文本"}, splitAliyunText("短文本"))
|
||||
|
||||
// 恰好等于上限:仍是单片。
|
||||
exact := strings.Repeat("a", aliyunContentRuneLimit)
|
||||
assert.Equal(t, []string{exact}, splitAliyunText(exact))
|
||||
|
||||
// 各档站点内容上限都要能覆盖:提问 1000 / 回答·私聊 2000 / 投票描述 5000。
|
||||
for _, length := range []int{aliyunContentRuneLimit + 1, 1000, 2000, 5000} {
|
||||
text := strings.Repeat("敏", length)
|
||||
chunks := splitAliyunText(text)
|
||||
|
||||
assert.Greater(t, len(chunks), 1, "length=%d 应当被切分", length)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
// 单次不得超过 2.0 的硬上限,否则请求会被服务端拒绝。
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(chunk), aliyunContentRuneLimit,
|
||||
"length=%d chunk=%d 超过单次上限", length, i)
|
||||
// 按 rune 切分,不能切坏多字节字符。
|
||||
assert.True(t, utf8.ValidString(chunk), "length=%d chunk=%d 切坏字符", length, i)
|
||||
}
|
||||
|
||||
// 首片必须从原文开头开始。
|
||||
assert.Equal(t, string([]rune(text)[:aliyunContentRuneLimit]), chunks[0], "length=%d", length)
|
||||
// 末片必须收束到原文结尾,否则尾部内容整段漏审。
|
||||
assert.True(t, strings.HasSuffix(text, chunks[len(chunks)-1]),
|
||||
"length=%d 最后一个分片未覆盖到原文结尾", length)
|
||||
|
||||
// 相邻分片必须重叠:消除「风险词正好跨切分点」的漏判盲区。
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
previous := []rune(chunks[i-1])
|
||||
current := []rune(chunks[i])
|
||||
overlap := aliyunChunkOverlapRunes
|
||||
if len(previous) < overlap {
|
||||
overlap = len(previous)
|
||||
}
|
||||
assert.Equal(t, string(previous[len(previous)-overlap:]), string(current[:overlap]),
|
||||
"length=%d 分片 %d 与上一片的重叠不符合预期", length, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunRiskLevelScore(t *testing.T) {
|
||||
// 2.0 不返回逐标签置信度,只返回 riskLevel,这里折算成 0~1 供 policy.go 复用。
|
||||
assert.Equal(t, 1.0, aliyunRiskLevelScore("high"))
|
||||
assert.Equal(t, 1.0, aliyunRiskLevelScore("HIGH"))
|
||||
assert.Equal(t, 0.6, aliyunRiskLevelScore("medium"))
|
||||
assert.Equal(t, 0.3, aliyunRiskLevelScore("low"))
|
||||
assert.Equal(t, 0.0, aliyunRiskLevelScore("none"))
|
||||
// 未知/缺失级别取保守值 1:宁可够到辱骂门槛被判拦截,也不要静默降权后放行。
|
||||
assert.Equal(t, 1.0, aliyunRiskLevelScore(""))
|
||||
assert.Equal(t, 1.0, aliyunRiskLevelScore("brand-new-level"))
|
||||
}
|
||||
|
||||
func TestAliyunRiskLevelSuggestion(t *testing.T) {
|
||||
assert.Equal(t, SuggestionBlock, aliyunRiskLevelSuggestion("high"))
|
||||
assert.Equal(t, SuggestionReview, aliyunRiskLevelSuggestion("medium"))
|
||||
assert.Equal(t, SuggestionPass, aliyunRiskLevelSuggestion("low"))
|
||||
assert.Equal(t, "", aliyunRiskLevelSuggestion("none"))
|
||||
assert.Equal(t, "", aliyunRiskLevelSuggestion(""))
|
||||
}
|
||||
|
||||
func TestAliyunHighestRiskLevel(t *testing.T) {
|
||||
assert.Equal(t, "", aliyunHighestRiskLevel(nil))
|
||||
assert.Equal(t, "", aliyunHighestRiskLevel([]string{"", " "}))
|
||||
assert.Equal(t, "low", aliyunHighestRiskLevel([]string{"low"}))
|
||||
assert.Equal(t, "medium", aliyunHighestRiskLevel([]string{"low", "medium"}))
|
||||
assert.Equal(t, "high", aliyunHighestRiskLevel([]string{"low", "high", "medium"}))
|
||||
// 未知级别按最高处理(方向保守),否则新级别会把整条内容静默降级。
|
||||
assert.Equal(t, "high", aliyunHighestRiskLevel([]string{"low", "brand-new-level"}))
|
||||
}
|
||||
|
||||
func TestAliyunParserRejectsLegacyFormat(t *testing.T) {
|
||||
// 迁移前内容安全 1.0 的响应(/green/text/scan)必须被拒绝。
|
||||
// 这是关键回归护栏:若把无法识别的载荷当成「无标签 → 通过」,
|
||||
// 历史缓存就会变成绕过审核的后门。
|
||||
raw := []byte(`{
|
||||
"code": 200,
|
||||
"data": [{"code": 200, "results": [
|
||||
{"label": "porn", "rate": 99.9, "scene": "antispam", "suggestion": "block", "details": []}
|
||||
]}],
|
||||
"msg": "OK",
|
||||
"requestId": "req-legacy"
|
||||
}`)
|
||||
|
||||
_, err := AliyunTextCensorParser(raw)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported aliyun response format")
|
||||
}
|
||||
|
||||
func TestAliyunParserCleanResponsePasses(t *testing.T) {
|
||||
raw := []byte(`{"api":"TextModeration","statusCode":200,"code":200,"message":"OK","requestIds":["req-1"]}`)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, parsed.Pass)
|
||||
assert.Empty(t, parsed.Details)
|
||||
assert.Equal(t, ForbiddenType(""), parsed.ForbiddenType)
|
||||
}
|
||||
|
||||
func TestAliyunEmptyCleanRawParses(t *testing.T) {
|
||||
// 空内容分支(原文只有一条可信链接、被 RemoveTrustedURL 整段移除)也必须走同一个解析器,
|
||||
// 保证「在线调用」与「缓存回放」两条路径产出的结构完全一致。
|
||||
parsed, err := AliyunTextCensorParser(aliyunEmptyCleanRaw)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, parsed.Pass)
|
||||
assert.Empty(t, parsed.Details)
|
||||
}
|
||||
|
||||
func TestAliyunParserFeedsPolicy(t *testing.T) {
|
||||
// 端到端校验:riskLevel=medium 折算 0.6,低于标准档的辱骂门槛(0.9),
|
||||
// 因此「标准档只拦高风险辱骂」,而严格档不限置信度、照拦。
|
||||
raw := []byte(`{
|
||||
"api": "TextModeration",
|
||||
"statusCode": 200,
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"requestIds": ["req-1"],
|
||||
"labels": ["profanity", "negative_content"],
|
||||
"riskLevel": "medium",
|
||||
"hint": "示例命中词"
|
||||
}`)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, parsed.Details, 2)
|
||||
assert.Equal(t, ForbiddenTypeAbuse, parsed.Details[0].Label)
|
||||
assert.InDelta(t, 0.6, parsed.Details[0].Score, 1e-9)
|
||||
assert.Equal(t, ForbiddenTypeHarmful, parsed.Details[1].Label)
|
||||
// 首个命中的标签决定 ForbiddenType / Confidence。
|
||||
assert.Equal(t, ForbiddenTypeAbuse, parsed.ForbiddenType)
|
||||
assert.InDelta(t, 0.6, parsed.Confidence, 1e-9)
|
||||
assert.Equal(t, "示例命中词", parsed.Hint)
|
||||
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
|
||||
assert.False(t, parsed.Pass)
|
||||
|
||||
decision, _ := Decide("lax", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
|
||||
// 中等风险不足 0.9 门槛 → 标准档放行(收敛误伤)。
|
||||
decision, _ = Decide("standard", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
|
||||
// 严格档不限置信度 → 拦截。
|
||||
decision, detail := Decide("strict", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, ForbiddenTypeAbuse, detail.Label)
|
||||
}
|
||||
|
||||
func TestAliyunParserMapsBaselineLabels(t *testing.T) {
|
||||
// 底线四件套在 2.0 下必须仍然映射得到(标签名与 1.0 完全不同):
|
||||
// political_content 涉政、violence 暴恐、sexual_content 色情、C_customized 自定义词库。
|
||||
for _, tc := range []struct {
|
||||
label string
|
||||
want ForbiddenType
|
||||
}{
|
||||
{"political_content", ForbiddenTypePolitics},
|
||||
{"regional", ForbiddenTypePolitics},
|
||||
{"violence", ForbiddenTypeTerrorism},
|
||||
{"sexual_content", ForbiddenTypePorn},
|
||||
{"sexuality", ForbiddenTypePorn},
|
||||
{"C_customized", ForbiddenTypeCustomized},
|
||||
{"contraband", ForbiddenTypeContraband},
|
||||
{"ad", ForbiddenTypeAd},
|
||||
{"PullinTraffic", ForbiddenTypeAd},
|
||||
{"ad_compliance", ForbiddenTypeAd},
|
||||
{"profanity", ForbiddenTypeAbuse},
|
||||
{"negative_content", ForbiddenTypeHarmful},
|
||||
{"cyberbullying", ForbiddenTypeHarmful},
|
||||
{"religion", ForbiddenTypeHarmful},
|
||||
{"nonsense", ForbiddenTypeMeaningless},
|
||||
{"spam", ForbiddenTypeSpam},
|
||||
} {
|
||||
assert.Equal(t, tc.want, formatAliyunForbiddenType(tc.label), "label=%s", tc.label)
|
||||
|
||||
// 逐一验证:只要映射到 ForbiddenType,就必须能被策略层正常消费。
|
||||
mapped := formatAliyunForbiddenType(tc.label)
|
||||
raw, err := json.Marshal(aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
Code: 200,
|
||||
Labels: []string{tc.label},
|
||||
RiskLevel: "low",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, parsed.Details, 1)
|
||||
assert.Equal(t, mapped, parsed.Details[0].Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunBaselineLabelsBlockAtEveryLevel(t *testing.T) {
|
||||
// 底线维度不受档位影响:即使审核服务给 low、置信度折算为 0.3,也必须拦。
|
||||
for _, label := range []string{"political_content", "violence", "sexual_content", "C_customized"} {
|
||||
raw, err := json.Marshal(aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
Code: 200,
|
||||
Labels: []string{label},
|
||||
RiskLevel: "low",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for _, level := range []string{"lax", "standard", "strict"} {
|
||||
decision, detail := Decide(level, parsed)
|
||||
assert.Equal(t, DecisionBlock, decision, "label=%s level=%s", label, level)
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, formatAliyunForbiddenType(label), detail.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunUnmappedLabelSkipped(t *testing.T) {
|
||||
// 未映射的标签不进入明细(无法参与按维度裁决),原始标签仍保留在 RawResponse 中供审计。
|
||||
raw, err := json.Marshal(aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
Code: 200,
|
||||
Labels: []string{"brand_new_label"},
|
||||
RiskLevel: "medium",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, parsed.Details)
|
||||
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
|
||||
}
|
||||
|
||||
func TestAliyunLabeledResponseWithoutRiskLevelFallsBackToBlock(t *testing.T) {
|
||||
// 与七牛保持一致:审核服务判为「非通过」却没给出建议时补成 block,
|
||||
// 否则策略层会因「没有明细维度 + 没有建议」把命中内容当成未命中放行。
|
||||
raw, err := json.Marshal(aliyunTextModerationRaw{
|
||||
API: aliyunTextModerationAPI,
|
||||
Code: 200,
|
||||
Labels: []string{"brand_new_label"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, parsed.Pass)
|
||||
assert.Equal(t, SuggestionBlock, parsed.ProviderSuggestion)
|
||||
|
||||
// 严格档采信整体 block 建议 → 拦截;宽松/标准档不采信 → 放行。
|
||||
decision, _ := Decide("strict", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
decision, _ = Decide("standard", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
decision, _ = Decide("lax", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
}
|
||||
|
||||
func TestAliyunInvalidJSONErrorCarriesRawResponse(t *testing.T) {
|
||||
// 响应不是 JSON(如网关返回 HTML 错误页)时,错误里要能看到响应原文。
|
||||
raw := []byte(`<html>502 Bad Gateway</html>`)
|
||||
|
||||
_, err := AliyunTextCensorParser(raw)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "502 Bad Gateway")
|
||||
}
|
||||
|
||||
func TestAliyunRawDigestTruncates(t *testing.T) {
|
||||
assert.Equal(t, "<empty>", aliyunRawDigest([]byte(" ")))
|
||||
|
||||
long := strings.Repeat("敏", aliyunMaxRawLogBytes+50)
|
||||
got := aliyunRawDigest([]byte(long))
|
||||
|
||||
suffix := "...(truncated)"
|
||||
assert.True(t, strings.HasSuffix(got, suffix))
|
||||
// 按字符(而非字节)截断,多字节字符不能被切成半个。
|
||||
assert.True(t, utf8.ValidString(got))
|
||||
assert.Equal(t, aliyunMaxRawLogBytes, len([]rune(strings.TrimSuffix(got, suffix))))
|
||||
}
|
||||
|
||||
// aliyunSDKResponse 构造一个 SDK 形态的 TextModeration 响应,省去每个用例重复拼结构体。
|
||||
func aliyunSDKResponse(statusCode, code int32, labels, reason string) *green20220302.TextModerationResponse {
|
||||
response := &green20220302.TextModerationResponse{
|
||||
StatusCode: dara.Int32(statusCode),
|
||||
Body: &green20220302.TextModerationResponseBody{
|
||||
Code: dara.Int32(code),
|
||||
Message: dara.String("OK"),
|
||||
RequestId: dara.String("req-1"),
|
||||
},
|
||||
}
|
||||
if labels != "" || reason != "" {
|
||||
response.Body.Data = &green20220302.TextModerationResponseBodyData{
|
||||
Labels: dara.String(labels),
|
||||
Reason: dara.String(reason),
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func TestExtractAliyunChunkResult(t *testing.T) {
|
||||
t.Run("成功:解析标签与 Reason", func(t *testing.T) {
|
||||
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "profanity, negative_content",
|
||||
`{"riskLevel":"medium","riskTips":"辱骂_人身攻击","riskWords":"你是个傻子"}`))
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int32(200), result.Code)
|
||||
assert.Equal(t, int32(200), result.StatusCode)
|
||||
assert.Equal(t, "req-1", result.RequestID)
|
||||
assert.Equal(t, []string{"profanity", "negative_content"}, result.Labels)
|
||||
assert.Equal(t, "medium", result.RiskLevel)
|
||||
// Hint 优先取命中词。
|
||||
assert.Equal(t, "你是个傻子", result.Hint)
|
||||
})
|
||||
|
||||
t.Run("nonLabel/normal 是占位标签,不进入明细", func(t *testing.T) {
|
||||
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "nonLabel,normal,", ""))
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, result.Labels)
|
||||
})
|
||||
|
||||
t.Run("RiskWords 缺失时 Hint 退回 RiskTips", func(t *testing.T) {
|
||||
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "ad",
|
||||
`{"riskLevel":"high","riskTips":"广告_引流"}`))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "广告_引流", result.Hint)
|
||||
})
|
||||
|
||||
t.Run("Reason 非法 JSON 不应导致整条审核失败", func(t *testing.T) {
|
||||
result, err := extractAliyunChunkResult(aliyunSDKResponse(200, 200, "ad", `not-json`))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"ad"}, result.Labels)
|
||||
assert.Equal(t, "", result.RiskLevel)
|
||||
})
|
||||
|
||||
t.Run("code 非 200 必须报错,不能当成未命中", func(t *testing.T) {
|
||||
// 服务未开通时阿里云会返回自己的错误码。若被当成「无标签 → 通过」,
|
||||
// fail-closed 会悄悄变成 fail-open。
|
||||
_, err := extractAliyunChunkResult(aliyunSDKResponse(200, 596, "", ""))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "596")
|
||||
})
|
||||
|
||||
t.Run("HTTP 非 2xx 必须报错", func(t *testing.T) {
|
||||
_, err := extractAliyunChunkResult(aliyunSDKResponse(502, 200, "", ""))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unexpected http status")
|
||||
})
|
||||
|
||||
t.Run("响应体缺失必须报错", func(t *testing.T) {
|
||||
_, err := extractAliyunChunkResult(nil)
|
||||
assert.Error(t, err)
|
||||
_, err = extractAliyunChunkResult(&green20220302.TextModerationResponse{})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeAliyunChunkResults(t *testing.T) {
|
||||
merged := mergeAliyunChunkResults([]aliyunChunkResult{
|
||||
{StatusCode: 200, Code: 200, RequestID: "r1", Labels: []string{"profanity"}, RiskLevel: "low"},
|
||||
{StatusCode: 200, Code: 200, RequestID: "r2", Labels: []string{"profanity", "political_content"},
|
||||
RiskLevel: "high", Hint: "命中词"},
|
||||
})
|
||||
|
||||
assert.Equal(t, aliyunTextModerationAPI, merged.API)
|
||||
assert.Equal(t, []string{"r1", "r2"}, merged.RequestIDs)
|
||||
// 标签取并集并去重,保持出现顺序。
|
||||
assert.Equal(t, []string{"profanity", "political_content"}, merged.Labels)
|
||||
// 风险级别取最高:任一分片命中高风险,整条内容按高风险处置。
|
||||
assert.Equal(t, "high", merged.RiskLevel)
|
||||
// Hint 取首个非空。
|
||||
assert.Equal(t, "命中词", merged.Hint)
|
||||
|
||||
// 合并结果可直接被解析器消费,且高风险 + 涉政 → 任何档位都拦。
|
||||
raw, err := json.Marshal(merged)
|
||||
assert.NoError(t, err)
|
||||
|
||||
parsed, err := AliyunTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, parsed.Details, 2)
|
||||
for _, detail := range parsed.Details {
|
||||
assert.InDelta(t, 1.0, detail.Score, 1e-9)
|
||||
}
|
||||
decision, detail := Decide("lax", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, ForbiddenTypePolitics, detail.Label)
|
||||
}
|
||||
|
||||
func TestMergeAliyunChunkResultsEmpty(t *testing.T) {
|
||||
// 没有任何分片(理论上不会发生,因为空内容有单独的短路分支)时,
|
||||
// 合并结果必须仍然是「无标签」,即不产生任何命中。
|
||||
merged := mergeAliyunChunkResults(nil)
|
||||
assert.Empty(t, merged.Labels)
|
||||
assert.Equal(t, "", merged.RiskLevel)
|
||||
assert.Equal(t, aliyunTextModerationAPI, merged.API)
|
||||
}
|
||||
|
||||
func TestAliyunIsAccountIssue(t *testing.T) {
|
||||
// 账号侧未就绪的各类表现都要能识别出来 —— 它们不会自愈,必须给出可操作告警。
|
||||
for _, message := range []string{
|
||||
"code=596 You have not opened Yundun Content Moderation Service",
|
||||
"You have not opened Yundun Content Moderation Service, please go to the page",
|
||||
"Forbidden.RAM: no permission to perform this action",
|
||||
"InvalidAccessKeyId.NotFound: the access key id is invalid",
|
||||
"SignatureDoesNotMatch: signature mismatch",
|
||||
} {
|
||||
assert.True(t, aliyunIsAccountIssue(errors.New(message)), "message=%q", message)
|
||||
}
|
||||
|
||||
// 偶发/网络类问题不应被当成账号问题,否则一次性告警会被无关噪声消耗掉。
|
||||
for _, message := range []string{
|
||||
"dial tcp 1.2.3.4:443: i/o timeout",
|
||||
"EOF",
|
||||
"context deadline exceeded",
|
||||
} {
|
||||
assert.False(t, aliyunIsAccountIssue(errors.New(message)), "message=%q", message)
|
||||
}
|
||||
|
||||
assert.False(t, aliyunIsAccountIssue(nil))
|
||||
}
|
||||
|
||||
func TestAliyunIsAccountIssueFromStructuredCode(t *testing.T) {
|
||||
// SDK 的结构化 code 比消息文本更稳定:即使 message 里没有特征词也要能识别。
|
||||
sdkErr := dara.NewSDKError(map[string]interface{}{
|
||||
"code": "Forbidden",
|
||||
"message": "the request is denied",
|
||||
})
|
||||
assert.True(t, aliyunIsAccountIssue(sdkErr))
|
||||
}
|
||||
@@ -6,6 +6,8 @@ package censor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -17,8 +19,49 @@ import (
|
||||
|
||||
var (
|
||||
censorCacheNoMoreThan = 31 * 24 * time.Hour // 1 month
|
||||
|
||||
// warnNoCensorCredentialsOnce 保证「没有可用凭据」的告警只打一次,避免按请求刷屏。
|
||||
warnNoCensorCredentialsOnce sync.Once
|
||||
)
|
||||
|
||||
// censorChain 返回按配置选定的审核通道列表,顺序即优先级:
|
||||
// 靠前的通道判定「通过」即返回;未通过则继续交给后面的通道复核。
|
||||
//
|
||||
// 取值来源 [security] text_censor_source(见 conf.CensorSource*):
|
||||
// - qiniu → 只用七牛(凭据齐备性已由 conf.Init 启动校验)
|
||||
// - aliyun → 只用阿里云(同上)
|
||||
// - 其它(含留空 / auto)→ 七牛优先、阿里云兜底,与引入该配置前完全一致
|
||||
func censorChain() []TextCensor {
|
||||
qiniu := NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret)
|
||||
aliyun := NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret)
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(conf.Security.TextCensorSource)) {
|
||||
case conf.CensorSourceQiniu:
|
||||
return []TextCensor{qiniu}
|
||||
case conf.CensorSourceAliyun:
|
||||
return []TextCensor{aliyun}
|
||||
default:
|
||||
return []TextCensor{qiniu, aliyun}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != ""
|
||||
if qiniuReady || aliyunReady {
|
||||
return
|
||||
}
|
||||
warnNoCensorCredentialsOnce.Do(func() {
|
||||
logrus.WithContext(ctx).WithField("text_censor_source", conf.Security.TextCensorSource).
|
||||
Error("Text censor is enabled but no usable credentials are configured; all submissions will be rejected (fail-closed)")
|
||||
})
|
||||
}
|
||||
|
||||
// Text checks the text for sensitive content.
|
||||
// It will save the censor log to the database and invoke the callback function.
|
||||
func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
@@ -28,11 +71,8 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
|
||||
var responses []*TextCensorResponse
|
||||
|
||||
for _, censor := range []TextCensor{
|
||||
//No, we need to use qiniu's API.
|
||||
NewQiniuTextCensor(conf.App.QiniuAccessKey, conf.App.QiniuAccessSecret),
|
||||
NewAliyunTextCensor(conf.App.AliyunAccessKey, conf.App.AliyunAccessKeySecret),
|
||||
} {
|
||||
warnIfNoCensorCredentials(ctx)
|
||||
for _, censor := range censorChain() {
|
||||
sourceName := censor.String()
|
||||
|
||||
// Try to get the censor from the database log.
|
||||
@@ -46,19 +86,33 @@ func Text(ctx context.Context, text string) (*TextCensorResponse, error) {
|
||||
// We got the previous censor log cache, seems like we saved money for the API call.
|
||||
switch censorLog.SourceName {
|
||||
case "qiniu":
|
||||
// HACK: Qiniu's API response is not accurate, so we need to check the text again with aliyun API.
|
||||
// 七牛缓存命中:只有「通过」才可直接复用(省一次 API 调用)。
|
||||
// 未通过时不在此下最终结论,而是交给链上的下一个通道复核(auto 模式下即阿里云,
|
||||
// 这是引入通道配置前就有的行为)。
|
||||
// text_censor_source = qiniu 时链上只有七牛:continue 后循环结束,
|
||||
// 仍会由函数末尾返回这份七牛结果,判定不会丢失。
|
||||
response, err := QiniuTextCensorParser(raw)
|
||||
if err == nil {
|
||||
if response.Pass {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
responses = append(responses, response) // If the text is not passed, we need to check it with aliyun API.
|
||||
responses = append(responses, response)
|
||||
continue
|
||||
}
|
||||
|
||||
case "aliyun":
|
||||
return AliyunTextCensorParser(raw)
|
||||
// 阿里云缓存命中:能解析就直接复用(省一次 API 调用)。
|
||||
// 解析失败必须退回一次实时调用,而不是把错误往上抛:
|
||||
// 最典型的失败源是「迁移到内容安全 2.0 之前落库的 1.0 缓存」,
|
||||
// 它无法按 2.0 语义解释;若在此返回错误,fail-closed 会让这些
|
||||
// 历史文本在缓存过期(31 天)前一直发不出去。
|
||||
response, err := AliyunTextCensorParser(raw)
|
||||
if err == nil {
|
||||
return response, nil
|
||||
}
|
||||
logrus.WithContext(ctx).WithError(err).WithField("censor_source", sourceName).
|
||||
Warn("Failed to replay cached aliyun censor result; falling back to a live call")
|
||||
|
||||
default:
|
||||
logrus.WithContext(ctx).WithField("censor_source", censorLog.SourceName).Error("Unknown censor source")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package censor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
// sourceNames 把通道列表映射成名称序列,便于断言「选了几条、哪条、顺序如何」。
|
||||
func sourceNames(chain []TextCensor) []string {
|
||||
names := make([]string, 0, len(chain))
|
||||
for _, censor := range chain {
|
||||
names = append(names, censor.String())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestCensorChainSelection 覆盖 [security] text_censor_source 对审核通道链的装配结果。
|
||||
//
|
||||
// 顺序即优先级:靠前通道「通过」即返回;未通过则交给后面的通道复核。
|
||||
// 最要紧的是默认(留空 / auto)必须与引入该配置前完全一致,避免升级改变线上判定。
|
||||
func TestCensorChainSelection(t *testing.T) {
|
||||
original := conf.Security.TextCensorSource
|
||||
t.Cleanup(func() { conf.Security.TextCensorSource = original })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
source string
|
||||
want []string
|
||||
}{
|
||||
{name: "留空:七牛优先 + 阿里云兜底(历史行为)", source: "", want: []string{"qiniu", "aliyun"}},
|
||||
{name: "auto:同留空", source: "auto", want: []string{"qiniu", "aliyun"}},
|
||||
{name: "qiniu:只走七牛", source: "qiniu", want: []string{"qiniu"}},
|
||||
{name: "aliyun:只走阿里云", source: "aliyun", want: []string{"aliyun"}},
|
||||
{name: "大小写与空白不敏感", source: " AlIyUn \t", want: []string{"aliyun"}},
|
||||
// 未知取值在启动期已被 conf.Init 直接拒绝(见 internal/conf validateTextCensorSource);
|
||||
// 这里断言 censor 侧仍保守回退到历史行为,避免绕过 Init 的调用路径(如 CLI/tests)行为突变。
|
||||
{name: "未知取值:保守回退 auto", source: "bogus", want: []string{"qiniu", "aliyun"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conf.Security.TextCensorSource = tc.source
|
||||
assert.Equal(t, tc.want, sourceNames(censorChain()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package censor
|
||||
|
||||
// Strictness 内容审核严格度档位。
|
||||
//
|
||||
// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
|
||||
// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
|
||||
// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
|
||||
// 唯一能控制的是「审核命中之后到底拦不拦」。
|
||||
//
|
||||
// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
|
||||
// 让箱主在「少误伤」与「少漏放」之间自行取舍。
|
||||
type Strictness string
|
||||
|
||||
const (
|
||||
// StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
|
||||
StrictnessLax Strictness = "lax"
|
||||
// StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
|
||||
StrictnessStandard Strictness = "standard"
|
||||
// StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
|
||||
//
|
||||
// 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
|
||||
// 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
|
||||
// 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
|
||||
// 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
|
||||
StrictnessStrict Strictness = "strict"
|
||||
)
|
||||
|
||||
// DefaultStrictness 缺省档位。
|
||||
//
|
||||
// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
|
||||
// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
|
||||
// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
|
||||
//
|
||||
// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
|
||||
// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
|
||||
// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
|
||||
// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
|
||||
const DefaultStrictness = StrictnessStrict
|
||||
|
||||
// Suggestion 审核服务自身给出的管控建议取值。
|
||||
const (
|
||||
SuggestionBlock = "block"
|
||||
SuggestionReview = "review"
|
||||
SuggestionPass = "pass"
|
||||
)
|
||||
|
||||
// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
|
||||
//
|
||||
// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
|
||||
// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
|
||||
//
|
||||
// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
|
||||
// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
|
||||
// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
|
||||
var baselineBlockLabels = map[ForbiddenType]struct{}{
|
||||
ForbiddenTypePolitics: {},
|
||||
ForbiddenTypeTerrorism: {},
|
||||
ForbiddenTypePorn: {},
|
||||
ForbiddenTypeCustomized: {},
|
||||
}
|
||||
|
||||
// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
|
||||
//
|
||||
// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
|
||||
// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
|
||||
//
|
||||
// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
|
||||
// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
|
||||
// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
|
||||
const abuseScoreFloor = 0.9
|
||||
|
||||
// levelPolicy 单一档位的拦截规则。
|
||||
type levelPolicy struct {
|
||||
// blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
|
||||
// 注意:底线维度不在此表中,由 Decide 无条件前置判定。
|
||||
blockLabels map[ForbiddenType]float64
|
||||
// blockOnSuggestion 是否采信审核服务自身的 block 建议。
|
||||
//
|
||||
// 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
|
||||
// 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
|
||||
blockOnSuggestion bool
|
||||
}
|
||||
|
||||
var strictnessPolicies = map[Strictness]levelPolicy{
|
||||
// 宽松:只留底线,其余全部放行。
|
||||
StrictnessLax: {
|
||||
blockLabels: map[ForbiddenType]float64{},
|
||||
blockOnSuggestion: false,
|
||||
},
|
||||
// 标准:底线 + 高置信度辱骂 + 违禁。
|
||||
StrictnessStandard: {
|
||||
blockLabels: map[ForbiddenType]float64{
|
||||
ForbiddenTypeAbuse: abuseScoreFloor,
|
||||
ForbiddenTypeContraband: 0,
|
||||
},
|
||||
blockOnSuggestion: false,
|
||||
},
|
||||
// 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
|
||||
StrictnessStrict: {
|
||||
blockLabels: map[ForbiddenType]float64{
|
||||
ForbiddenTypeSpam: 0,
|
||||
ForbiddenTypeAd: 0,
|
||||
ForbiddenTypeAbuse: 0,
|
||||
ForbiddenTypeFlood: 0,
|
||||
ForbiddenTypeContraband: 0,
|
||||
ForbiddenTypeMeaningless: 0,
|
||||
ForbiddenTypeHarmful: 0,
|
||||
},
|
||||
blockOnSuggestion: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Decision 对单条内容的最终裁决。
|
||||
type Decision int
|
||||
|
||||
const (
|
||||
// DecisionAllow 放行。
|
||||
DecisionAllow Decision = iota
|
||||
// DecisionBlock 拒绝提交。
|
||||
DecisionBlock
|
||||
)
|
||||
|
||||
// Blocked 是否应当拒绝提交。
|
||||
func (d Decision) Blocked() bool { return d == DecisionBlock }
|
||||
|
||||
// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
|
||||
// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
|
||||
func IsValidStrictness(s string) bool {
|
||||
switch Strictness(s) {
|
||||
case StrictnessLax, StrictnessStandard, StrictnessStrict:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
|
||||
// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
|
||||
func ParseStrictness(s string) Strictness {
|
||||
if !IsValidStrictness(s) {
|
||||
return DefaultStrictness
|
||||
}
|
||||
return Strictness(s)
|
||||
}
|
||||
|
||||
// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
|
||||
//
|
||||
// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
|
||||
// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
|
||||
// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
|
||||
// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
|
||||
//
|
||||
// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
|
||||
// 只有开启 blockOnSuggestion 的档位会拦截。
|
||||
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
|
||||
if r == nil {
|
||||
return DecisionAllow, nil
|
||||
}
|
||||
|
||||
policy := strictnessPolicies[ParseStrictness(strictness)]
|
||||
|
||||
// 1. 底线:合规底线,任何档位强制生效、不可关闭。
|
||||
for i := range r.Details {
|
||||
detail := &r.Details[i]
|
||||
if _, ok := baselineBlockLabels[detail.Label]; ok {
|
||||
return DecisionBlock, detail
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
|
||||
for i := range r.Details {
|
||||
detail := &r.Details[i]
|
||||
scoreFloor, ok := policy.blockLabels[detail.Label]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if scoreFloor > 0 && detail.Score < scoreFloor {
|
||||
continue
|
||||
}
|
||||
return DecisionBlock, detail
|
||||
}
|
||||
|
||||
// 3. 审核服务自身的 block 建议:仅严格档采信。
|
||||
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
|
||||
return DecisionBlock, nil
|
||||
}
|
||||
|
||||
return DecisionAllow, nil
|
||||
}
|
||||
|
||||
// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
|
||||
//
|
||||
// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
|
||||
// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
|
||||
// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
|
||||
// 生成文案,这里是唯一事实来源。
|
||||
func BlockedLabels(strictness string) []ForbiddenType {
|
||||
policy := strictnessPolicies[ParseStrictness(strictness)]
|
||||
|
||||
labels := make([]ForbiddenType, 0, len(baselineBlockLabels)+len(policy.blockLabels))
|
||||
for label := range baselineBlockLabels {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
for label := range policy.blockLabels {
|
||||
// 底线维度已在上面加入,避免重复展示。
|
||||
if _, ok := baselineBlockLabels[label]; ok {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package censor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// resp 构造一个带指定维度判定的审核结果,省去每个用例重复拼结构体。
|
||||
func resp(suggestion string, details ...LabelScore) *TextCensorResponse {
|
||||
return &TextCensorResponse{
|
||||
SourceName: "qiniu",
|
||||
Pass: suggestion != SuggestionBlock,
|
||||
ProviderSuggestion: suggestion,
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideBaselineAlwaysBlocks(t *testing.T) {
|
||||
// 底线维度(涉政 / 暴恐 / 色情 / 自定义词库)是强制项:任何档位、任何置信度都必须拦截,
|
||||
// 且不受审核服务整体建议的影响(这里刻意给 pass 建议,验证仍然拦得住)。
|
||||
baselines := []ForbiddenType{
|
||||
ForbiddenTypePolitics,
|
||||
ForbiddenTypeTerrorism,
|
||||
ForbiddenTypePorn,
|
||||
ForbiddenTypeCustomized,
|
||||
}
|
||||
levels := []string{"lax", "standard", "strict", "", "unknown-level"}
|
||||
|
||||
for _, label := range baselines {
|
||||
for _, level := range levels {
|
||||
t.Run(string(label)+"/"+level, func(t *testing.T) {
|
||||
decision, detail := Decide(level, resp(SuggestionPass, LabelScore{Label: label, Score: 0.01}))
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, label, detail.Label)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideByLevel(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
level string
|
||||
response *TextCensorResponse
|
||||
wantBlock bool
|
||||
wantDetail ForbiddenType
|
||||
}{
|
||||
{
|
||||
name: "宽松:高置信度辱骂也放行",
|
||||
level: "lax",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.99}),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "宽松:垃圾信息放行",
|
||||
level: "lax",
|
||||
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.9}),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "宽松:不采信审核服务的整体 block 建议",
|
||||
level: "lax",
|
||||
response: resp(SuggestionBlock),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "标准:高置信度辱骂拦截",
|
||||
level: "standard",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.95}),
|
||||
wantBlock: true,
|
||||
wantDetail: ForbiddenTypeAbuse,
|
||||
},
|
||||
{
|
||||
name: "标准:辱骂置信度不足 90% 放行(收敛误伤)",
|
||||
level: "standard",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.72}),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "标准:辱骂恰好 90% 拦截(阈值含等号)",
|
||||
level: "standard",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.9}),
|
||||
wantBlock: true,
|
||||
wantDetail: ForbiddenTypeAbuse,
|
||||
},
|
||||
{
|
||||
name: "标准:违禁不限置信度",
|
||||
level: "standard",
|
||||
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeContraband, Score: 0.3}),
|
||||
wantBlock: true,
|
||||
wantDetail: ForbiddenTypeContraband,
|
||||
},
|
||||
{
|
||||
name: "标准:垃圾信息放行",
|
||||
level: "standard",
|
||||
response: resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.99}),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "标准:不采信整体 block 建议",
|
||||
level: "standard",
|
||||
response: resp(SuggestionBlock),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "严格:辱骂不限置信度拦截(最严档)",
|
||||
level: "strict",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeAbuse, Score: 0.2}),
|
||||
wantBlock: true,
|
||||
wantDetail: ForbiddenTypeAbuse,
|
||||
},
|
||||
{
|
||||
name: "严格:无意义内容拦截",
|
||||
level: "strict",
|
||||
response: resp(SuggestionReview, LabelScore{Label: ForbiddenTypeMeaningless, Score: 0.5}),
|
||||
wantBlock: true,
|
||||
wantDetail: ForbiddenTypeMeaningless,
|
||||
},
|
||||
{
|
||||
name: "严格:采信整体 block 建议(无明细维度)",
|
||||
level: "strict",
|
||||
response: resp(SuggestionBlock),
|
||||
wantBlock: true,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "严格:仅 review 且无维度命中则放行",
|
||||
level: "strict",
|
||||
response: resp(SuggestionReview),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
{
|
||||
name: "全部正常文本放行",
|
||||
level: "strict",
|
||||
response: resp(SuggestionPass),
|
||||
wantBlock: false,
|
||||
wantDetail: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decision, detail := Decide(tc.level, tc.response)
|
||||
assert.Equal(t, tc.wantBlock, decision.Blocked())
|
||||
|
||||
if tc.wantDetail == "" {
|
||||
assert.Nil(t, detail)
|
||||
return
|
||||
}
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, tc.wantDetail, detail.Label)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideNilResponse(t *testing.T) {
|
||||
// 空结果(审核未返回)不应被当成命中,避免把「拿不到结论」误判为违规。
|
||||
decision, detail := Decide("strict", nil)
|
||||
assert.Equal(t, DecisionAllow, decision)
|
||||
assert.Nil(t, detail)
|
||||
}
|
||||
|
||||
func TestDecideUnknownLevelFallsBackToStrict(t *testing.T) {
|
||||
// 脏数据 / 未识别取值必须回退「严格」,方向保守:
|
||||
// 宁可沿用升级前的严格判定,也不要因为认不出档位就静默放宽。
|
||||
unknown := resp(SuggestionPass, LabelScore{Label: ForbiddenTypeSpam, Score: 0.9})
|
||||
decision, _ := Decide("", unknown)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
decision, _ = Decide("bogus", unknown)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
}
|
||||
|
||||
func TestStrictnessHelpers(t *testing.T) {
|
||||
assert.True(t, IsValidStrictness("lax"))
|
||||
assert.True(t, IsValidStrictness("standard"))
|
||||
assert.True(t, IsValidStrictness("strict"))
|
||||
assert.False(t, IsValidStrictness(""))
|
||||
assert.False(t, IsValidStrictness("STRICT"))
|
||||
assert.False(t, IsValidStrictness("off"))
|
||||
|
||||
assert.Equal(t, StrictnessStrict, ParseStrictness(""))
|
||||
assert.Equal(t, StrictnessStrict, ParseStrictness("nope"))
|
||||
assert.Equal(t, StrictnessLax, ParseStrictness("lax"))
|
||||
assert.Equal(t, StrictnessStandard, ParseStrictness("standard"))
|
||||
}
|
||||
|
||||
func TestQiniuParserFeedsPolicy(t *testing.T) {
|
||||
// 策略层完全依赖 parser 填好的 Details / ProviderSuggestion,这里做一次端到端校验:
|
||||
// 审核服务给出 review(自身判定为放行)但带 abuse 维度,严格档仍应拦得住。
|
||||
raw := []byte(`{
|
||||
"code": 200,
|
||||
"message": "ok",
|
||||
"result": {
|
||||
"suggestion": "review",
|
||||
"scenes": {
|
||||
"antispam": {
|
||||
"suggestion": "review",
|
||||
"details": [
|
||||
{"label": "normal", "score": 0.02, "contexts": []},
|
||||
{"label": "abuse", "score": 0.94, "contexts": [{"context": "示例片段", "positions": [{"startPos": 0, "endPos": 4}]}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
parsed, err := QiniuTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// normal 维度必须被过滤掉,只保留真正命中的维度。
|
||||
assert.Len(t, parsed.Details, 1)
|
||||
assert.Equal(t, ForbiddenTypeAbuse, parsed.Details[0].Label)
|
||||
assert.Equal(t, 0.94, parsed.Details[0].Score)
|
||||
assert.Equal(t, SuggestionReview, parsed.ProviderSuggestion)
|
||||
assert.Equal(t, "示例片段", parsed.Hint)
|
||||
|
||||
// 审核服务自己判 review(Pass=true),但档位策略仍可覆盖它。
|
||||
assert.True(t, parsed.Pass)
|
||||
|
||||
decision, detail := Decide("strict", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
assert.NotNil(t, detail)
|
||||
assert.Equal(t, ForbiddenTypeAbuse, detail.Label)
|
||||
|
||||
decision, _ = Decide("standard", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
|
||||
decision, _ = Decide("lax", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
}
|
||||
|
||||
func TestQiniuParserProviderSuggestionFallback(t *testing.T) {
|
||||
// 外层 suggestion 缺失时回退到 antispam 场景内的建议,避免整体建议丢失后
|
||||
// 严格档的「采信整体 block」分支失效。
|
||||
raw := []byte(`{"code":200,"result":{"scenes":{"antispam":{"suggestion":"block","details":[]}}}}`)
|
||||
|
||||
parsed, err := QiniuTextCensorParser(raw)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, SuggestionBlock, parsed.ProviderSuggestion)
|
||||
assert.Empty(t, parsed.Details)
|
||||
|
||||
// 严格档:无具体维度,但整体建议为 block → 拦截。
|
||||
decision, detail := Decide("strict", parsed)
|
||||
assert.Equal(t, DecisionBlock, decision)
|
||||
assert.Nil(t, detail)
|
||||
|
||||
// 标准档不采信整体建议 → 放行(这是「少误伤」的核心差异)。
|
||||
decision, _ = Decide("standard", parsed)
|
||||
assert.False(t, decision.Blocked())
|
||||
}
|
||||
|
||||
func TestBlockedLabelsIncludesBaselineAtEveryLevel(t *testing.T) {
|
||||
// 供设置页展示拦截范围:底线维度必须出现在每一个档位的清单里。
|
||||
for _, level := range []string{"lax", "standard", "strict"} {
|
||||
labels := BlockedLabels(level)
|
||||
assert.Contains(t, labels, ForbiddenTypePolitics, "level=%s", level)
|
||||
assert.Contains(t, labels, ForbiddenTypeTerrorism, "level=%s", level)
|
||||
assert.Contains(t, labels, ForbiddenTypePorn, "level=%s", level)
|
||||
assert.Contains(t, labels, ForbiddenTypeCustomized, "level=%s", level)
|
||||
|
||||
// 不重复展示底线维度。
|
||||
seen := map[ForbiddenType]int{}
|
||||
for _, label := range labels {
|
||||
seen[label]++
|
||||
}
|
||||
for label, count := range seen {
|
||||
assert.Equal(t, 1, count, "label %s 重复出现", label)
|
||||
}
|
||||
}
|
||||
|
||||
// 宽松档只含底线四个维度(涉政 / 暴恐 / 色情 / 自定义词库)。
|
||||
assert.Len(t, BlockedLabels("lax"), 4)
|
||||
assert.Contains(t, BlockedLabels("standard"), ForbiddenTypeAbuse)
|
||||
assert.NotContains(t, BlockedLabels("standard"), ForbiddenTypeSpam)
|
||||
assert.Contains(t, BlockedLabels("strict"), ForbiddenTypeSpam)
|
||||
}
|
||||
@@ -128,27 +128,52 @@ func QiniuTextCensorParser(raw []byte) (*TextCensorResponse, error) {
|
||||
var hint string
|
||||
var detailKey string
|
||||
var confidence float64
|
||||
for _, detail := range responseJSON.Result.Scenes.Antispam.Details {
|
||||
if detail.Label == "normal" {
|
||||
// 收集全部非 normal 维度的判定明细,供严格度策略按维度裁决(见 policy.go)。
|
||||
// 只取首个维度的做法会丢掉其余维度信息,无法做到「只拦高置信度的辱骂」。
|
||||
details := make([]LabelScore, 0, len(responseJSON.Result.Scenes.Antispam.Details))
|
||||
for _, d := range responseJSON.Result.Scenes.Antispam.Details {
|
||||
if d.Label == "normal" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the first context as the hint, forbidden type, confidence.
|
||||
for _, context := range detail.Contexts {
|
||||
hint = context.Context
|
||||
details = append(details, LabelScore{
|
||||
Label: formatQiniuForbiddenType(d.Label),
|
||||
Score: d.Score,
|
||||
})
|
||||
|
||||
// 保留原有语义:首个非 normal 维度决定 ForbiddenType / Hint / Confidence。
|
||||
if detailKey == "" {
|
||||
for _, context := range d.Contexts {
|
||||
hint = context.Context
|
||||
}
|
||||
detailKey = d.Label
|
||||
confidence = d.Score
|
||||
}
|
||||
detailKey = detail.Label
|
||||
confidence = detail.Score
|
||||
break
|
||||
}
|
||||
|
||||
pass := responseJSON.IsPass()
|
||||
|
||||
// 整体建议优先取外层,缺失时回退到 antispam 场景的建议。
|
||||
suggestion := responseJSON.Result.Suggestion
|
||||
if suggestion == "" {
|
||||
suggestion = responseJSON.Result.Scenes.Antispam.Suggestion
|
||||
}
|
||||
// 建议字段整体缺失、但服务自身判定为不放行时,把整体建议补成 block:
|
||||
// 否则策略层会因「没有明细维度 + 没有建议」把它当成未命中而放行,
|
||||
// 与改造前的 fail-closed 行为不一致。
|
||||
if suggestion == "" && !pass {
|
||||
suggestion = SuggestionBlock
|
||||
}
|
||||
|
||||
return &TextCensorResponse{
|
||||
SourceName: "qiniu",
|
||||
Pass: responseJSON.IsPass(),
|
||||
ForbiddenType: formatQiniuForbiddenType(detailKey),
|
||||
Hint: hint,
|
||||
Confidence: confidence,
|
||||
RawResponse: raw,
|
||||
SourceName: "qiniu",
|
||||
Pass: pass,
|
||||
ForbiddenType: formatQiniuForbiddenType(detailKey),
|
||||
Hint: hint,
|
||||
Confidence: confidence,
|
||||
RawResponse: raw,
|
||||
Details: details,
|
||||
ProviderSuggestion: suggestion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
ForbiddenTypeContraband ForbiddenType = "contraband"
|
||||
ForbiddenTypeMeaningless ForbiddenType = "meaningless"
|
||||
ForbiddenTypeHarmful ForbiddenType = "harmful"
|
||||
// ForbiddenTypeCustomized 命中审核服务侧的自定义词库(阿里云控制台配置,
|
||||
// 七牛不支持服务端词库因此不会返回该维度)。属运营方显式配置的规则,
|
||||
// 在 policy.go 中被归入底线集合,任何档位都不放行。
|
||||
ForbiddenTypeCustomized ForbiddenType = "customized"
|
||||
)
|
||||
|
||||
func (f ForbiddenType) String() string {
|
||||
@@ -38,9 +42,25 @@ func (f ForbiddenType) String() string {
|
||||
ForbiddenTypeContraband: "违禁",
|
||||
ForbiddenTypeMeaningless: "无意义",
|
||||
ForbiddenTypeHarmful: "不良场景",
|
||||
ForbiddenTypeCustomized: "自定义词库",
|
||||
}[f]
|
||||
}
|
||||
|
||||
// LabelScore 审核服务针对单个维度(label)给出的判定与置信度。
|
||||
//
|
||||
// 各服务商都会返回「多维度 + 每个维度一个分值」的结果,过去只取首个非 normal 维度,
|
||||
// 丢掉了其余维度的信息,导致无法按维度做差异化处置(例如只拦高置信度的辱骂)。
|
||||
//
|
||||
// ⚠️ Score 已统一归一化到 0~1,与提供方无关:七牛本身即 0~1。
|
||||
// 阿里云内容安全 2.0 不返回逐标签置信度,只返回整条内容的 riskLevel,
|
||||
// 由 AliyunTextCensorParser 折算为 0~1(high=1 / medium=0.6 / low=0.3)后由该响应的
|
||||
// 所有标签共用 —— 这是有损的近似,详见 AliyunTextCensorParser 与 aliyunRiskLevelScore 的注释。
|
||||
// policy.go 的阈值因此对两家服务商都成立(对阿里云的语义为「按风险级别分级」)。
|
||||
type LabelScore struct {
|
||||
Label ForbiddenType `json:"label"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type TextCensorResponse struct {
|
||||
SourceName string `json:"source_name"`
|
||||
Pass bool `json:"pass"`
|
||||
@@ -48,6 +68,12 @@ 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)。
|
||||
ProviderSuggestion string `json:"provider_suggestion,omitempty"`
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ToJSON() []byte {
|
||||
@@ -55,10 +81,16 @@ func (r *TextCensorResponse) ToJSON() []byte {
|
||||
return jsonBytes
|
||||
}
|
||||
|
||||
func (r *TextCensorResponse) ErrorMessage() string {
|
||||
// ErrorMessageFor 生成针对指定命中维度的拒绝提示。
|
||||
//
|
||||
// 与已移除的 ErrorMessage 的区别:后者固定使用首个非 normal 维度,而实际触发拦截的
|
||||
// 可能是另一个维度(策略层按档位裁决),两者不一致时用户会看到对不上的原因。
|
||||
// 因此拦截时统一用本方法,按真正命中的维度出文案。
|
||||
// detail 为 nil(即由审核服务的整体 block 建议触发)时退化为不带维度的基础文案。
|
||||
func (r *TextCensorResponse) ErrorMessageFor(detail *LabelScore) string {
|
||||
errorMessage := "内容安全检查不通过"
|
||||
if r.ForbiddenType != "" {
|
||||
errorMessage += fmt.Sprintf(" [%s]", r.ForbiddenType.String())
|
||||
if detail != nil && detail.Label != "" {
|
||||
errorMessage += fmt.Sprintf(" [%s]", detail.Label.String())
|
||||
}
|
||||
if r.Hint != "" {
|
||||
errorMessage += fmt.Sprintf(",相关标签:%q", r.Hint)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package moderation 提供跨场景统一的内容审核守卫。
|
||||
//
|
||||
// 存在意义:提问、投票、私聊三条链路都要对用户输入做内容审核,过去各自调用
|
||||
// censor.Text 并自行解释结果,判定口径一旦调整就要改多处,极易遗漏。
|
||||
// 本包收敛为唯一入口:调审核 → 按箱主「审核严格度」裁决 → 返回是否放行。
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
)
|
||||
|
||||
// StrictnessFor 解析账号最终生效的严格度档位:
|
||||
// - 账号显式设置了档位(lax / standard / strict)→ 用该档位;
|
||||
// - 账号未设置(空值,存量账号的默认形态)→ 跟随站点默认
|
||||
// [security] default_moderation_strictness;
|
||||
// - 站点默认同样为空或非法 → 回退 censor.DefaultStrictness(严格)。
|
||||
//
|
||||
// 回退方向刻意取「严格」:配置缺失或非法时宁可从严,也不要因为识别失败
|
||||
// 把全站静默切到最松档(那等于在配置出错时悄悄放大漏放面)。
|
||||
func StrictnessFor(raw string) censor.Strictness {
|
||||
if raw != "" {
|
||||
return censor.ParseStrictness(raw)
|
||||
}
|
||||
return censor.ParseStrictness(conf.Security.DefaultModerationStrictness)
|
||||
}
|
||||
|
||||
// Check 执行一次内容审核,并按箱主严格度裁决是否放行。
|
||||
//
|
||||
// 这是提问 / 投票 / 私聊三类场景**唯一**应当使用的审核入口。
|
||||
//
|
||||
// 返回:
|
||||
// - resp:审核明细,调用方用于落 content_censor_metadata 等审计字段;裁决失败时可能为 nil。
|
||||
// - err != nil:应当拒绝本次提交,错误信息已按 lang 本地化,可直接展示给用户。
|
||||
//
|
||||
// 审核服务不可用(超时/报错)时仍为 fail-closed 拒绝提交:这是引入本能力之前就存在的
|
||||
// 行为,不在本次改造范围内(相关可用性问题另见 outputs/llm-moderation-feasibility 评审)。
|
||||
func Check(ctx context.Context, strictness censor.Strictness, lang, text string) (*censor.TextCensorResponse, error) {
|
||||
resp, err := censor.Text(ctx, text)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).
|
||||
WithField("strictness", string(strictness)).
|
||||
Error("Failed to censor text")
|
||||
return nil, errors.New(i18n.T(lang, "censor.unavailable"))
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New(i18n.T(lang, "censor.unavailable"))
|
||||
}
|
||||
|
||||
decision, detail := censor.Decide(string(strictness), resp)
|
||||
if decision.Blocked() {
|
||||
logrus.WithContext(ctx).
|
||||
WithField("strictness", string(strictness)).
|
||||
WithField("source", resp.SourceName).
|
||||
WithField("forbidden_type", resp.ForbiddenType).
|
||||
Info("Text blocked by moderation strictness policy")
|
||||
return resp, errors.New(resp.ErrorMessageFor(detail))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -210,6 +211,18 @@ func FuncMap() []template.FuncMap {
|
||||
"langName": func(lang string) string {
|
||||
return i18n.Name(lang)
|
||||
},
|
||||
|
||||
// moderationStrictness 返回账号「最终生效」的审核严格度档位(lax / standard / strict)。
|
||||
//
|
||||
// 设置页下拉框必须用它回显,不能直接用 LoggedUser.ModerationStrictness:
|
||||
// 存量账号该字段为空值(表示"跟随站点默认"),三个 <option> 的 eq 判定全部为假,
|
||||
// 浏览器会默认选中第一个选项(宽松),而实际生效的是站点默认档位(通常为严格)——
|
||||
// 界面会告诉箱主"你是宽松",与真实拦截行为相反。
|
||||
// 走 moderation.StrictnessFor 解析,保证回显与 internal/security/censor/policy.go
|
||||
// 的裁决口径是同一份事实来源(含站点默认与非法值回退)。
|
||||
"moderationStrictness": func(raw string) string {
|
||||
return string(moderation.StrictnessFor(raw))
|
||||
},
|
||||
}}
|
||||
})
|
||||
return funcMap
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# 阿里云审核通道「response data is empty」诊断报告
|
||||
|
||||
日期:2026-09-10
|
||||
场景:`@scene#15:"日常开发"` — 内容审核 / `[security] text_censor_source = aliyun`
|
||||
|
||||
---
|
||||
|
||||
## 最终落地(2026-09-10 16:40)— 已迁移至内容安全 2.0
|
||||
|
||||
三条路径中**已选定并落地「方案 A:迁移到内容安全增强版 2.0」**,同时采纳第五节提出的折中防呆
|
||||
(首次出现账号级拒绝时打一条一次性告警)。
|
||||
|
||||
### 为什么不是方案 B / C
|
||||
|
||||
- **B(升级企业实名认证)**:需要企业资质,当前账号不具备;且 1.0 的标签体系本身也已过时。
|
||||
- **C(放弃阿里云只用七牛)**:会丢掉 auto 模式后半段「七牛误报时由阿里云二次复核」的能力。
|
||||
|
||||
### 关键事实(官方文档核实)
|
||||
|
||||
| 项目 | 内容安全 1.0(旧) | 文本审核增强版 2.0(新) |
|
||||
| --- | --- | --- |
|
||||
| 接口 | `green.cn-shanghai` + `/green/text/scan` | `green-cip.cn-shanghai.aliyuncs.com` + `TextModeration` |
|
||||
| SDK | `alibaba-cloud-sdk-go`(已从依赖中移除) | `alibabacloud-go/green-20220302/v3 v3.5.4` |
|
||||
| 开通前提 | **企业实名认证**(个人账号永远 596) | **实名认证即可**(个人可开通) |
|
||||
| 计费 | — | 文本审核标准 7.5 元/万次;开通免费、按量出账 |
|
||||
| 单次上限 | — | `content` ≤ **600 字符**(超长必须分段) |
|
||||
|
||||
⚠️ **一处必须记录的事实修正**:`green-20220302` SDK 中
|
||||
`TextModerationResponseBodyData.Labels` 的内联注释列出的标签(10 个,且不含涉政)
|
||||
**是旧的、不完整的**。以官方《文本审核增强版标签处理建议参考》为准,2.0 通用服务
|
||||
**包含** `political_content`(涉政)、`violence`(暴恐)、`sexual_content`(色情)、
|
||||
`C_customized`(自定义词库)—— 即 `policy.go` 的底线四件套可完整覆盖,
|
||||
**不存在此前担心的「迁移后失去涉政拦截」缺口**。
|
||||
|
||||
### 落地改动
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `internal/security/censor/aliyun.go` | 整文件重写为 2.0:`TextModeration` + 归一化响应 + 标签映射 + 分段审核 + 一次性账号告警 |
|
||||
| 同上 | 审核场景固定 `comment_detection`(公聊评论:官方定位覆盖涉政/涉暴/涉黄底线 + 广告导流) |
|
||||
| 同上 | 超长文本按 550 字分段、段间重叠 50 字;合并口径为标签取并集、风险级别取最高 |
|
||||
| 同上 | `riskLevel` → 置信度折算 high=1 / medium=0.6 / low=0.3,供 `policy.go` 的阈值复用 |
|
||||
| 同上 | 归一化响应带 `api: "TextModeration"` 格式标记 |
|
||||
| `internal/security/censor/censor.go` | 阿里云缓存回放解析失败时**退回实时调用**,不再直接抛错 |
|
||||
| `internal/security/censor/{text,policy}.go` | 同步注释(置信度来源由 rate 改为 riskLevel 折算) |
|
||||
| `internal/security/censor/aliyun_test.go` | 新增 2.0 测试套件(分段 / 标签映射 / 多片合并 / 兼容性 / 告警判定) |
|
||||
| `internal/security/censor/policy_test.go` | 移除 1.0 格式的旧测试 |
|
||||
| `conf/app.sample.ini`、`internal/conf/static.go` | 注释改为 2.0 开通指引 |
|
||||
| `go.mod` / `go.sum` | 移除已无引用的 `alibaba-cloud-sdk-go v1.62.26` |
|
||||
|
||||
### 两个刻意的设计取舍
|
||||
|
||||
1. **格式标记 + 缓存回退**:解析器拒绝一切非 `api: "TextModeration"` 的载荷,`censor.go`
|
||||
在解析失败时退回一次实时调用。作用:迁移前 1.0 落库的缓存(若有)既不会被误判成
|
||||
「通过」(那会变成绕过审核的后门),也不会因 fail-closed 让这些历史文本在 31 天缓存
|
||||
过期前一直发不出去。
|
||||
2. **一次性账号级告警**:首次出现 596 / Forbidden / InvalidAccessKeyId 等「账号侧未就绪」
|
||||
失败时,打印一条带开通入口与授权说明的 WARN,之后不再重复 —— 零成本、无外部依赖,
|
||||
把「静默全站拒签」变成日志里一眼可见的可操作信号。
|
||||
|
||||
### 校验结果
|
||||
|
||||
- `go build -overlay=... ./...`:通过
|
||||
- `go vet ./internal/security/...`:通过
|
||||
- `go test ./...`:`internal/security/censor` 全绿;仅 `internal/db` 失败,
|
||||
且失败信息均为 `Unknown database type: ""`(需真实 PostgreSQL/MySQL),与本改动无关
|
||||
- `gofmt -l`:仅 `censor.go`(改动前既有的空格缩进,按约定未动)
|
||||
|
||||
### 仍需你处理
|
||||
|
||||
1. 在阿里云**内容安全 2.0 控制台**开通「内容审核增强版」按量付费;RAM 子账号授予
|
||||
`AliyunYundunGreenWebFullAccess`。开通前:`auto` 模式只走七牛;`aliyun` 模式仍会 fail-closed。
|
||||
2. CHANGELOG 尚未记入(见文末第五节)。
|
||||
|
||||
---
|
||||
|
||||
## 〇、结论速览(诊断阶段,2026-09-10 16:05)
|
||||
|
||||
> ✅ **根因(两层)**
|
||||
> 1. 直接原因:**阿里云账号未开通「内容安全 1.0」** → `code=596`。
|
||||
> 2. 深层原因:**「内容安全 1.0」的开通前提是账号已完成企业实名认证**(官方文档明文)。
|
||||
> 若该账号是**个人实名认证**,则 1.0 **永远无法开通**,无论在产品页怎么点。
|
||||
|
||||
诊断改动落地后,下一条日志立即给出了答案:
|
||||
|
||||
```
|
||||
error="text scan (request body={\"scenes\":[\"antispam\"],\"tasks\":[{\"content\":\"测试!\"}]}):
|
||||
aliyun rejected the request: code=596
|
||||
msg=\"You have not opened Yundun Content Moderation Service, please go to the page
|
||||
(https://www.aliyun.com/product/lvwang) to open our service after login Ali cloud console\"
|
||||
request_id=01A08A4B-CCE2-5AAF-9329-A0C51C0F5060
|
||||
raw={\"code\":596,\"httpStatusCode\":200,\"msg\":\"...\",\"success\":false}"
|
||||
```
|
||||
|
||||
三点关键信息:
|
||||
|
||||
1. **`httpStatusCode: 200` + `success: false` + 无 `data`** —— 精确印证了第二节的推断:
|
||||
阿里云在**请求被拒时依然返回 HTTP 200**,所以旧代码只会报出「`data` 为空」这一句废话。
|
||||
2. `code=596` 是**账号级「服务未开通」**拒绝,与请求体、签名、Content-Type、RAM 权限均无关
|
||||
(RAM 权限不足会返回 403/Forbidden,不是 596)。
|
||||
3. 用户按提示点开后仍报 596,原因见下:**开通入口与认证门槛**。
|
||||
|
||||
### 开通门槛对照(官方文档原文)
|
||||
|
||||
| 服务 | 开通前提条件 | 个人实名认证可否 |
|
||||
| --- | --- | --- |
|
||||
| **内容安全 1.0**(`green`, 2018-05-09 — 本项目在用) | **需要账号完成企业认证**(`document_detail/69806.html`,2026-07-23 更新) | ❌ 不可 |
|
||||
| 内容安全增强版 2.0(`green-cip`, 2022-03-02) | 需要账号完成**实名认证**(`document_detail/477720.html`) | ✅ 可 |
|
||||
|
||||
另附两点排查结论:
|
||||
|
||||
- 用户点开的 `https://www.aliyun.com/product/lvwang` **只是产品介绍页**(现为「AI 安全护栏」聚合页),
|
||||
点不出「一键开通 1.0」。1.0 的正确开通入口是:
|
||||
`https://common-buy.aliyun.com/?commodityCode=cdi#/open`。
|
||||
- 但即使换到正确入口,个人认证账号依然会被「企业认证」前提卡住 → 596 不会消失。
|
||||
|
||||
### 三条可选路径
|
||||
|
||||
| 方案 | 代价 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **A. 改用「内容安全增强版 2.0」** | 🟠 需改代码(中等) | 个人认证即可开通。需换成 `green-cip.<region>.aliyuncs.com` + `TextModeration` API + 新 SDK(`green-20220302`),并**重做风险标签映射**(2.0 标签体系与 1.0 完全不同:`pornographic_adult` / `inappropriate_profanity` / `pt_to_contact` …),策略层 `policy.go` 的维度清单需同步重写。 |
|
||||
| **B. 把该账号升级为企业实名认证** | 需企业资质 | **代码零改动**,升级后到正确入口开通 1.0 即可。 |
|
||||
| **C. 放弃阿里云通道,只用七牛** | 功能损失 | 置 `text_censor_source = auto` / `qiniu`。阿里云通道的原始用途是「七牛误报时的二次复核」,放弃后该能力消失。 |
|
||||
|
||||
📌 **副产品结论**:第三节第 2 项(`SetContentType(requests.Json)`)**已被证伪**——它不是本次根因。
|
||||
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
|
||||
|
||||
📌 **副产品结论**:第三节第 2 项(`SetContentType(requests.Json)`)**已被证伪**——它不是本次根因。
|
||||
改动保留,但代码注释已改成「按接口契约对齐」而非「修 bug」,避免后人误解。
|
||||
|
||||
## 一、报障现象
|
||||
|
||||
```
|
||||
ERRO Failed to censor text censor_source=aliyun error="response data is empty"
|
||||
ERRO Failed to censor text error="no censor source available" strictness=lax
|
||||
```
|
||||
|
||||
两条同时出现 = 链上**所有**通道都没给出结论 → `censor.Text` 返回错误 → `moderation.Check` fail-closed 拒签。
|
||||
|
||||
> 🔴 **严重性说明**:fail-closed 是本项目既有设计,但推论是 —— **只要显式选定的审核通道坏掉(账号未开通 / 欠费停服 / RAM 权限不足),整站的「发布提问/回答 + 私信」会全部被拒**,且现象只在日志可见,前端只显示「审核服务不可用」。这次报障正是这种形态。
|
||||
|
||||
## 二、根因取证
|
||||
|
||||
### 2.1 请求构造:不是本项目引入的问题(已验证)
|
||||
|
||||
| 比对对象 | 结论 |
|
||||
| --- | --- |
|
||||
| 上游 `NekoWheel/NekoBox` 的 `internal/security/censor/aliyun.go` | 与本仓 **逐字节一致** |
|
||||
| 阿里云官方 Go 示例(`help.aliyun.com/document_detail/330839.htm`) | `green.CreateTextScanRequest()` + `SetContent(json)`,body 形状 `{"scenes":["antispam"],"tasks":[{"content":...}]}` **一致** |
|
||||
|
||||
→ 请求的 URL、body 形状、签名流程都与官方参考实现相同,**不是本次改造引入的偏差**。
|
||||
|
||||
### 2.2 能产出该错误消息的唯一组合(已验证,走通 SDK 源码)
|
||||
|
||||
逐段读过 `[email protected]` 后,可以确定报出 `response data is empty` 必须同时满足:
|
||||
|
||||
| 条件 | 依据 |
|
||||
| --- | --- |
|
||||
| HTTP 状态是 2xx | `sdk/responses/response.go:44` 非 2xx 会被 `Unmarshal` 直接转成 `ServerError` → 走 `errors.Wrap(err,"text scan")`,不会是该消息 |
|
||||
| 响应体是**合法 JSON** | `AliyunTextCensorParser` 的 `json.Unmarshal` 失败会报 `unmarshal response` |
|
||||
| 顶层 `data` 为空数组 / 缺省 / null | `len(responseJSON.Data) == 0` 才命中该分支 |
|
||||
|
||||
→ 即:**阿里云回了「HTTP 200 + 合法 JSON + 无 data」**。
|
||||
|
||||
顺带排除一个可疑点:`green.EndpointMap` **不含 `cn-shanghai`**,但端点解析会一路回落到
|
||||
`GetEndpointRules` → `green.cn-shanghai.aliyuncs.com`,**地址是正确的**。
|
||||
|
||||
### 2.3 顺带发现的确定缺陷(🟠 P1,非本次根因,但让根因不可见)
|
||||
|
||||
旧 parser 在 `len(Data)==0` 时只报 `response data is empty`,**把顶层 `code` / `msg` / `requestId` 全部丢掉**——
|
||||
而这正是唯一能区分「签名错 / 权限不足 / 欠费 / 服务未开通 / 请求体不合契约」的线索。
|
||||
这不是「阿里云用不了」的原因,但它是「为什么查不下去」的原因。
|
||||
|
||||
## 三、已落地改动
|
||||
|
||||
`internal/security/censor/aliyun.go`、`internal/security/censor/policy_test.go`
|
||||
|
||||
| # | 改动 | 性质 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 抽出 `newTextScanRequest(text)`,便于测试断言请求形态 | 重构 |
|
||||
| 2 | **显式 `req.SetContentType(requests.Json)`** | 🟡 **假设性修复**,见下方说明 |
|
||||
| 3 | 顶层 `code != 0 && != 200` → 新分支 `aliyun rejected the request: code=.. msg=.. request_id=.. raw=..` | ✅ 确定改进(与旧「data 为空」同为错误返回,上层控制流不变) |
|
||||
| 4 | `data` 为空 / JSON 解析失败时,错误里带上 `code/msg/requestId` + 原始响应摘要(≤512 字符、按字符截断) | ✅ 确定改进 |
|
||||
| 5 | `Censor()` 中 SDK 返回 err 时补 `status` + `body`;`!IsSuccess()` 分支同样带 status/body | ✅ 确定改进 |
|
||||
| 6 | `data[].code != 200`(任务级失败)**语义不变**(仍 fail-closed 置 block),但在真正决定 fail-closed 处补一条 Warn(code/msg/taskId/requestId) | ✅ 确定改进 |
|
||||
| 7 | 修掉 `Censor()` 上错误的文档链接(原指向七牛 7260 → 阿里云 70439) | 文档 |
|
||||
| 8 | 新增 5 个测试:请求形态+Content-Type、顶层 code 拒绝、空 data、非法 JSON、摘要截断 | 测试 |
|
||||
|
||||
**关于第 2 项(Content-Type)**:SDK 对 ROA 请求的**默认回退值是 `application/octet-stream`**
|
||||
(`sdk/auth/roa_signature_composer.go:88`),而 `/green/text/scan` 的接口契约是 JSON 请求体。
|
||||
该改动属**按接口契约对齐**(对已正常工作的环境无副作用)。
|
||||
> ⚠️ 曾假设它是本例根因,**已于 2026-09-10 15:50 证伪**(真实根因是 code=596 未开通服务)。
|
||||
> 保留理由仅为「显式声明正确类型优于依赖 SDK 兜底值」,代码注释已同步更正。
|
||||
|
||||
注意它必须在 `client.TextScan` 之前设置(`Content-Type` 参与 ROA 签名串)。
|
||||
|
||||
### 校验结果
|
||||
|
||||
- `gofmt -l internal/security/censor/`:仅列出 `censor.go`、`text_test.go`(升级前既有的空格缩进,按约定未动)
|
||||
- `go build -overlay=... ./...`:通过
|
||||
- `go vet ./internal/security/...`:通过
|
||||
- `go test ./internal/security/... ./internal/template/...`:全绿
|
||||
|
||||
## 四、处置(已确认后)
|
||||
|
||||
1. ⛔ **(已作废,见文首「最终落地」)** 原计划:到阿里云开通「内容安全 1.0」按量付费。
|
||||
实际已确认个人实名认证账号**无法**开通 1.0(企业认证前提),故改为迁移到 2.0。
|
||||
2. ⚠️ **先恢复线上可用性**:开通动作需要时间。在开通完成前,把 `[security] text_censor_source`
|
||||
改回 `auto`(七牛优先)或 `qiniu`,否则整站发布/私信会持续被 fail-closed 拒绝。
|
||||
3. 开通后切回 `aliyun` 再验证一次;届时日志若仍报错,`code` / `msg` / `requestId` 会直接给出下一步。
|
||||
|
||||
### 为同类问题补的防呆(已落地)
|
||||
|
||||
`conf/app.sample.ini` 的 `text_censor_source` 注释里补上了这段经验:
|
||||
|
||||
```
|
||||
; ⚠️ 配好凭据 ≠ 可用:阿里云通道还必须在阿里云控制台开通
|
||||
; 「内容安全 1.0」按量付费(https://www.aliyun.com/product/lvwang,需勾选协议点「立即开通」),
|
||||
; 否则接口返回 code=596 "You have not opened Yundun Content Moderation Service",
|
||||
; 审核必然失败 → fail-closed 拒绝全部提交(整站发不出提问/回答/私信)。
|
||||
; 用 RAM 子账号调用时还需授予内容安全(Green)权限;欠费停服同理。
|
||||
; 开通完成前建议先用 auto / qiniu,避免线上不可用。
|
||||
; 审核失败时日志会打印阿里云返回的 code / msg / requestId 便于定位。
|
||||
```
|
||||
|
||||
## 五、待你决策
|
||||
|
||||
1. **是否加「启动预检」**(🟠 P2,现在证据更充分了):`text_censor_source` 显式选定某个通道时,
|
||||
启动阶段发一次探活请求,失败则告警或直接拒启。
|
||||
- 收益:把「静默全站拒签」提前到启动时暴露——本次故障正是这个形态(用户是先发现发不出内容,才去看日志);
|
||||
- 成本:按条计费产生一次调用 + 启动强依赖外部网络;
|
||||
- 折中方案:不做网络预检,改为在**首次**出现账号级拒绝(如 596 / 403)时打一条一次性、带处置指引的显式告警
|
||||
(复用 `warnNoCensorCredentials` 的 `sync.Once` 模式)——零成本、无外部依赖。
|
||||
2. **CHANGELOG 仍未记入**(此前遗留):按项目惯例应在 `dev/changelog-source.md` 顶部补条目并跑
|
||||
`python dev/gen_changelog.py` 重新生成 `static/tamabox.changelogs.json`。档位口径已定(strict + auto),可以写了。
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
@@ -100,17 +100,10 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
|
||||
return
|
||||
}
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up text")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetErrorFlash(censorResponse.ErrorMessage())
|
||||
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
|
||||
// (提问 / 投票 / 私聊共用同一份判定,见 internal/security/moderation)。
|
||||
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Content); err != nil {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
@@ -167,17 +160,9 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
|
||||
|
||||
followUpID := uint(ctx.ParamInt("followUpID"))
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Answer)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up answer")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetErrorFlash(censorResponse.ErrorMessage())
|
||||
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
|
||||
if _, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
+14
-23
@@ -36,7 +36,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/storage"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
@@ -343,6 +343,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
||||
return
|
||||
}
|
||||
|
||||
// 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness),
|
||||
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
|
||||
// 在此解析一次,供下方私聊分支与普通提问分支复用,保证同一请求的判定口径一致。
|
||||
strictness := moderation.StrictnessFor(pageUser.ModerationStrictness)
|
||||
|
||||
// ---------- 私聊处理 ----------
|
||||
if f.StartChat == "true" && ctx.IsLogged {
|
||||
|
||||
@@ -431,17 +436,10 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
||||
return
|
||||
}
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetError(errors.New(censorResponse.ErrorMessage()), f)
|
||||
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
|
||||
// (审核源不可用时 moderation.Check 内部仍为 fail-closed)。
|
||||
if _, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), f.Content); err != nil {
|
||||
ctx.SetError(err, f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
@@ -571,18 +569,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
||||
return
|
||||
}
|
||||
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), content)
|
||||
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行。
|
||||
// 返回的 censorResponse 供下方落 content_censor_metadata(审计留痕)。
|
||||
censorResponse, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行,
|
||||
// 避免审核服务宕机期间提问内容免审直接公开。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
errorMessage := censorResponse.ErrorMessage()
|
||||
ctx.SetError(errors.New(errorMessage), f)
|
||||
ctx.SetError(err, f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
@@ -165,17 +165,11 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
|
||||
answer := f.Answer
|
||||
|
||||
// 🚨 Content security check.
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), answer)
|
||||
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
|
||||
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
|
||||
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), answer)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
errorMessage := censorResponse.ErrorMessage()
|
||||
ctx.SetError(errors.New(errorMessage), f)
|
||||
ctx.SetError(err, f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
)
|
||||
|
||||
// UpdateModerationStrictness 保存当前账号的「内容审核严格度」档位。
|
||||
//
|
||||
// 该档位同时作用于提问、投票、私聊三条链路(由 internal/security/moderation.Check
|
||||
// 统一裁决),因此只需在账号侧存一份。
|
||||
func UpdateModerationStrictness(ctx context.Context, f form.UpdateModerationStrictness) {
|
||||
// 反序列化失败(如请求体超限)时零值 Strictness="" 会被当成「跟随站点默认」写入,
|
||||
// 把账号已选择的档位静默改回默认档 —— 先中止,不触碰现有设置。
|
||||
if abortOnFormError(ctx, returnTab(ctx)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非法取值一律拒绝保存:宁可保留原档位,也不要写入一个解释不了的中间状态。
|
||||
if !censor.IsValidStrictness(f.Strictness) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessInvalid"))
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Users.UpdateModerationStrictness(ctx.Request().Context(), ctx.User.ID, f.Strictness); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update moderation strictness")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.moderationStrictnessUpdated"))
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
}
|
||||
+39
-25
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
votepkg "github.com/tamakyi/TamaBox/route/vote"
|
||||
)
|
||||
|
||||
@@ -58,17 +59,13 @@ type voteForm struct {
|
||||
}
|
||||
|
||||
// censorCheck 对单段文本做内容审核。
|
||||
// fail-closed:审核服务异常时拒绝发布(与提问/回答流程一致);
|
||||
// 未开启 EnableTextCensor 时 censor.Text 直接放行,不影响开发环境。
|
||||
//
|
||||
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
|
||||
// 投票发起人本人的「审核严格度」档位决定。
|
||||
// fail-closed:审核服务异常时拒绝发布;未开启 EnableTextCensor 时底层直接放行。
|
||||
func censorCheck(ctx context.Context, text string) error {
|
||||
resp, err := censor.Text(ctx.Request().Context(), text)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
}
|
||||
if !resp.Pass {
|
||||
return errors.New(resp.ErrorMessage())
|
||||
}
|
||||
return nil
|
||||
_, 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。
|
||||
@@ -159,12 +156,9 @@ func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
|
||||
// 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明)
|
||||
var censorMeta datatypes.JSON
|
||||
if !draft {
|
||||
titleCensor, err := censor.Text(ctx.Request().Context(), title)
|
||||
titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(ctx.User.ModerationStrictness), ctx.Lang(), title)
|
||||
if err != nil {
|
||||
return nil, errors.New("内容审核服务暂时不可用,请稍后再试")
|
||||
}
|
||||
if !titleCensor.Pass {
|
||||
return nil, errors.New(titleCensor.ErrorMessage())
|
||||
return nil, err
|
||||
}
|
||||
censorMeta = datatypes.JSON(titleCensor.ToJSON())
|
||||
if description != "" {
|
||||
@@ -996,32 +990,52 @@ func validateForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// voteOwnerStrictness 读取投票发起人本人的「审核严格度」档位。
|
||||
//
|
||||
// 这里不用 ctx.User 的原因:发布动作未必由发起人本人触发(后台管理、补发布等场景),
|
||||
// ctx.User 有可能是操作者而非发起人,直接沿用会导致「按别人的档位审核别人箱子的内容」。
|
||||
// 取不到发起人时回退最严档位 —— 宁可沿用严格判定,也不要因为查不到就把判定放宽。
|
||||
func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness {
|
||||
owner, err := db.Users.GetByID(ctx.Request().Context(), ownerID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).WithField("owner_id", ownerID).
|
||||
Error("Failed to get vote owner for moderation strictness")
|
||||
return censor.StrictnessStrict
|
||||
}
|
||||
return moderation.StrictnessFor(owner.ModerationStrictness)
|
||||
}
|
||||
|
||||
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
|
||||
// 草稿保存阶段跳过了审核,发布(对外公开)时必须补齐,确保公开内容受管控。
|
||||
func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
// 同一次发布的全部可见文本必须用「同一个」档位裁决:这里只解析一次发起人的档位并复用。
|
||||
// 否则标题走发起人档位、描述/选项走 ctx.User 档位(censorCheck 内部读 ctx.User),
|
||||
// 一旦发布动作不是发起人本人触发,同一条投票的两段文本就会落到两个账号的松紧档上。
|
||||
strictness := voteOwnerStrictness(ctx, vote.UserID)
|
||||
check := func(text string) error {
|
||||
_, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), text)
|
||||
return err
|
||||
}
|
||||
|
||||
if vote.Title != "" {
|
||||
resp, err := censor.Text(ctx.Request().Context(), vote.Title)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
}
|
||||
if !resp.Pass {
|
||||
return errors.New(resp.ErrorMessage())
|
||||
if err := check(vote.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if vote.Description != "" {
|
||||
if err := censorCheck(ctx, vote.Description); err != nil {
|
||||
if err := check(vote.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
for _, o := range options {
|
||||
if err := censorCheck(ctx, o.Content); err != nil {
|
||||
if err := check(o.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
for _, f := range fields {
|
||||
if err := censorCheck(ctx, f.Label); err != nil {
|
||||
if err := check(f.Label); err != nil {
|
||||
return err
|
||||
}
|
||||
// 预置选项(select/radio/checkbox/rank)也纳入审核
|
||||
@@ -1029,7 +1043,7 @@ func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
|
||||
if fo == "" {
|
||||
continue
|
||||
}
|
||||
if err := censorCheck(ctx, fo); err != nil {
|
||||
if err := check(fo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
@@ -57,16 +58,19 @@ func InvalidateResultsCache(voteID uint) {
|
||||
resultsCache.Delete(strconv.FormatUint(uint64(voteID), 10))
|
||||
}
|
||||
|
||||
// censorCheck 对开放文本做内容审核(fail-closed;EnableTextCensor=false 时自动放行)。
|
||||
func censorCheck(ctx context.Context, lang, text string) error {
|
||||
resp, err := censor.Text(ctx.Request().Context(), text)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T(lang, "censor.unavailable"))
|
||||
// censorCheck 对开放文本做内容审核。
|
||||
//
|
||||
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
|
||||
// 投票发起人的「审核严格度」档位决定,而不是无条件采信审核服务的判定。
|
||||
// 审核服务异常仍为 fail-closed 拒绝提交;EnableTextCensor=false 时底层自动放行。
|
||||
// owner 为 nil 时回退到最严档位,避免发起人为空就把或从宽放行。
|
||||
func censorCheck(ctx context.Context, owner *db.User, lang, text string) error {
|
||||
strictness := censor.StrictnessStrict
|
||||
if owner != nil {
|
||||
strictness = moderation.StrictnessFor(owner.ModerationStrictness)
|
||||
}
|
||||
if !resp.Pass {
|
||||
return errors.New(resp.ErrorMessage())
|
||||
}
|
||||
return nil
|
||||
_, err := moderation.Check(ctx.Request().Context(), strictness, lang, text)
|
||||
return err
|
||||
}
|
||||
|
||||
// blockWordCheck 校验开放文本是否命中投票发起人的全局屏蔽词表
|
||||
@@ -968,7 +972,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
return nil, err
|
||||
}
|
||||
// 开放文本内容审核(fail-closed)
|
||||
if err := censorCheck(ctx, lang, custom); err != nil {
|
||||
if err := censorCheck(ctx, owner, lang, custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
formData[f.Label] = custom
|
||||
@@ -1004,7 +1008,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
return nil, err
|
||||
}
|
||||
// 开放文本内容审核(fail-closed)
|
||||
if err := censorCheck(ctx, lang, custom); err != nil {
|
||||
if err := censorCheck(ctx, owner, lang, custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chosen = append(chosen, custom)
|
||||
@@ -1088,7 +1092,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
return nil, err
|
||||
}
|
||||
// 开放文本内容审核(fail-closed)
|
||||
if err := censorCheck(ctx, lang, val); err != nil {
|
||||
if err := censorCheck(ctx, owner, lang, val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
formData[f.Label] = val
|
||||
@@ -1110,7 +1114,7 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
return nil, err
|
||||
}
|
||||
// 开放文本内容审核(fail-closed)
|
||||
if err := censorCheck(ctx, lang, custom); err != nil {
|
||||
if err := censorCheck(ctx, owner, lang, custom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.CustomText = custom
|
||||
|
||||
@@ -39,46 +39,47 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="uk-container uk-container-small uk-margin-top">
|
||||
<h2 class="uk-text-center">
|
||||
<div class="uk-container uk-container-small uk-margin-top" data-page-node-id="VJALGxXgA3LjvngL2idiPd">
|
||||
{{template "base/alert" .}}
|
||||
<h2 class="uk-text-center" data-page-node-id="4ZhL4EHH1LeTPtFA5wpgbh">
|
||||
{{ if .IsOwner }}{{t .LangCode "chat.withAsker"}}{{ else }}{{t .LangCode "chat.withUser" .PageUser.Name}}{{ end }}
|
||||
</h2>
|
||||
<p class="uk-text-center uk-text-meta">
|
||||
<p class="uk-text-center uk-text-meta" data-page-node-id="NNKGVnXP49GNhyCvho0PAw">
|
||||
{{ if .Conversation.IsClosed }}
|
||||
<span class="uk-label uk-label-danger">{{t .LangCode "chat.closed"}}</span>
|
||||
<span class="uk-label uk-label-danger" data-page-node-id="QuKE1FE9CzlNmbQOXq68zA">{{t .LangCode "chat.closed"}}</span>
|
||||
{{ else }}
|
||||
{{t .LangCode "chat.remaining" (sub .Conversation.MaxMessages .Conversation.MessageCount) .Conversation.MaxMessages}}
|
||||
{{ end }}
|
||||
</p>
|
||||
|
||||
<div class="uk-card uk-card-default uk-card-body uk-margin uk-padding-small">
|
||||
<ul class="uk-list uk-list-divider">
|
||||
<div class="uk-card uk-card-default uk-card-body uk-margin uk-padding-small" data-page-node-id="Ngi6SbUffTDHaNpDFcCzjT">
|
||||
<ul class="uk-list uk-list-divider" data-page-node-id="VrxQ4Bp6BfmklKAkEqykZf">
|
||||
{{ range .Messages }}
|
||||
{{ $isMyMessage := eq .SenderID $.LoggedUser.ID }}
|
||||
<li class="uk-margin-small-bottom">
|
||||
<div class="uk-flex {{ if $isMyMessage }}uk-flex-right{{ else }}uk-flex-left{{ end }}">
|
||||
<li class="uk-margin-small-bottom" data-page-node-id="LSFm4EINd1XxnwZSFbAlji">
|
||||
<div class="uk-flex {{ if $isMyMessage }}uk-flex-right{{ else }}uk-flex-left{{ end }}" data-page-node-id="afhl9aZDsknmAlut7imPnp">
|
||||
<div class="uk-card uk-card-hover uk-card-body uk-padding-small uk-margin-small-left uk-margin-small-right"
|
||||
style="max-width: 75%; border-radius: 12px; background-color: {{ if $isMyMessage }}#e6ffe6{{ else }}#f0f8ff{{ end }};">
|
||||
<div class="uk-flex uk-flex-middle uk-margin-small-bottom">
|
||||
<span class="uk-icon" uk-icon="user"></span>
|
||||
<strong class="uk-margin-small-left">
|
||||
style="max-width: 75%; border-radius: 12px; background-color: {{ if $isMyMessage }}#e6ffe6{{ else }}#f0f8ff{{ end }};" data-page-node-id="zPuwctcKF6xPKnwQKaYADD">
|
||||
<div class="uk-flex uk-flex-middle uk-margin-small-bottom" data-page-node-id="NuHyY57VYOUYQWdpRPqeS6">
|
||||
<span class="uk-icon" uk-icon="user" data-page-node-id="ctN3HeenrU4wiv39zSfOgY"></span>
|
||||
<strong class="uk-margin-small-left" data-page-node-id="V7t5pBo43sNBv1a6rvp0jv">
|
||||
{{ if $isMyMessage }}{{t $.LangCode "chat.me"}}{{ else }}{{t $.LangCode "chat.other"}}{{ end }}
|
||||
</strong>
|
||||
<span class="uk-text-meta uk-margin-small-left" style="font-size: 0.75rem;">
|
||||
<span class="uk-text-meta uk-margin-small-left" style="font-size: 0.75rem;" data-page-node-id="JidfstvpABpsDBPRL3WY6I">
|
||||
{{ .CreatedAt.Format "01-02 15:04" }}
|
||||
</span>
|
||||
</div>
|
||||
{{ if and $.IsOwner $.IsBlocked (eq .SenderID $.Conversation.AskerID) }}
|
||||
<p class="uk-margin-remove"><span class="uk-text-muted uk-text-italic">{{t $.LangCode "chat.userBlocked"}}</span></p>
|
||||
<p class="uk-margin-remove" data-page-node-id="LSDlwCDxP0hWTAw2tjVSvA"><span class="uk-text-muted uk-text-italic" data-page-node-id="nTFMPXU5SSa8Affx4vPAm8">{{t $.LangCode "chat.userBlocked"}}</span></p>
|
||||
{{ else }}
|
||||
{{ if .Content }}
|
||||
<p class="uk-margin-remove">{{ .Content }}</p>
|
||||
<p class="uk-margin-remove" data-page-node-id="KMOMzUxidmbA4tMskOa1z2">{{ .Content }}</p>
|
||||
{{ end }}
|
||||
{{ range (index $.MessageImages .ID) }}
|
||||
<div class="uk-margin-small-top">
|
||||
<div class="uk-margin-small-top" data-page-node-id="sB1P5iHpDXEjFBdUymfkxz">
|
||||
<img src="{{ .URL }}" alt="{{ .FileName }}"
|
||||
style="max-width: 200px; max-height: 200px; border-radius: 8px; cursor: pointer;"
|
||||
onclick="openLightbox('{{ .URL }}', '{{ .FileName }}')" />
|
||||
onclick="openLightbox('{{ .URL }}', '{{ .FileName }}')" data-page-node-id="siJUfJdLhG2zPrXzGC1KZ3" />
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -90,58 +91,58 @@
|
||||
</div>
|
||||
|
||||
{{ if .CanSend }}
|
||||
<div class="uk-card uk-card-default uk-card-body uk-margin-top">
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/send" enctype="multipart/form-data">
|
||||
<div class="uk-card uk-card-default uk-card-body uk-margin-top" data-page-node-id="ZAXQKcFkCuSMdF0fOQ5dYH">
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/send" enctype="multipart/form-data" data-page-node-id="9xSD9GJlKfqAkKPgVBuOAn">
|
||||
{{ .CSRFTokenHTML }}
|
||||
<textarea name="content" class="uk-textarea" rows="3" placeholder="{{t .LangCode "chat.input"}}" maxlength="{{ .ChatMessageLimit }}"></textarea>
|
||||
<textarea name="content" class="uk-textarea" rows="3" placeholder="{{t .LangCode "chat.input"}}" maxlength="{{ .ChatMessageLimit }}">{{ .Content }}</textarea>
|
||||
|
||||
<div class="uk-margin-small-top" style="display: flex; align-items: center; gap: 10px;">
|
||||
<div uk-form-custom>
|
||||
<a href="#" class="uk-icon-link" uk-icon="image"></a>
|
||||
<span style="font-size: 13px; color: #666;">{{t .LangCode "chat.addImage"}}</span>
|
||||
<input type="file" name="image" accept="image/*">
|
||||
<div class="uk-margin-small-top" style="display: flex; align-items: center; gap: 10px;" data-page-node-id="jtjlLfDzoOkYDKdiOzZ6vl">
|
||||
<div uk-form-custom data-page-node-id="elqLK3luiM7Q4FamlGb3pV">
|
||||
<a href="#" class="uk-icon-link" uk-icon="image" data-page-node-id="uZN2LSBiXgLUmffpnhXWH6"></a>
|
||||
<span style="font-size: 13px; color: #666;" data-page-node-id="TKZ0z7rbdw4u4YcNs6VmAv">{{t .LangCode "chat.addImage"}}</span>
|
||||
<input type="file" name="image" accept="image/*" data-page-node-id="37rmGJRHggLssaZUDxV4FV">
|
||||
</div>
|
||||
<span class="uk-text-meta" id="file-name"></span>
|
||||
<span class="uk-text-meta" id="file-name" data-page-node-id="EBlY0CCL4DtDxgbNBrlLaw"></span>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin-small-top uk-text-right">
|
||||
<button type="submit" class="uk-button uk-button-primary">{{t .LangCode "chat.send"}}</button>
|
||||
<div class="uk-margin-small-top uk-text-right" data-page-node-id="Tr2UTO7GlEX9LFxDVo1RlK">
|
||||
<button type="submit" class="uk-button uk-button-primary" data-page-node-id="IH6ljh8Db7Wo4xfsEz9P5w">{{t .LangCode "chat.send"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="uk-text-center uk-margin-top">
|
||||
<p class="uk-text-warning">{{t .LangCode "chat.closedNoSend"}}</p>
|
||||
<a href="/_/{{.PageUser.Domain}}" class="uk-button uk-button-default">{{t .LangCode "common.backHome"}}</a>
|
||||
<div class="uk-text-center uk-margin-top" data-page-node-id="ZfG2zLwjExCXoA9YaDrqCn">
|
||||
<p class="uk-text-warning" data-page-node-id="YKwFufPMOEUVlUvX4yBHAB">{{t .LangCode "chat.closedNoSend"}}</p>
|
||||
<a href="/_/{{.PageUser.Domain}}" class="uk-button uk-button-default" data-page-node-id="iVHgoa52j3R1yuhJWbLPXF">{{t .LangCode "common.backHome"}}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .IsOwner }}
|
||||
<div class="uk-margin-top uk-text-center">
|
||||
<a class="uk-button uk-button-{{ if .IsBlocked }}default{{ else }}warning{{ end }} uk-button-small" href="#">
|
||||
<div class="uk-margin-top uk-text-center" data-page-node-id="YuzOGI8cGxQAGXx3EsCnKq">
|
||||
<a class="uk-button uk-button-{{ if .IsBlocked }}default{{ else }}warning{{ end }} uk-button-small" href="#" data-page-node-id="sG8R1GMpGC52pNeUFJEkTD">
|
||||
{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}
|
||||
</a>
|
||||
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click">
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h3 class="uk-card-title">{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}?</h3>
|
||||
<p>{{ if .IsBlocked }}{{t .LangCode "chat.confirmUnblock"}}{{ else }}{{t .LangCode "chat.confirmBlock"}}{{ end }}</p>
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/block">
|
||||
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click" data-page-node-id="iW9QyGsZ1XWa4Bq7Ch0805">
|
||||
<div class="uk-card uk-card-default uk-card-body" data-page-node-id="UebLy6cpqCXeiPoX1JLRcE">
|
||||
<h3 class="uk-card-title" data-page-node-id="qVRELxzwy4UR2ANNo6PNCi">{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}?</h3>
|
||||
<p data-page-node-id="DrquhcYxj4Dkx7yGjoed4o">{{ if .IsBlocked }}{{t .LangCode "chat.confirmUnblock"}}{{ else }}{{t .LangCode "chat.confirmBlock"}}{{ end }}</p>
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/block" data-page-node-id="gN7rHfCpdQ5b3HR05NNiaI">
|
||||
{{ .CSRFTokenHTML }}
|
||||
<button type="submit" class="uk-button uk-button-{{ if .IsBlocked }}primary{{ else }}danger{{ end }}">
|
||||
<button type="submit" class="uk-button uk-button-{{ if .IsBlocked }}primary{{ else }}danger{{ end }}" data-page-node-id="Bv6TCmw3NFsNXmUfGQ0s9K">
|
||||
{{t .LangCode "chat.confirm"}}{{ if .IsBlocked }}{{t .LangCode "chat.unblock"}}{{ else }}{{t .LangCode "chat.block"}}{{ end }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="uk-button uk-button-danger uk-button-small" href="#">{{t .LangCode "chat.delete"}}</a>
|
||||
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click">
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h3 class="uk-card-title">{{t .LangCode "qitem.danger"}}</h3>
|
||||
<p>{{t .LangCode "chat.deleteConfirm"}}</p>
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/delete">
|
||||
<a class="uk-button uk-button-danger uk-button-small" href="#" data-page-node-id="LD7jP6dOcVE2UuLTnEuNCZ">{{t .LangCode "chat.delete"}}</a>
|
||||
<div class="uk-dropbar uk-dropbar-top" uk-drop="stretch: x; mode: click" data-page-node-id="hmBJTLVxTGAh3eaZJFbd4u">
|
||||
<div class="uk-card uk-card-default uk-card-body" data-page-node-id="vdlAgEiEKDXhfsLG3U6qt3">
|
||||
<h3 class="uk-card-title" data-page-node-id="uELuLnRDZnyl09lrlJGgpG">{{t .LangCode "qitem.danger"}}</h3>
|
||||
<p data-page-node-id="vCyeXBe3K710pdnGTnCpBl">{{t .LangCode "chat.deleteConfirm"}}</p>
|
||||
<form method="post" action="/_/{{.PageUser.Domain}}/chat/{{.Conversation.Token}}/delete" data-page-node-id="83mnoGpvXiYyvWETOUlsna">
|
||||
{{ .CSRFTokenHTML }}
|
||||
<button type="submit" class="uk-button uk-button-danger">{{t .LangCode "qitem.deleteConfirmBtn"}}</button>
|
||||
<button type="submit" class="uk-button uk-button-danger" data-page-node-id="XjUAxGnN5V8BrLG9n4zVcX">{{t .LangCode "qitem.deleteConfirmBtn"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,10 +152,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 自定义灯箱结构 -->
|
||||
<div id="customLightbox" class="custom-lightbox" onclick="closeLightbox()">
|
||||
<span class="close-btn" onclick="closeLightbox()">×</span>
|
||||
<img id="lightboxImg" src="" alt="" />
|
||||
<div id="lightboxCaption" class="caption"></div>
|
||||
<div id="customLightbox" class="custom-lightbox" onclick="closeLightbox()" data-page-node-id="xmqzFCKLG0BSnAeRPCQBEP">
|
||||
<span class="close-btn" onclick="closeLightbox()" data-page-node-id="UVNFSFTxluTqzyZ4rPyYLe">×</span>
|
||||
<img id="lightboxImg" src="" alt="" data-page-node-id="wX0DtrO8n9OxRAAdFaDtAx" />
|
||||
<div id="lightboxCaption" class="caption" data-page-node-id="bCQzLy8AgSWOKL7ZcMU3ZR"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
{{template "base/header" .}}
|
||||
<div class="uk-card uk-card-default uk-card-body">
|
||||
<h1 class="uk-heading-small">{{t .LangCode "site.title"}}</h1>
|
||||
<p style="color: red;">重大消息!本站已切换到<a href="https://box.shiroko.one/">box.shiroko.one</a>,原域名将在2026年12月24日到期,期间将通过301转发指向新域名,到期后旧域名将彻底不在使用。<br>问:为什么?答:.guru域名连续涨价,现在一年三百出头了遭不住。<br>问:我该做什么?答:记住新域名,如果之前有通过二维码分享出去的,记得替换成新的二维码。</p><hr>
|
||||
<p style="color: red;">重大消息!本站已切换到<a href="https://box.shiroko.one/">box.shiroko.one</a>,原域名将在2026年12月24日到期,期间将通过301转发指向新域名,到期后旧域名将彻底不在使用。<br>
|
||||
现已切换内容审核服务到阿里云,审核速度提升且误伤变少了。另外允许用户调整审核严格度,在不碰底线的前提下允许用户设置不接受辱骂、广告等信息(即不听坏话模式)按理来说之前的一些逆天的言论现在都能挡住了。 </p><hr>
|
||||
<p><br>⭐一个无需注册即可提问的匿名提问箱。你不需要下载App,不需要绑定社交账号,甚至连邮箱都不需要是真的。<br>⭐本站在原版的基础上新增了问题页面的二维码、私聊、投票、追加提问等功能以及更多自定义内容。各位用户可自行修改二维码背景图、颜色等个性化配置。<br>⭐可以到站长唯一提问提问箱链接<a href="//box.shiroko.one/_/tamakyi">小狼的提问箱</a>尝试相关功能或给我提意见。<br>⭐可以加群:<a href="http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=rRy9g5XY34LIzOITGGmQHjX6BIICFpam&authKey=EjyNjp60KyG8BtKZGN8BXJaf1jl8okkCcUhVF2zAnGPRjhvuSdopnVqOSfSymnGg&noverify=0&group_code=777163254">小狼的群</a>一起水群。<br></p>
|
||||
<hr><p style="color: red;">近期将国内段访问交由EdgeOne接管,境外访问照旧由CloudFlare提供,经测试国内访问速度有所提升。但因EdgeOne Free套餐不提供CIDR列表,IP放行采取自动抓取手动维护模式,如果出现521错误请刷新一下页面,过几天应该就好了。</p>
|
||||
<hr><p style="color: red; font-weight: bold;">近期更新:<br>⭐2026.9 增加了提问、投票、私聊的总开关,可以设置定时收集、定量收集或关闭功能,顺便调整了前后端显示,看起来更易用了一些。<br>⭐2026.8 新增了<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown显示</a>、置顶显示等多个功能,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。<br>⭐2026.8 放弃使用recaptcha等对国内用户非常不友好的验证方式,更新了密码加密方式,修复了图片上传等一系列问题。<br>⭐2026.5 增加了私聊功能,可进行连续性对话,详见<a href="https://blog.shiroko.one/record/tamabox-update-20260513">介绍</a>;另有增加提问箱的卡片分享,更直观和美观。<br>⭐2026.2 新增了用户展示和标签功能,如果你希望陌生人给你提问,可以在设置页面给自己贴上5个标签并选择是否展示在探索页面:<a href="https://box.shiroko.one/explore">点我访问</a>,详情见<a href="https://blog.shiroko.one/record/tamabox-tags">狼的公告板</a><br></p>
|
||||
<hr><p style="color: red; font-weight: bold;">近期更新:<br>⭐2026.9 新增审核严格度选项,增加了提问、投票、私聊的总开关,可以设置定时收集、定量收集或关闭功能,顺便调整了前后端显示,看起来更易用了一些。<br>⭐2026.8 新增了<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown显示</a>、置顶显示等多个功能,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。<br>⭐2026.8 放弃使用recaptcha等对国内用户非常不友好的验证方式,更新了密码加密方式,修复了图片上传等一系列问题。<br>⭐2026.5 增加了私聊功能,可进行连续性对话,详见<a href="https://blog.shiroko.one/record/tamabox-update-20260513">介绍</a>;另有增加提问箱的卡片分享,更直观和美观。<br>⭐2026.2 新增了用户展示和标签功能,如果你希望陌生人给你提问,可以在设置页面给自己贴上5个标签并选择是否展示在探索页面:<a href="https://box.shiroko.one/explore">点我访问</a>,详情见<a href="https://blog.shiroko.one/record/tamabox-tags">狼的公告板</a><br></p>
|
||||
<hr><p>一点提醒:<br>⭐本站唯一指定认证站长:<a href="//box.shiroko.one/_/tamakyi">小狼</a>,注意不要被骗。
|
||||
<br>⭐本站不允许垃圾信息、广告、辱骂、涉政、暴恐、色情、违禁、无意义、灌水提问的出现,如有涉证或不良言论将直接删除。</p>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
gotemplate "html/template"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,9 +14,37 @@ import (
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/security/moderation"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
)
|
||||
|
||||
// 审核严格度下拉框的解析:不依赖属性的书写顺序或是否存在额外属性
|
||||
// (模板可能被页面编辑器注入 data-* 属性),因此用正则而不是精确子串匹配。
|
||||
var (
|
||||
strictnessOptionRe = regexp.MustCompile(`<option value="(lax|standard|strict)"([^>]*)>`)
|
||||
selectedAttrRe = regexp.MustCompile(`\bselected\b`)
|
||||
)
|
||||
|
||||
// selectedStrictnessOptions 返回渲染结果中被标记 selected 的档位值(去重后升序)。
|
||||
// 三个面板各渲染一份下拉框,正常情况下应得到恰好一个值;返回多个说明面板之间不一致。
|
||||
func selectedStrictnessOptions(out string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var values []string
|
||||
for _, m := range strictnessOptionRe.FindAllStringSubmatch(out, -1) {
|
||||
if !selectedAttrRe.MatchString(m[2]) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[m[1]]; ok {
|
||||
continue
|
||||
}
|
||||
seen[m[1]] = struct{}{}
|
||||
values = append(values, m[1])
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
}
|
||||
|
||||
// renderWithData 用给定数据渲染模板(复刻生产环境 Templater + FuncMap)。
|
||||
func renderWithData(t *testing.T, name string, data map[string]interface{}) error {
|
||||
t.Helper()
|
||||
@@ -73,6 +103,8 @@ type mockLoggedUser struct {
|
||||
Language string
|
||||
HarassmentSetting string
|
||||
BlockWords string
|
||||
// 审核严格度档位(lax / standard / strict),设置页三个功能面板共用一份。
|
||||
ModerationStrictness string
|
||||
Tags string
|
||||
ShowInHome bool
|
||||
Acfunlink string
|
||||
@@ -97,7 +129,8 @@ func profileBaseData() map[string]interface{} {
|
||||
Qrcodepdpcolor: "#000", Qrcodebackcolor: "#fff", Dotscale: "0.35",
|
||||
Backgroundimagealpha: "0.8", Notify: "email", Language: "zh-CN",
|
||||
HarassmentSetting: "none", BlockWords: "广告,spam", Tags: "杂谈",
|
||||
ChatMaxMessages: 20, DailyMessageLimit: 0,
|
||||
ModerationStrictness: "standard",
|
||||
ChatMaxMessages: 20, DailyMessageLimit: 0,
|
||||
},
|
||||
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
|
||||
"IsUserPage": false,
|
||||
@@ -207,6 +240,26 @@ func TestRenderUserProfileTabs(t *testing.T) {
|
||||
t.Fatalf("account: 屏蔽词输入缺 data-tb-max 注入,want %q", wantBlock)
|
||||
}
|
||||
}
|
||||
// 审核严格度:提问 / 私聊 / 投票三个面板都必须渲染同一份设置,且把每个档位的
|
||||
// 拦截范围平铺展示出来 —— 要求是用户按下保存前就知道哪些内容会被拦、哪些会放行。
|
||||
if tab == "question" || tab == "chat" || tab == "vote" {
|
||||
if got := selectedStrictnessOptions(out); len(got) != 1 || got[0] != "standard" {
|
||||
t.Fatalf("%s: 审核严格度未按当前档位渲染 selected 状态,got %v", tab, got)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"admin.moderationStrictnessScope.lax",
|
||||
"admin.moderationStrictnessScope.standard",
|
||||
"admin.moderationStrictnessScope.strict",
|
||||
} {
|
||||
if want := i18n.T("zh-CN", key); !strings.Contains(out, want) {
|
||||
t.Fatalf("%s: 档位拦截范围缺失 %s,want %q", tab, key, want)
|
||||
}
|
||||
}
|
||||
// 底线拦截是强制项、任何档位不可关闭,界面上必须明确告知。
|
||||
if want := i18n.T("zh-CN", "admin.moderationStrictnessBaseline"); !strings.Contains(out, want) {
|
||||
t.Fatalf("%s: 缺少底线拦截说明,want %q", tab, want)
|
||||
}
|
||||
}
|
||||
if tab == "chat" {
|
||||
// 私聊面板的 number 输入框 min/max 与 conf.Limits 同源
|
||||
wantChat := fmt.Sprintf(`name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="%d"`, conf.Limits.ChatMaxMessages)
|
||||
@@ -232,6 +285,46 @@ func TestRenderUserProfileTabs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderProfileStrictnessReflectsEffectiveTier 覆盖「档位回显」这个易错点。
|
||||
//
|
||||
// 存量账号的 moderation_strictness 为空值(语义是"跟随站点默认")。若模板直接拿该字段值
|
||||
// 去和三个 <option> 逐个比较,会全部不匹配 —— 浏览器随后默认选中第一项「宽松」,
|
||||
// 界面显示的档位就与真实生效的档位(站点默认,通常为严格)相反,把箱主误导到最松的一档。
|
||||
// 因此回显必须走「最终生效档位」。
|
||||
func TestRenderProfileStrictnessReflectsEffectiveTier(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stored string
|
||||
want []string
|
||||
}{
|
||||
{name: "未设置:回显生效档位,而非浏览器默认选中的宽松", stored: "", want: []string{string(moderation.StrictnessFor(""))}},
|
||||
{name: "宽松", stored: "lax", want: []string{"lax"}},
|
||||
{name: "标准", stored: "standard", want: []string{"standard"}},
|
||||
{name: "严格", stored: "strict", want: []string{"strict"}},
|
||||
{name: "非法值:回退严格", stored: "bogus", want: []string{string(moderation.StrictnessFor("bogus"))}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := profileBaseData()
|
||||
base["ActiveProfileTab"] = "question"
|
||||
base["LoggedUser"].(*mockLoggedUser).ModerationStrictness = tc.stored
|
||||
|
||||
out, err := renderToString(t, "user/profile", base)
|
||||
if err != nil {
|
||||
t.Fatalf("render user/profile failed: %v", err)
|
||||
}
|
||||
|
||||
got := selectedStrictnessOptions(out)
|
||||
if fmt.Sprint(got) != fmt.Sprint(tc.want) {
|
||||
t.Fatalf("档位回显不符:stored=%q got=%v want=%v", tc.stored, got, tc.want)
|
||||
}
|
||||
// 核心回归点:未设置时绝不能显示为「宽松」,那是浏览器默认选中首项的陷阱。
|
||||
if tc.stored == "" && len(got) == 1 && got[0] == "lax" {
|
||||
t.Fatal("未设置档位时回显为「宽松」—— 与真实生效档位相反")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderUserQuestionList 模拟 /user/questions 的数据渲染,复现 500 错误。
|
||||
func TestRenderUserQuestionList(t *testing.T) {
|
||||
type mockItem struct {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<!--
|
||||
审核严格度表单部分模板:提问 / 私聊 / 投票 三个功能面板共用同一份设置
|
||||
(与 user/global-blockwords 相同的渲染约定:dict{ Root: 根数据, Action: 提交地址 })。
|
||||
|
||||
这里刻意把「底线拦截」与每个档位的拦截范围全部平铺展示,而不是藏在折叠提示里:
|
||||
要求是让用户在按下保存之前就清楚知道哪些内容会被拦、哪些会被放行。
|
||||
档位的判定规则以 internal/security/censor/policy.go 为唯一事实来源,
|
||||
本模板只负责展示,不重复实现判定。
|
||||
-->
|
||||
{{ $root := .Root }}
|
||||
<div class="uk-margin">
|
||||
<form method="post" action="{{ .Action }}">
|
||||
{{ $root.CSRFTokenHTML }}
|
||||
<legend class="uk-legend">{{t $root.LangCode "admin.moderationStrictness"}}</legend>
|
||||
|
||||
<p class="uk-text-meta uk-text-small">{{t $root.LangCode "admin.moderationStrictnessHint"}}</p>
|
||||
<p class="uk-text-meta uk-text-small">{{t $root.LangCode "admin.moderationStrictnessBaseline"}}</p>
|
||||
|
||||
<table class="uk-table uk-table-small uk-table-divider">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{t $root.LangCode "admin.moderationStrictnessColumnLevel"}}</th>
|
||||
<th>{{t $root.LangCode "admin.moderationStrictnessColumnScope"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessOption.lax"}}</td>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessScope.lax"}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessOption.standard"}}</td>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessScope.standard"}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessOption.strict"}}</td>
|
||||
<td>{{t $root.LangCode "admin.moderationStrictnessScope.strict"}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">{{t $root.LangCode "admin.moderationStrictness"}}</label>
|
||||
{{/* 回显「最终生效」档位而非原始字段值:字段为空(跟随站点默认)时若直接比较,
|
||||
三个 option 都不匹配,浏览器会默认选中第一项(宽松),与实际生效的严格档不符。
|
||||
解析走 internal/template 的 moderationStrictness 函数(同一份事实来源)。 */}}
|
||||
{{ $current := moderationStrictness $root.LoggedUser.ModerationStrictness }}
|
||||
<select name="strictness" class="uk-select uk-width-medium">
|
||||
<option value="lax"{{ if eq $current "lax" }} selected{{ end }}>
|
||||
{{t $root.LangCode "admin.moderationStrictnessOption.lax"}}
|
||||
</option>
|
||||
<option value="standard"{{ if eq $current "standard" }} selected{{ end }}>
|
||||
{{t $root.LangCode "admin.moderationStrictnessOption.standard"}}
|
||||
</option>
|
||||
<option value="strict"{{ if eq $current "strict" }} selected{{ end }}>
|
||||
{{t $root.LangCode "admin.moderationStrictnessOption.strict"}}
|
||||
</option>
|
||||
</select>
|
||||
<span class="uk-text-muted uk-text-small">{{t $root.LangCode "admin.moderationStrictnessUnsetHint"}}</span>
|
||||
</div>
|
||||
|
||||
<div class="tb-card-save">
|
||||
<button type="submit" class="uk-button uk-button-primary">{{t $root.LangCode "admin.updateModerationStrictness"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -834,6 +834,9 @@
|
||||
<!-- 全局屏蔽词:三个功能面板共用同一份(见 user/global-blockwords 部分模板) -->
|
||||
{{ template "user/global-blockwords" (dict "Root" $ "Action" "/user/blockwords/update?tab=question") }}
|
||||
|
||||
<!-- 审核严格度:三个功能面板共用同一份(见 user/moderation-strictness 部分模板) -->
|
||||
{{ template "user/moderation-strictness" (dict "Root" $ "Action" "/user/moderation-strictness/update?tab=question") }}
|
||||
|
||||
<hr>
|
||||
<div class="uk-margin">
|
||||
<form method="post" action="/user/profile/update-ask-password">
|
||||
@@ -882,6 +885,8 @@
|
||||
<hr>
|
||||
<!-- 全局屏蔽词:提问/私聊/投票三面板共用同一份(见 user/global-blockwords 部分模板) -->
|
||||
{{ template "user/global-blockwords" (dict "Root" $ "Action" "/user/blockwords/update?tab=chat") }}
|
||||
<!-- 审核严格度:同样三面板共用一份(见 user/moderation-strictness 部分模板) -->
|
||||
{{ template "user/moderation-strictness" (dict "Root" $ "Action" "/user/moderation-strictness/update?tab=chat") }}
|
||||
</div><!-- /私聊功能管理面板 -->
|
||||
|
||||
<!-- ===================== 投票功能管理面板 ===================== -->
|
||||
@@ -891,6 +896,9 @@
|
||||
<hr>
|
||||
<!-- 全局屏蔽词:三个功能面板共用同一份(见 user/global-blockwords 部分模板) -->
|
||||
{{ template "user/global-blockwords" (dict "Root" $ "Action" "/user/blockwords/update?tab=vote") }}
|
||||
|
||||
<!-- 审核严格度:三个功能面板共用同一份(见 user/moderation-strictness 部分模板) -->
|
||||
{{ template "user/moderation-strictness" (dict "Root" $ "Action" "/user/moderation-strictness/update?tab=vote") }}
|
||||
</div><!-- /投票功能管理面板 -->
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user