fix: restore reviewable migration evidence

This commit is contained in:
MiMoCode
2026-07-10 18:26:48 +08:00
commit d1bbb5370c
42 changed files with 6419 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
# TokenThief
newapi 反向代理,采集请求/响应(包括 SSE/chunked 流式响应)并异步批量写入 ClickHouse。
## 特性
- 透明反代任意 HTTP 后端(默认目标为 newapi)。
- 流式响应(SSE、chunked、OpenAI 兼容 chat completions)边转发边缓冲,结束后整体入库。
- 异步队列 + 批量写入,主路径零阻塞。
- **DB 故障不影响代理服务**:连接失败时丢弃日志,后台持续重连。
- 通过 yaml 配置 glob 风格的黑白名单。
- 原样存储 headers/bodyString 字段保存 JSON 文本与 body 内容)。
## 快速开始
### 本地运行
```bash
cp .env.example .env
# 编辑 .env 设置 UPSTREAM_URL / CLICKHOUSE_URL
set -a; source .env; set +a
go run .
```
### Docker
```bash
docker build -t tokenthief .
docker run --rm -p 8080:8080 \
-e UPSTREAM_URL=http://newapi:3000 \
-e CLICKHOUSE_URL='clickhouses://tokenthief:<URL_ENCODED_PASSWORD>@clickhouse:9440/tokenthief' \
-v $(pwd)/filter.yaml:/etc/tokenthief/filter.yaml \
tokenthief
```
镜像内二进制路径为 `/usr/local/bin/TokenThief`
### Docker Compose
`compose.yml` 已包含 TokenThief 与 ClickHouse
```bash
cp .env.example .env
# 编辑 .env,设置 UPSTREAM_URL、强密码 CLICKHOUSE_PASSWORD 和对应的 CLICKHOUSE_URL
docker compose -f compose.yml up -d --build
```
默认端口:
| 服务 | 地址 |
|---|---|
| TokenThief | `http://localhost:8080` |
| ClickHouse | 仅 Compose 内部网络,不默认发布宿主机端口 |
Compose 在渲染配置时要求显式提供 `UPSTREAM_URL`、应用 DSN 和非空 ClickHouse 密码。密码中的 URL 特殊字符必须编码:
```text
clickhouse://tokenthief:<URL_ENCODED_PASSWORD>@thief_clickhouse:9000/tokenthief
```
### 运行测试
测试代码主要集中在 `tests/` 目录,数据库包还包含同包测试。
```bash
gofmt -w .
go test ./...
go vet ./...
```
构建二进制时不会引入测试内容:`_test.go` 不参与 `go build``tests/scripts/` 也不被主程序 importDocker 构建时 `.dockerignore` 会把整个 `tests/` 目录排除在 build context 之外。
## 环境变量
| 变量 | 说明 | 默认 |
|---|---|---|
| `LISTEN_ADDR` | 监听地址 | `:8080` |
| `UPSTREAM_URL` | 后端地址(必填) | - |
| `UPSTREAM_TLS_INSECURE_SKIP_VERIFY` | 跳过上游 HTTPS 证书校验;仅开发/可信内网自签证书场景使用 | `false` |
| `CLICKHOUSE_URL` | ClickHouse 原生协议地址(必填);支持严格 `host:port``clickhouse://` 或 TLS `clickhouses://` | - |
| `MAX_BODY_BYTES` | 单个请求体和响应体的记录上限 | `1048576` |
| `LOG_QUEUE_SIZE` | 异步队列条数上限 | `256` |
| `LOG_QUEUE_BYTES` | 异步队列总字节预算 | `67108864` |
| `LOG_BATCH_SIZE` | 批量写入条数 | `50` |
| `LOG_BATCH_INTERVAL` | 批量刷新间隔 | `2s` |
| `LOG_WORKERS` | worker 数 | `2` |
| `FILTER_FILE` | 黑白名单文件 | `./filter.yaml` |
| `DB_RECONNECT_INTERVAL` | DB 重连间隔 | `10s` |
| `READ_TIMEOUT` | 请求读取总超时 | `30s` |
| `WRITE_TIMEOUT` | 响应写入总超时 | `10m` |
| `IDLE_TIMEOUT` | HTTP keep-alive 空闲超时 | `5m` |
| `UPSTREAM_TIMEOUT` | 上游连接、TLS 握手、响应头等待超时 | `30s` |
| `UPSTREAM_RESPONSE_TIMEOUT` | 普通上游响应体总超时 | `30s` |
| `UPSTREAM_STREAM_IDLE_TIMEOUT` | SSE 上游响应体空闲超时,每次成功读取后重置 | `2m` |
| `TRUSTED_PROXIES` | 可信反向代理 IP/CIDR,逗号分隔;为空时忽略转发头 | 空 |
## 黑白名单(filter.yaml
`CLICKHOUSE_URL` 中的账号、密码和数据库名会传给 ClickHouse 原生协议连接。密码如果包含 `@``:``/``#` 等 URL 特殊字符,需要先做 URL encode。仅填写 `host:port` 时会使用 ClickHouse 默认用户、空密码和默认数据库。
默认 `filter.yaml` 已按 [newapi 官方文档](https://docs.newapi.pro/zh/docs/api) 列出全部 AI 模型接口(chat、completions、embeddings、moderations、rerank、realtime、audio、images、videos、Claude、Gemini 等)。
```yaml
mode: whitelist # whitelist | blacklist | disabled
patterns:
- /v1/chat/completions
- /v1/audio/* # 单段通配
- /v1/videos/** # 跨段通配
- /v1beta/models/*:generateContent # 段内通配
```
Pattern 语法:
| 通配符 | 含义 |
|---|---|
| `*` | 匹配单个路径段内除 `/` 之外的任意字符 |
| `**` | 跨段匹配任意字符(包括 `/` |
| `?` | 匹配单个非 `/` 字符 |
## 端点
- `/healthz` — 始终返回 200,不参与反代与日志。
- 其余路径 — 全部反代到 `UPSTREAM_URL`
## 数据库表
启动时自动创建单张 ClickHouse MergeTree 表 `proxy_logs`(详见 `db/migrate.go`),按 `started_at` 月份分区。DDL 如下:
```sql
CREATE TABLE IF NOT EXISTS proxy_logs (
request_id String,
method String,
path String,
query String,
client_ip String,
request_headers String,
request_body String,
request_truncated Bool DEFAULT false,
status_code Int32,
response_headers String,
response_body String,
response_truncated Bool DEFAULT false,
is_stream Bool DEFAULT false,
latency_ms Int64,
started_at DateTime64(3),
finished_at DateTime64(3),
error String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(started_at)
ORDER BY (started_at, request_id);
```
完整字段说明如下。
### 字段一览
| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `request_id` | `String` | 否 | 每次请求由代理生成的 16 字节随机 hex(32 个字符),同时写入响应头 `X-Request-Id`,便于客户端日志关联 |
| `method` | `String` | 否 | HTTP 方法(`GET` / `POST` / …),与客户端实际发送一致 |
| `path` | `String` | 否 | 请求路径,不含 query string,例如 `/v1/chat/completions` |
| `query` | `String` | 否 | 原始 query string(不含 `?`),例如 `model=gpt-4&stream=true`;无 query 时为空串 |
| `client_ip` | `String` | 否 | 默认记录 TCP 对端;仅直接对端命中 `TRUSTED_PROXIES` 时,从右向左解析 `X-Forwarded-For`,无 XFF 时使用有效的 `X-Real-IP` |
| `request_headers` | `String` | 否 | 完整请求头,序列化为 `{"Header-Name": ["value1", "value2"], ...}` 的 JSON 对象;**注意 Authorization、Cookie、API-Key 等敏感头未脱敏**,按设计原样存储 |
| `request_body` | `String` | 否 | 请求体内容。最多保留 `MAX_BODY_BYTES`(默认 1 MiB)字节,超出部分丢弃;读取失败时请求不会转发 |
| `request_truncated` | `Bool` | 否 | 请求体是否被 `MAX_BODY_BYTES` 截断。即使截断,下游 newapi 仍会通过 `MultiReader` 接收到完整 body,不影响功能 |
| `status_code` | `Int32` | 否 | 上游返回的 HTTP 状态码。`502` 通常意味着上游连接失败;WebSocket 成功升级记录为 `101` |
| `response_headers` | `String` | 否 | 响应头,结构同 `request_headers`。对于 SSE,会包含 `Content-Type: text/event-stream` 等 |
| `response_body` | `String` | 否 | 响应体内容。对于流式响应(SSE / chunked),这里保存的是**所有 chunk 拼接后的完整字节流**(包含 `data:` 前缀、`\n\n` 分隔符以及最后的 `[DONE]`),方便事后离线解析。最多保留 `MAX_BODY_BYTES` 字节 |
| `response_truncated` | `Bool` | 否 | 响应体是否被截断。截断只影响数据库存储,客户端始终收到完整数据 |
| `is_stream` | `Bool` | 否 | 是否 SSE 响应,仅接受媒体类型 `text/event-stream` |
| `latency_ms` | `Int64` | 否 | 端到端耗时(毫秒),从代理接收到请求到响应完成。对流式响应 = 从首请求到最后一个 chunk 发出 |
| `started_at` | `DateTime64(3)` | 否 | 代理接收到请求的时刻;ClickHouse 按 `toYYYYMM(started_at)` 月度分区 |
| `finished_at` | `DateTime64(3)` | 否 | 响应完全写回客户端(包括所有 chunk)的时刻 |
| `error` | `String` | 否 | 仅在反代过程中出现错误时填充。常见值:上游不可达、超时、读取请求体失败等 |
### 常用查询示例
```sql
-- 查看最近 20 次失败请求
SELECT started_at, path, status_code, error
FROM proxy_logs
WHERE status_code >= 400 OR error != ''
ORDER BY started_at DESC
LIMIT 20;
-- 查看某次请求的完整内容
SELECT
request_id,
method, path,
request_body AS req_text,
response_body AS resp_text,
latency_ms, is_stream
FROM proxy_logs
WHERE request_id = '0123456789abcdef0123456789abcdef';
-- 按模型统计调用量(从请求体里提取 JSON 字段)
SELECT
JSONExtractString(request_body, 'model') AS model,
count(*) AS calls,
toInt32(avg(latency_ms)) AS avg_ms
FROM proxy_logs
WHERE path = '/v1/chat/completions'
AND started_at > now() - INTERVAL 1 DAY
GROUP BY 1
ORDER BY calls DESC;
-- 查 Authorization(注意:敏感信息)
SELECT JSONExtractRaw(request_headers, 'Authorization') FROM proxy_logs LIMIT 5;
```
### 注意事项
- **敏感信息**:请求头中的 `Authorization``Cookie``X-Api-Key` 等**未脱敏**。如需脱敏请在 `proxy/capture.go``headersJSON` 中改造,或对数据库做列级权限控制。
- **body 编码**ClickHouse 以 `String` 保存 body 内容;文本接口可直接查询,二进制或压缩内容需按业务格式离线解析。
- **WebSocket**:只记录 101 握手元数据,`response_body` 为空;WS 帧内容不采集,进程关闭时会关闭受管升级连接。
- **截断**`request_truncated` / `response_truncated``true` 时,对应 `*_body` 仅包含前 `MAX_BODY_BYTES` 字节。需保留完整内容请调高 `MAX_BODY_BYTES`,但要警惕数据库膨胀。
## 设计要点
- 请求体读取使用 `LimitReader`,超长仅记录前 `MAX_BODY_BYTES` 字节,下游仍能拿到完整 body。
- `httputil.ReverseProxy` + `FlushInterval = -1`,自定义 `ResponseWriter` 同时实现 `Flusher`/`Hijacker`,写入时先转发再缓冲,保证流式实时性。
- 日志通过非阻塞 channel 投递,队列满或 DB 不健康时直接丢弃(每 30 秒打印 metrics)。
- DB 健康状态机:写入失败立即标记 unhealthy,后台 ping 恢复后重新启用。
- ClickHouse `PrepareBatch` 失败会保留整批重试;逐项 `Append` 失败只保留明确失败项,已成功追加项继续发送。`Send` 返回错误时提交结果可能不明,系统不会自动重发该批,避免静默重复,并通过 `ambiguous_send` 计数暴露可能丢失;进程内 best-effort 队列不承诺分布式 exactly-once。
- 启动会校验现有 `proxy_logs` 的列、引擎、分区键和排序键;不兼容 schema 会保持数据库 unhealthy,不自动重建数据表。
- 所有 batch 路径都会执行清理;`Abort`/关闭失败会与原始错误合并记录,但不能使模糊提交变得可判定。