Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
311 lines
20 KiB
Markdown
311 lines
20 KiB
Markdown
# 提问箱 LLM 内容审核方案 · 可行性评审
|
||
|
||
- 日期:2026-09-10
|
||
- 范围:可行性评审,**未改动任何代码**
|
||
- 背景更正:项目文本/图片审核服务为**七牛云**(阿里云通道虽仍在代码中,但 `conf/app.sample.ini` 的 `aliyun_access_key` 为空,实际不生效)
|
||
|
||
---
|
||
|
||
## 0. 七牛云能力核对(评审基准)
|
||
|
||
已验证(官方文档 [文本审核 API](https://developer.qiniu.com/censor/api/7260/api-text-censor)):
|
||
|
||
| 项 | 事实 |
|
||
|---|---|
|
||
| 端点 | `POST https://ai.qiniuapi.com/v3/text/censor`(代码 `qiniu.go:85` 一致) |
|
||
| 审核场景 | 仅 `antispam` 一种 |
|
||
| 返回 label | `normal` / `spam` / `ad` / `politics` / `terrorism` / **`abuse`(辱骂)** / `porn` / `flood` / `contraband` / `meaningless` |
|
||
| 管控建议 | `result.suggestion`:`block` / `review` / `pass`,并带 `score` |
|
||
| 限制 | 文本长度 ≤ 2000 字;**不支持白名单**,自定义拉黑词库需提交工单 |
|
||
|
||
已验证([七牛 AI 大模型推理](https://developer.qiniu.com/aitokenapi/13379/real-time-ai-interface-api)):
|
||
|
||
- 提供 MaaS 推理端点 `https://api.qiniuapi.com/v1` → 实为 **`https://api.qnaigc.com/v1`**
|
||
- 兼容 OpenAI `/v1/chat/completions`、Anthropic `/v1/messages`、支持流式
|
||
- 50+ 模型(DeepSeek / Qwen / Kimi / GLM 等)
|
||
- 使用**独立的 API Key**,与七牛对象存储 AK/SK 不是同一套凭证
|
||
|
||
代码内已验证的现状(`/internal/security/censor/qiniu.go`):
|
||
|
||
```go
|
||
func (r *QiniuTextCensorResponse) IsPass() bool {
|
||
for _, detail := range r.Result.Scenes.Antispam.Details {
|
||
blockedLabels := []string{"politics", "terrorism", "porn"} // ← 只有 3 类
|
||
if lo.Contains(blockedLabels, detail.Label) { return false }
|
||
}
|
||
return r.Result.Scenes.Antispam.Suggestion != "block" // ← review 放行
|
||
}
|
||
```
|
||
|
||
**关键发现:`abuse`(辱骂)不在硬拦名单内,且 `review` 一律放行** —— 辱骂类内容现在基本是放行的。
|
||
|
||
> ⚠️ 推测待实测:`abuse` 在七牛返回的是 `review` 而非 `block`。建议直接抽样 `censor_logs.raw_response` 里 `label=abuse` 的记录验证(零成本,不需要额外调接口)。
|
||
|
||
**另一条已验证的好消息**:`censor.go:50-53` 命中缓存后会用 `QiniuTextCensorParser(raw)` **重新计算** `Pass`(缓存只省 API 调用费,不锁死判定结果)。所以后续若收紧 `IsPass()` 逻辑,**不需要清理 31 天缓存**。
|
||
|
||
---
|
||
|
||
## 1. 总体结论
|
||
|
||
**方案可行,但按现在的描述直接落地会有 4 个 P0 问题(SSRF、Key 明文、成本被刷、邮件轰炸)。**
|
||
|
||
更重要的一点判断:**方案里"系统内置的若干种屏蔽判定类型",大部分根本不需要 LLM。**
|
||
|
||
七牛已经按 `antispam` 场景返回 9 类 label + score,且代码里已经拿到这些数据只是没用上。合理分层是:
|
||
|
||
| 层级 | 手段 | 成本 | 覆盖 |
|
||
|---|---|---|---|
|
||
| L1(应先做) | 收紧七牛 `IsPass()`:`abuse` + score 阈值 | **0 元,0 延迟** | 辱骂、垃圾、广告、灌水、违禁 |
|
||
| L2(补充) | 本地负面情绪词库打分 | 0 元 | 七牛不覆盖的语气/情绪词 |
|
||
| L3(本方案) | LLM 语义判定 | 钱 + 延迟 | 阴阳怪气、暗示、语境依赖的恶意 |
|
||
|
||
**建议:L3 只承担 L1/L2 覆盖不到的部分**,否则你在用最贵、最慢、最不确定的手段去解决已经有确定性解的问题。
|
||
|
||
---
|
||
|
||
## 2. 数据流与调用时序
|
||
|
||
### 2.1 现状(已验证)
|
||
|
||
`route/question/page.go` 的 `New()` 是**全同步**链路,任一环节返回即终止:
|
||
|
||
```
|
||
长度校验 → 验证码 → 同IP重复拦截 → 屏蔽词 → 七牛审核(10s超时) → 入库 → 邮件通知(goroutine)
|
||
↓ 失败
|
||
fail-closed 拒绝发布
|
||
```
|
||
|
||
现状里唯一的异步是 `shared.SafeGo`(`internal/shared/goutil.go`)——**裸 goroutine,无重试、无持久化、进程重启即丢失**,项目里没有队列/Worker,Redis 只用于 session 与 cache。
|
||
|
||
### 2.2 拟议方案插在哪里
|
||
|
||
按方案 3「命中时在前端提示提问者」,LLM 判定**必须同步阻塞**在入库之前:
|
||
|
||
```
|
||
... → 屏蔽词 → 七牛审核 → 【LLM 审核】→ 入库
|
||
├─ 命中 → 前端提示 / 拦截
|
||
├─ 未命中 → 正常入库
|
||
└─ 超限/连不上 → 跳过 + 前端异常提示 + 邮件告警 + 正常入库
|
||
```
|
||
|
||
### 2.3 时序上的问题
|
||
|
||
| # | 问题 | 级别 | 说明 |
|
||
|---|---|---|---|
|
||
| T1 | **同步阻塞被放大** | 🟠 P1 | 七牛审核约 100–500ms;LLM 判定通常 1–10s(推理模型可达 30s+)。提问提交从"几乎无感"变成明显卡顿 |
|
||
| T2 | **超时预算未定义** | 🟠 P1 | 方案未给超时值。必须独立设 deadline(建议 3–5s),且不能复用请求 ctx(客户端断连会误判为调用失败 → 触发邮件告警) |
|
||
| T3 | **429 与连接失败未区分** | 🟠 P1 | 429(配额耗尽)应当立即熔断,不要再每次都等满超时;连接失败才走告警路径 |
|
||
| T4 | **无结果缓存** | 🟡 P2 | 相同内容重复提交会重复计费。可直接复用 `censor_logs` 已有的 31 天缓存范式 |
|
||
| T5 | **临时性错误无重试** | 🟡 P2 | 一次网络抖动就降级放行,命中率会明显低于预期 |
|
||
|
||
**改进建议(时序)**
|
||
|
||
1. 硬超时 3–5s,用独立 `context.WithTimeout`,**不复用请求 ctx**;
|
||
2. 内置熔断器:连续 N 次失败或收到 429 → 熔断 5–10 分钟,期间直接 fail-open 且**不发邮件**;
|
||
3. 结果按 `hash(userID + 内容)` 缓存,复用 `censor_logs` 的写法;
|
||
4. 若坚持不阻塞体验,可改为**先入库 → 异步判定 → 命中则转"待审/折叠"**,但这就不是方案 3 描述的"前端提示"了,需要你二选一(见 §7)。
|
||
|
||
---
|
||
|
||
## 3. 逐条评审
|
||
|
||
### 3.1 用户自填 API 地址与 Key
|
||
|
||
**可行,但这是整个方案风险最集中的一条。**
|
||
|
||
🔴 **P0-1 · SSRF(服务端请求伪造)**
|
||
用户填的地址由**你的服务器**发起请求。攻击者注册账号后填入 `http://127.0.0.1:6379`、`http://169.254.169.254/latest/meta-data/`、内网段地址,即可:
|
||
- 探测内网拓扑与端口存活
|
||
- 打云厂商 metadata 拿临时凭证
|
||
- 1C1G 机器上 Redis(6379) / Postgres(5432) 通常与 Web 同机
|
||
|
||
而且**方案 4 的"保存前连通性测试"本身就是一个开箱即用的内网探测入口**——不需要任何绕过技巧,填个地址点保存就能看到响应差异。
|
||
|
||
必须的防护(缺一不可):
|
||
- 仅允许 `https`(例外:显式允许 `http://localhost` 供自建模型调试,且需独立开关)
|
||
- 解析 DNS 后**逐个 IP 校验**,拒绝 loopback / link-local(169.254/16) / private(10/8, 172.16/12, 192.168/16) / IPv6 ULA
|
||
- **禁用 HTTP 重定向跟随**(否则 302 到内网即可绕过上面的校验)
|
||
- 建议在 `internal/shared/multidial.go` 已有自定义 Dialer 的基础上实现,统一在 dial 层拦截
|
||
|
||
🔴 **P0-2 · Key 存储安全**
|
||
- 已验证:全项目**没有任何 at-rest 加密**(grep `aes` / `Encrypt(` / `secretbox` 无结果)
|
||
- `users` 表单表存储,新增列即明文落库;DB/备份/导出 CSV 泄露 = Key 全泄露
|
||
- 建议:派生密钥加密存储(不要直接用 `conf.Server.Salt`,用 HKDF 派生独立子密钥);后台**只回显掩码**(`sk-...abcd`);**永不写日志**(现有代码有把完整响应写日志的习惯,需确认 LLM 请求体不会被 logrus 打出来)
|
||
|
||
🟠 **P1 · 协议兼容性未定义**
|
||
"API 地址"是任意 OpenAI 兼容地址,还是固定七牛 `https://api.qnaigc.com/v1`?若允许任意地址,则:
|
||
- 需约定必须 OpenAI 兼容(否则无法解析)
|
||
- 模型名由用户填还是系统固定?不同模型对 JSON 输出的遵循度差异极大
|
||
- 建议:**内置预设 + 允许自定义**,预设直接给七牛官方端点,用户只填 Key 和选模型(顺带消掉 SSRF 面)
|
||
|
||
### 3.2 内置判定类型,用户只能勾选
|
||
|
||
**方向正确。** 但强烈建议:
|
||
|
||
- **类型优先映射到七牛 label**(`abuse` / `spam` / `ad` / `flood` / `contraband` …),这部分走 L1,零成本、确定性、无延迟
|
||
- LLM 类型只保留七牛覆盖不到的少数几种(如「阴阳怪气 / 恶意暗示」「情绪宣泄 / 人身攻击」)
|
||
- 类型定义要写死在 Go 常量 + i18n key 里,与现有 `ForbiddenType`(`internal/security/censor/text.go:16`)保持同一种风格
|
||
|
||
🟡 注意:判定类型与界面语言是 4 种(zh-CN / zh-TW / en / ja),提示词里的类型描述要用提问箱主人设置的语言,但**被审核的提问文本可能是任意语言**,prompt 需显式要求"按原文语言判断"。
|
||
|
||
### 3.3 命中时在前端提示提问者
|
||
|
||
🟠 **P1 · 提示会泄露规则,可被枚举探测**
|
||
若提示写成"你的提问包含辱骂内容",攻击者就能用二分法逐词试探出完整判定规则,进而绕过。
|
||
现有屏蔽词的提示(`question.errBlockWords`)只说"包含屏蔽词"、不说是哪个 —— **LLM 提示应对齐这个粒度**,不暴露具体类型。
|
||
|
||
🟠 **P1 · 误判兜底(最重要的一条)**
|
||
LLM 判定是非确定性的:同一段文本两次调用可能给出不同结果。如果"命中 = 拒绝提交",那么**误判导致的提问丢失是不可逆的**——提问者写了一大段,被吞了,且不知道为什么。
|
||
|
||
建议三选一(按推荐度排序):
|
||
1. **命中 → 提示 + 仍允许提交**,但给箱主打 ⚠️ 标记并默认折叠(推荐:零丢失,箱主有最终处置权)
|
||
2. 命中 → 提示 + 要求二次确认后提交(保留"冷静期"效果,又不丢内容)
|
||
3. 命中 → 硬拦(不推荐,除非你接受误判成本)
|
||
|
||
另需注意:**提示文案走 i18n**,4 种语言各一份(现有 `admin.*` / `question.*` key 的写法)。
|
||
|
||
### 3.4 保存前连通性与鉴权测试
|
||
|
||
**可行,但是 SSRF 的头号入口(见 P0-1)。** 补充:
|
||
|
||
- 测试请求本身也要有超时(≤5s)与 SSRF 校验
|
||
- 建议用**最小 chat 请求**(`max_tokens=1`)而不是 `/v1/models`:部分 Key 只有推理权限没有列举模型权限,用 `/v1/models` 会误判为鉴权失败
|
||
- 测试通过 ≠ 长期可用:Key 可能随时被吊销/欠费。**保存时通过只是准入条件,运行期的熔断与告警才是主力**
|
||
- 保存动作必须防重放:测试是服务端发起的外网请求,需加频率限制(否则可被用来打第三方 / 放大攻击)
|
||
|
||
### 3.5 失败降级:前端提示 + 邮件告警 + 跳过审核正常提交
|
||
|
||
🟠 **P1 · fail-open 之后没有补审**
|
||
"跳过 LLM 审核并正常提交"意味着降级窗口内恶意内容**照常入库且无人复查**。而攻击者完全可以**主动制造失败**(刷到 Key 超限 / 打满你的连接),把系统稳定地压在降级态。
|
||
|
||
建议:降级期间入库的提问标记为 `llm_review = 'skipped'`,后台提供"补审"入口(复用 `cmd/censor.go` 已有的批量补审范式),否则降级态就是永久免审。
|
||
|
||
🔴 **P0-3 · 成本被刷(用户 Key 直接经济损失)**
|
||
Key 是**用户自己的钱**。提问入口匿名开放,攻击者可对某个启用了 LLM 的箱主持续提问,一夜烧光配额:
|
||
- 必须有**每箱主每日调用上限**(超上限即 fail-open,不再调用)
|
||
- 复用 `internal/route/ratelimit.go` 已有的内存滑动窗口限流器做**单 IP 限流**
|
||
- 超限后应**自动停用**该箱主的 LLM 审核并通知箱主,而不是静默继续烧钱
|
||
|
||
🔴 **P0-4 · 邮件轰炸 → SMTP 账号被封**
|
||
"调用失败就发邮件"在攻击场景下 = **每条提问一封邮件**。攻击者刷 1000 条提问,你的发信账号会被标记为垃圾邮件发送者甚至直接封号,**影响的是全站所有邮件(含找回密码)**。
|
||
|
||
必须的节流:
|
||
- 按 **箱主 + 时间窗聚合**(建议 10 分钟最多 1 封),窗口内汇总次数
|
||
- 收敛粒度建议按**错误类别**(连接失败 / 超时 / 429 / 鉴权失败)分别计数,而不是每次都发
|
||
- 触发熔断后**停止发信**,只在恢复时发一封汇总
|
||
- 告警收件人建议复用 `conf.Security.SecurityAlertEmail` 的模式,并允许箱主选择关闭告警
|
||
|
||
---
|
||
|
||
## 4. 失败降级策略 · 建议矩阵
|
||
|
||
| 失败类型 | 是否跳过审核 | 是否提示提问者 | 是否发邮件 | 是否熔断 |
|
||
|---|---|---|---|---|
|
||
| 超时 | 跳过 | 否(静默) | 窗口内聚合 | 连续 N 次 → 熔断 |
|
||
| 连接失败 | 跳过 | 否(静默) | 窗口内聚合 | 是 |
|
||
| 429 / 配额耗尽 | 跳过 | 否 | 是(1 次) | 是(较长) |
|
||
| 401 / 403 鉴权失败 | 跳过 | 否 | 是(1 次) | **自动停用该功能** |
|
||
| 响应解析失败 | 跳过(视为未命中) | 否 | 窗口内聚合 | 连续 N 次 → 熔断 |
|
||
| 达到日调用上限 | 跳过 | 否 | 是(1 次) | 是(到次日) |
|
||
|
||
> 原则:**提问者侧永远不要暴露"审核系统坏了"**——否则等于告诉对方"现在可以随便发"。
|
||
|
||
---
|
||
|
||
## 5. 风险清单
|
||
|
||
| ID | 级别 | 问题 | 处置 |
|
||
|---|---|---|---|
|
||
| P0-1 | 🔴 | 用户自填 API 地址导致 SSRF,连通性测试即探测入口 | 协议白名单 + DNS 解析后逐 IP 校验 + 禁用重定向 |
|
||
| P0-2 | 🔴 | Key 明文落库、无 at-rest 加密、可能回显/入日志 | 派生密钥加密 + 后台掩码 + 禁用请求体日志 |
|
||
| P0-3 | 🔴 | 用户 Key 被刷造成直接经济损失 | 每日调用上限 + 单 IP 限流 + 超限自动停用 |
|
||
| P0-4 | 🔴 | 失败即发信 → 邮件轰炸 → SMTP 封号 | 箱主+时间窗聚合 + 熔断后停发 + 允许关闭告警 |
|
||
| P1-1 | 🟠 | 同步阻塞放大(1–10s+),超时预算未定义 | 独立 3–5s deadline,不复用请求 ctx |
|
||
| P1-2 | 🟠 | 误判导致提问不可逆丢失 | 改"提示 + 标记折叠"或"二次确认",不要硬拦 |
|
||
| P1-3 | 🟠 | 提示文案泄露判定规则,可被枚举绕过 | 提示不暴露具体类型,对齐现有屏蔽词语气 |
|
||
| P1-4 | 🟠 | fail-open 后无补审,攻击者可主动维持降级态 | 标记 `skipped` + 后台补审入口 |
|
||
| P1-5 | 🟠 | LLM 输出解析不可靠(中文自由文本) | 强制 JSON 输出(`response_format`),严格 schema 校验,解析失败视为未命中 |
|
||
| P2-1 | 🟡 | 无结果缓存,重复内容重复计费 | 复用 `censor_logs` 31 天缓存范式 |
|
||
| P2-2 | 🟡 | 临时错误无重试 | 1 次快速重试(仅对连接类错误) |
|
||
| P2-3 | 🟡 | 提问内容出网给第三方 LLM(匿名提问的隐私) | 设置页明确告知 + 默认关闭 |
|
||
| P2-4 | 🟡 | 七牛文本上限 2000 字;`AnswerContentLen` 默认即 2000(边界) | 站长若调高超 2000,七牛会拒 → fail-closed 拒绝发布。需加前置截断或上限校验 |
|
||
| P2-5 | 🟡 | 无调用审计(谁、何时、花多少) | 建议记录调用量/耗时/结果,供箱主自查 |
|
||
|
||
---
|
||
|
||
## 6. 成本与限额
|
||
|
||
| 维度 | 建议 |
|
||
|---|---|
|
||
| 调用时机 | 只对**提问**调用,不要覆盖回答 / 追问 / 私聊(成本与延迟都会失控) |
|
||
| 单箱主日上限 | 建议默认 100–300 次/天,可在 `[limits]` 配置,超限 fail-open + 通知箱主 |
|
||
| 单 IP 限流 | 复用 `internal/route/ratelimit.go`,建议 5–10 次/分钟 |
|
||
| 输入长度 | 截断到提问上限(`QuestionContentLen`,默认 1000 字符),避免超长文本爆 token |
|
||
| 模型 | 审核是分类任务,用小模型即可(成本差一个数量级);不要上推理模型 |
|
||
| 输出 | `max_tokens` 压到最小(如 64),只回 JSON |
|
||
| 缓存 | 相同内容 31 天内不重复调用 |
|
||
| 免费额度 | 七牛 AI 推理对新用户有免费 token,可作为内置预设的冷启动优势 |
|
||
|
||
---
|
||
|
||
## 6.5 修订:撤回 L1 建议(用户实测反馈后追加)
|
||
|
||
> 2026-09-10 追加。用户反馈:**七牛对「辱骂」的判定过于严格,正常对话经常被误伤。**
|
||
|
||
这条反馈直接推翻了本文档 §1 的 L1 建议("收紧 `IsPass()`,把 `abuse` 纳入硬拦")——**该建议予以撤回**,照做只会放大误伤。
|
||
|
||
配合代码复核,误伤的真实成因有两个,且都是**硬拦语义**造成的:
|
||
|
||
| 成因 | 代码位置 | 现状 |
|
||
|---|---|---|
|
||
| 七牛把正常内容判为 `abuse` 且 `suggestion=block` | `qiniu.go:55-68` → `!Pass` → 拒绝 | 提问/私聊**直接发不出去**,用户只看到"内容安全检查不通过 [辱骂]" |
|
||
| 审核服务不可用/超时 | `page.go:574`、`chat_handler.go:258` → `err != nil` → **fail-closed 拒绝** | 七牛抖动期间所有提问与私聊一律失败 |
|
||
|
||
第二个成因尤其容易被忽略:**这不是"判得准不准"的问题,是审核服务一抖全站就发不出内容。**
|
||
|
||
### 关键约束(七牛官方已确认)
|
||
|
||
文本审核**不支持白名单**,自定义拉黑词库需**提交工单**处理 —— 也就是说:
|
||
|
||
**我们既不能让七牛判得更准,也不能让它判得更松,唯一能控制的是"命中之后怎么办"。**
|
||
|
||
### 修订后的设计原则
|
||
|
||
**显式规则硬拦,算法推断只标记。**
|
||
|
||
| 判定来源 | 处置 | 理由 |
|
||
|---|---|---|
|
||
| 箱主自定义屏蔽词 | **硬拦** | 用户显式指定,知道自己拦的是什么,误伤自负 |
|
||
| 算法判定 `politics` / `terrorism` / `porn` | **硬拦** | 合规底线,不能软 |
|
||
| 算法判定 `abuse` / `spam` / `ad` / `flood` / `contraband` / `meaningless` | **只打 ⚠️ 标记,照常送达** | 误报率高,误伤不可逆,把决定权交回箱主 |
|
||
| 审核服务不可用 | **fail-open + 标记待审** | 避免服务抖动导致全站发不出内容 |
|
||
|
||
### 落地形态
|
||
|
||
- `questions` / `messages` 各加一个标记列(GORM `AutoMigrate` 自动加列,无需手写迁移)
|
||
- 箱主后台:⚠️ 角标 + 默认折叠 + 可筛选 + 一键"忽略/删除"
|
||
- 设置页新增「审核严格度」三档,把权衡交还给用户:
|
||
- **严格**:维持现状(不过即拒)
|
||
- **宽松(建议默认)**:仅高危三类硬拦,其余标记
|
||
- **仅标记**:全部不拒,只标记
|
||
- 阈值自建:不采信七牛 `suggestion`,改用已有的 `detail.Score` + 可配阈值(默认偏高,如 0.9)
|
||
- 容错:短文本可跳过判定;箱主"这不是辱骂"反馈计数,用于后续调阈值
|
||
|
||
### 这条路线顺带消掉的风险
|
||
|
||
零延迟、零外部依赖 → **SSRF、Key 明文、Key 被刷、邮件轰炸四个 P0 全部不再存在**,也无需引入队列或加密主密钥。
|
||
|
||
---
|
||
|
||
## 7. 需要你确认的点
|
||
|
||
1. **命中后的语义**:是「①仅提示 + 仍提交(打标记折叠)」/「②提示 + 二次确认后提交」/「③硬拦拒绝」?
|
||
—— 我的建议是 ①,理由见 §3.3。
|
||
2. **是否接受同步阻塞**?若不接受,方案 3「前端提示提问者」就要改成异步 + 待审队列(需要新增队列能力,项目现在没有)。
|
||
3. **API 地址范围**:固定七牛 `https://api.qnaigc.com/v1`(消灭 SSRF 面)/ 还是允许任意 OpenAI 兼容地址?
|
||
4. **模型名**:系统固定一个小模型,还是用户自填?
|
||
5. **Key 加密**:是否接受引入 at-rest 加密(需要一个主密钥,放 `[server]` 段)?还是先明文 + `json:"-"` + 掩码回显,后续再补?
|
||
6. **告警收件人**:复用 `conf.Security.SecurityAlertEmail`,还是发给箱主本人(`users.email`),还是两者都发?
|
||
7. **调用范围**:只审提问,还是也要审回答 / 追问 / 私聊?
|
||
8. **是否先做 L1/L2**:我建议先收紧七牛 `IsPass()`(把 `abuse` 纳入 + score 阈值),这条零成本、零延迟,可能就解决了你 80% 的诉求,之后再决定 LLM 要不要上。要不要我先出这部分的变更清单?
|