Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
55891dff
Commit
55891dff
authored
Apr 28, 2026
by
陈曦
Browse files
整理capture requests实现和apikey capture的标识位设置sh
parent
aa6d2cf7
Changes
2
Hide whitespace changes
Inline
Side-by-side
REQUEST_CAPTURE_CHANGES.md
View file @
55891dff
# Request/Response Capture 功能变更报告
> 功能:对指定 API Key 开启请求体与响应体的双路采集,支持数据库和 NFS 两种存储方式,覆盖 Claude、OpenAI 及 Gemini 全部网关路径。
> **功能概述:** 对指定 API Key 开启请求体与响应体的双路采集,支持数据库和 NFS 两种存储方式,
> 覆盖 Claude / OpenAI / Gemini 全部网关路径。支持通过管理员 API 动态开关,变更立即生效(缓存强制失效)。
---
...
...
@@ -51,7 +52,7 @@ type RequestCaptureConfig struct {
```
yaml
request_capture
:
nfs_path
:
"
/app/logs/nfs/"
nfs_path
:
"
"
# 留空则跳过 NFS,仅写 DB
worker_timeout_seconds
:
5
```
...
...
@@ -62,23 +63,29 @@ REQUEST_CAPTURE_NFS_PATH=
REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5
```
> viper 使用 `AutomaticEnv()` + `SetEnvKeyReplacer(".", "_")`,
> 环境变量 `REQUEST_CAPTURE_NFS_PATH` 自动映射到 `request_capture.nfs_path`。
---
## 三、核心服务
### 3.1 `backend/internal/service/request_capture_service.go`(全新文件)
| 方法 |
描述
|
| 方法 |
行为
|
|---|---|
|
`Capture(
...)`
| 同步
写 DB(返回 captureID),
异步
写 NFS 请求文件
;若有 NFS 路径则将
`captureID→nfsFilePath`
存入
`sync.Map`
|
|
`CaptureResponse(captureID, responseBody)`
|
异步
:更新 DB
`response_body`
;若有
NFS
路径则写
`<原文件名>_response.json`
|
|
`Capture(
apiKeyID, userID, requestID, path, method, ipAddr, body)`
|
**同步**
写 DB(返回 captureID),
**异步**
写 NFS 请求文件 |
|
`CaptureResponse(captureID, responseBody)`
|
**全异步**
:更新 DB
`response_body`
+ 写
NFS
响应文件
|
|
`nfsResponseFilePath(requestPath)`
|
`xxx.json`
→
`xxx_response.json`
|
|
`writeResponseToNFS(...)`
| 写
`nfsResponseEnvelope{capture_id, created_at, body}`
|
**关键设计:**
-
`Capture()`
是
**同步**
DB 写入,保证返回 captureID 后立即可用
-
`CaptureResponse()`
是
**全异步**
,不阻塞请求响应链路
-
`sync.Map`
以 captureID 为 key 暂存 nfsFilePath,
`LoadAndDelete`
一次性消费,避免内存泄漏
-
`Capture()`
是
**同步**
DB 写入,保证调用方拿到 captureID 后立即可用于
`CaptureResponse()`
-
`CaptureResponse()`
是
**全异步**
(goroutine),不阻塞响应链路
-
`sync.Map`
以
`captureID`
为 key 暂存 nfsFilePath,
`LoadAndDelete`
一次性消费,避免内存泄漏
-
`captureID == 0`
时
`CaptureResponse()`
静默跳过(DB 写失败时的降级)
-
NFS 响应文件
`Body`
字段类型为
`any`
:先用
`json.Valid()`
判断——合法 JSON(非流式)用
`json.RawMessage`
保留结构,否则(流式纯文本)直接存
`string`
,避免编码失败
**NFS 文件组织结构:**
...
...
@@ -90,19 +97,90 @@ REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5
└── {unixNano}_{requestID}_response.json ← 响应体
```
**NFS 文件格式:**
```
json
//
请求文件
{
"api_key_id"
:
42
,
"user_id"
:
1
,
"request_id"
:
"req-xxx"
,
"created_at"
:
"2024-01-01T00:00:00Z"
,
"path"
:
"/v1/messages"
,
"method"
:
"POST"
,
"ip_address"
:
"1.2.3.4"
,
"body"
:
{
"model"
:
"claude-3-5-haiku-20241022"
,
"messages"
:
[
...
]
}
}
//
响应文件(非流式:body
为
JSON
对象;流式:body
为纯文本字符串)
{
"capture_id"
:
123
,
"created_at"
:
"2024-01-01T00:00:01Z"
,
"body"
:
{
"id"
:
"msg_xxx"
,
"content"
:
[
...
]
}
}
```
### 3.2 `backend/internal/repository/request_capture_log_repo.go`(全新文件)
```
go
// 实现 RequestCaptureLogRepository 接口
Create
(
ctx
,
params
)
(
int64
,
error
)
UpdateResponseBody
(
ctx
,
id
,
responseBody
)
error
Create
(
ctx
,
params
CreateRequestCaptureLogParams
)
(
int64
,
error
)
UpdateResponseBody
(
ctx
,
id
int64
,
responseBody
string
)
error
```
---
## 四、认证缓存层(关键修复)
### 4.1 问题根因
`capture_requests`
字段要在每次请求认证时读取。认证路径经过三层缓存,字段必须在每一层都正确传递,否则即使数据库更新了,运行时读到的也是旧值
`false`
。
### 4.2 `backend/internal/repository/api_key_repo.go`
**修复 1:`GetByKeyForAuth` Select 白名单补充字段**
```
go
// 之前缺失,导致认证路径查出来的字段值恒为 false
apikey
.
FieldCaptureRequests
,
// ← 新增
```
**修复 2:`Update()` 方法持久化字段**
```
go
builder
.
SetCaptureRequests
(
key
.
CaptureRequests
)
// ← 新增,否则 Update 不会写入该列
```
### 4.3 `backend/internal/service/api_key_auth_cache.go`
```
go
// APIKeyAuthSnapshot 新增字段
CaptureRequests
bool
`json:"capture_requests"`
```
### 4.4 `backend/internal/service/api_key_auth_cache_impl.go`
```
go
// 版本号从 7 升到 8,使所有旧快照(不含该字段)在 Redis 中自动失效
const
apiKeyAuthSnapshotVersion
=
8
// v8: added CaptureRequests on api key snapshot
// snapshotFromAPIKey:DB → 快照
CaptureRequests
:
apiKey
.
CaptureRequests
,
// snapshotToAPIKey:快照 → 运行时 APIKey 对象
CaptureRequests
:
snapshot
.
CaptureRequests
,
```
**缓存 TTL 参考:**
-
L1 in-memory(ristretto):15 秒
-
L2 Redis:300 秒(5 分钟)
版本号升级后,所有 Redis 中的旧快照会因版本不匹配而被丢弃,强制回源 DB。
---
##
四
、Context
Key
##
五
、Context
Buffer(流式响应文本采集)
**`backend/internal/pkg/ctxkey/ctxkey.go`(新增)**
**`backend/internal/pkg/ctxkey/ctxkey.go`(新增
常量
)**
```
go
// ResponseCaptureBuffer 流式响应中收集 assistant 文本,供 request_capture 使用。
...
...
@@ -110,80 +188,184 @@ UpdateResponseBody(ctx, id, responseBody) error
ResponseCaptureBuffer
Key
=
"ctx_response_capture_buffer"
```
流式请求的文本采集流程:
**流式文本采集流程:**
```
handler 注入 *strings.Builder 到 context
↓
service streaming handler 追加 text_delta
↓
Forward() / ForwardGemini() 读取 builder.String()
↓
ForwardResult.ResponseBody
↓
handler CaptureResponse()
1. handler 判断 apiKey.CaptureRequests && captureID > 0
↓
2. 注入 *strings.Builder 到 context(WithValue)
↓
3. service streaming handler 解析 SSE 事件
在 content_block_delta + text_delta 事件中 captureBuilder.WriteString(text)
↓
4. Forward() / forwardXxx() 读取 builder.String()
赋值给 ForwardResult.ResponseBody
↓
5. handler 在 Forward() 返回后调用 CaptureResponse(captureID, result.ResponseBody)
```
---
## 五、各端点覆盖详情
## 六、各端点覆盖详情
### 6.1 `POST /v1/messages` → Anthropic 账号
| 文件 | 变更 |
|---|---|
|
`handler/gateway_handler.go`
|
`Capture()`
保存 captureID;流式注入
`ResponseCaptureBuffer`
;Anthropic & Gemini success path 调用
`CaptureResponse()`
|
|
`service/gateway_service.go`
|
`ForwardResult`
新增
`ResponseBody string`
;非流式读响应字节;流式三条路径均读 context buffer |
**gateway_service.go 三条流式路径(均已覆盖):**
-
`handleStreamingResponseAnthropicAPIKeyPassthrough`
(Anthropic API Key 直传流式)
-
`handleStreamingResponseForClaude`
(OAuth 账号流式)
-
`handleNonStreamingResponseAnthropicAPIKeyPassthrough`
(Anthropic 非流式,返回签名改为
`(string, *ClaudeUsage, error)`
)
###
5.1 `
/v1/messages` →
Anthropic
账号
###
6.2 `POST
/v1/messages` →
Bedrock
账号
|
位置
| 变更 |
|
文件
| 变更 |
|---|---|
|
`
handler/gateway_handler.go`
|
`Capture()`
保存 captureID;流式注入
`ResponseC
aptureBu
ff
er`
;
Anthropic success path 调用
`CaptureResponse()`
|
|
`
handler
/gateway_
handler.go`
(Gemini success path)|
**新增**
`CaptureResponse(captureID, r
esult.ResponseBody
)`
(约第513行)
|
|
`service/gateway_service.go`
|
`ForwardResult`
新增
`ResponseBody string`
;非流式读响应字节,流式读 context buffer
|
|
`
service/bedrock_stream.go`
| 从 context 读取
`c
aptureBu
ild
er`
;
在
`content_block_delta + text_delta`
中追加文本
|
|
`
service
/gateway_
service.go`
(
`forwardBedrock`
) | 非流式使用
`handleBedrockNonStreamingResponse`
返回的 string;流式读 context buffer;
`ForwardR
esult.ResponseBody
`
填充
|
|
`service/gateway_service.go`
(
`handleBedrockNonStreamingResponse`
) | 签名改为
`(string, *ClaudeUsage, error)`
,返回
`string(body)`
|
###
5.2 `
/v1/messages` →
CC 转发(Anthropic CC 协议)
###
6.3 `POST
/v1/messages` →
Antigravity(Gemini)账号
|
位置
| 变更 |
|
文件
| 变更 |
|---|---|
|
`handler/gateway_handler_chat_completions.go`
|
`Capture()`
+
`CaptureResponse()`
|
|
`service/gateway_forward_as_chat_completions.go`
| 非流式:marshal
`ccResp`
→
`ResponseBody`
;流式:
`textBuilder`
收集
`Delta.Content`
|
|
`handler/gateway_handler.go`
| 复用 6.1 的
`captureID`
+
`CaptureResponse`
调用 |
|
`service/antigravity_gateway_service.go`
| 四条路径均已覆盖(见下) |
**antigravity_gateway_service.go 四条路径:**
| 函数 | 类型 | 采集方式 |
|---|---|---|
|
`handleClaudeStreamToNonStreaming`
| Claude→非流式输出 |
`responseBody = string(claudeResp)`
|
|
`handleClaudeStreamingResponse`
| Claude→流式输出 | 从 context buffer 读(
`candidates[0].content.parts[0].text`
) |
|
`handleGeminiStreamToNonStreaming`
| Gemini→非流式输出 |
`strings.Join(collectedTextParts, "")`
|
|
`handleGeminiStreamingResponse`
| Gemini→流式输出 | 从 context buffer 读(遍历
`candidates[0].content.parts[*].text`
) |
`streamUpstreamResponse`
函数签名新增
`ctx context.Context`
参数,两处调用点同步更新。
###
5.3 `/openai/v1/chat/completions`
###
6.4 `POST /v1/messages` → GeminiMessagesCompat 账号
| 位置 | 变更 |
| 文件 | 变更 |
|---|---|
|
`handler/gateway_handler.go`
| 复用 6.1 的
`captureID`
+
`CaptureResponse`
调用 |
|
`service/gemini_messages_compat_service.go`
|
`geminiStreamResult`
新增
`responseBody string`
|
|
`handleStreamingResponse`
|
`responseBody = seenText`
(函数内已有文本累积) |
|
`handleNonStreamingResponse`
| 签名改为
`(string, *ClaudeUsage, error)`
;
`c.JSON`
改为
`json.Marshal + c.Data`
,填充
`ResponseBody`
|
|
`Forward()`
| 三条 non-streaming 子路径均填
`responseBody`
;
`ForwardResult.ResponseBody`
填充 |
### 6.5 `POST /openai/v1/chat/completions`
| 文件 | 变更 |
|---|---|
|
`handler/openai_chat_completions.go`
|
`Capture()`
+
`CaptureResponse()`
|
|
`service/openai_gateway_chat_completions.go`
| 非流式:marshal
`ccResp`
;流式:
`textBuilder`
收集
`Delta.Content`
|
###
5.4 `
/openai/v1/responses`(
**本次重点修复**
)
###
6.6 `POST
/openai/v1/responses`(
Codex path
)
**问题:**
`Capture()`
返回值被丢弃,两条子路径均无
`CaptureResponse()`
调用,且
`ForwardAsAnthropic`
从未填充
`ResponseBody`
。
| 位置 | 变更 |
| 文件 | 变更 |
|---|---|
|
`handler/openai_gateway_handler.go`
|
**`Capture()` 改为保存 `captureID`**
;Sub-path 1(
`Forward()`
)和 Sub-path 2(
`ForwardAsAnthropic()`
)success block 均
**新增**
`CaptureResponse()`
|
|
`service/openai_gateway_messages.go`
(
`handleAnthropicBufferedResponse`
)|
`c.JSON`
改为
`json.Marshal + c.Data`
,
**填充 `ResponseBody`**
|
|
`service/openai_gateway_messages.go`
(
`handleAnthropicStreamingResponse`
)|
**新增**
`textBuilder`
;
`processDataLine`
中捕获
`content_block_delta / text_delta`
;
`resultWithUsage()`
加
`ResponseBody: textBuilder.String()`
|
|
`handler/openai_gateway_handler.go`
(
`Responses`
函数) |
`captureID`
变量声明保存返回值;success block 调用
`CaptureResponse()`
|
|
`service/gateway_forward_as_responses.go`
| 流式:
`textBuilder`
收集
`content_block_delta + text_delta`
|
###
5.5 `/v1/messages` → Antigravity(Gemini)账号(**本次新增**)
###
6.7 `POST /openai/v1/responses`(Messages path)→ Anthropic 路由
|
位置
| 变更 |
|
文件
| 变更 |
|---|---|
|
`handler/gateway_handler.go`
| 复用 5.1 的
`captureID`
+
`CaptureResponse`
|
|
`service/antigravity_gateway_service.go`
|
`antigravityStreamResult`
新增
`responseBody string`
;import 加
`ctxkey`
|
|
`handleClaudeStreamToNonStreaming`
|
`responseBody: string(claudeResp)`
|
|
`handleClaudeStreamingResponse`
| 读 context buffer;gjson 提取每条 SSE 中的
`response.candidates.0.content.parts.0.text`
或
`candidates.0.content.parts.0.text`
|
|
`handleGeminiStreamToNonStreaming`
|
`responseBody: strings.Join(collectedTextParts, "")`
|
|
`handleGeminiStreamingResponse`
| 读 context buffer;gjson 遍历
`candidates.0.content.parts[*].text`
|
|
`Forward()`
/
`ForwardGemini()`
| 流式从 context buffer 读;非流式从
`streamRes.responseBody`
读;
`ForwardResult.ResponseBody`
填充 |
|
`handler/openai_gateway_handler.go`
(
`Messages`
函数) |
**新增**
`captureID`
声明及
`Capture()`
调用;success block 调用
`CaptureResponse()`
|
|
`service/openai_gateway_messages.go`
(
`handleAnthropicBufferedResponse`
) |
`c.JSON`
改为
`json.Marshal + c.Data`
,填充
`ResponseBody`
|
|
`service/openai_gateway_messages.go`
(
`handleAnthropicStreamingResponse`
) | 新增
`textBuilder`
;在
`processDataLine`
中捕获
`content_block_delta + text_delta`
;
`resultWithUsage()`
加
`ResponseBody`
|
---
## 七、管理员 API(动态开关 + 缓存强制失效)
### 7.1 接口
**`PUT /api/v1/admin/api-keys/:id/capture-requests`**
```
bash
# 开启
curl
-X
PUT https://your-server/api/v1/admin/api-keys/35/capture-requests
\
-H
"Authorization: Bearer
$ADMIN_KEY
"
\
-H
"Content-Type: application/json"
\
-d
'{"enabled": true}'
# 关闭
curl
-X
PUT https://your-server/api/v1/admin/api-keys/35/capture-requests
\
-H
"Authorization: Bearer
$ADMIN_KEY
"
\
-H
"Content-Type: application/json"
\
-d
'{"enabled": false}'
```
**响应:**
```
json
{
"code"
:
0
,
"data"
:
{
"id"
:
35
,
"capture_requests"
:
true
}}
```
###
5.6 `/v1/messages` → GeminiMessagesCompat 账号(**本次新增**)
###
7.2 实现文件
|
位置
| 变更 |
|
文件
| 变更 |
|---|---|
|
`handler/gateway_handler.go`
| 复用 5.1 的
`captureID`
+
`CaptureResponse`
|
|
`service/gemini_messages_compat_service.go`
|
`geminiStreamResult`
新增
`responseBody string`
|
|
`handleStreamingResponse`
|
`responseBody: seenText`
(函数内已有完整文本累积) |
|
`handleNonStreamingResponse`
| 签名改为
`(string, *ClaudeUsage, error)`
;
`c.JSON`
改为
`json.Marshal + c.Data`
|
|
`Forward()`
| 三条 non-streaming 子路径均填
`responseBody`
;
`ForwardResult.ResponseBody`
填充 |
|
`handler/admin/apikey_handler.go`
| 新增
`AdminSetCaptureRequestsRequest`
结构体 +
`SetCaptureRequests`
handler |
|
`service/admin_service.go`
| 接口新增
`AdminSetCaptureRequests`
;实现:
`GetByID → Update → InvalidateAuthCacheByKey`
|
|
`server/routes/admin.go`
|
`apiKeys.PUT("/:id/capture-requests", h.Admin.APIKey.SetCaptureRequests)`
|
|
`handler/admin/admin_service_stub_test.go`
| 新增 stub 实现 |
### 7.3 `AdminSetCaptureRequests` 执行流程
```
1. GetByID(keyID) ← 从 DB 读取完整 APIKey 对象
2. apiKey.CaptureRequests = enabled
3. apiKeyRepo.Update(apiKey) ← 持久化(包含 SetCaptureRequests builder 调用)
4. InvalidateAuthCacheByKey(apiKey.Key)
├─ 删除 L1 in-memory 缓存
└─ 删除 L2 Redis 缓存
```
步骤 4 确保下一条请求立即回源 DB,获取最新的
`capture_requests`
值。
### 7.4 bash 脚本(项目根目录)
**文件:`capture_requests.sh`**
```
bash
./capture_requests.sh <key_id> <on|off>
# 示例
BASE_URL
=
https://s2a-st.appbym.com
ADMIN_KEY
=
sk-xxx ./capture_requests.sh 35 on
BASE_URL
=
https://s2a-st.appbym.com
ADMIN_KEY
=
sk-xxx ./capture_requests.sh 35 off
```
支持
`on/true/1/yes`
和
`off/false/0/no`
,输出带颜色,自动用 jq 格式化 JSON。
---
## 六、依赖注入
## 八、端点覆盖总览
| 端点 | 协议 | 请求捕获 | 响应捕获 |
|---|---|:---:|:---:|
|
`POST /v1/messages`
→ Anthropic 账号(非流式) | Claude Direct | ✅ | ✅ |
|
`POST /v1/messages`
→ Anthropic 账号(流式) | Claude Direct | ✅ | ✅ |
|
`POST /v1/messages`
→ Bedrock 账号(非流式) | Claude via Bedrock | ✅ | ✅ |
|
`POST /v1/messages`
→ Bedrock 账号(流式) | Claude via Bedrock | ✅ | ✅ |
|
`POST /v1/messages`
→ CC 转发(非流式) | Claude → OpenAI CC | ✅ | ✅ |
|
`POST /v1/messages`
→ CC 转发(流式) | Claude → OpenAI CC | ✅ | ✅ |
|
`POST /v1/messages`
→ Antigravity 账号(非流式) | Claude → Gemini | ✅ | ✅ |
|
`POST /v1/messages`
→ Antigravity 账号(流式) | Claude → Gemini | ✅ | ✅ |
|
`POST /v1/messages`
→ GeminiCompat 账号(非流式)| Claude → Gemini | ✅ | ✅ |
|
`POST /v1/messages`
→ GeminiCompat 账号(流式) | Claude → Gemini | ✅ | ✅ |
|
`POST /openai/v1/chat/completions`
(非流式) | OpenAI CC | ✅ | ✅ |
|
`POST /openai/v1/chat/completions`
(流式) | OpenAI CC | ✅ | ✅ |
|
`POST /openai/v1/responses`
(Codex path,非流式)| OpenAI Responses | ✅ | ✅ |
|
`POST /openai/v1/responses`
(Codex path,流式) | OpenAI Responses | ✅ | ✅ |
|
`POST /openai/v1/responses`
(Messages path)| OpenAI → Anthropic | ✅ | ✅ |
---
## 九、依赖注入
**`backend/cmd/server/wire_gen.go`**
(已生成,确认包含)
...
...
@@ -191,79 +373,94 @@ handler CaptureResponse()
requestCaptureLogRepository
:=
repository
.
NewRequestCaptureLogRepository
(
client
)
requestCaptureService
:=
service
.
NewRequestCaptureService
(
requestCaptureLogRepository
,
configConfig
)
gatewayHandler
:=
handler
.
NewGatewayHandler
(
...
,
requestCaptureService
,
...
)
gatewayHandler
:=
handler
.
NewGatewayHandler
(
...
,
requestCaptureService
,
...
)
openAIGatewayHandler
:=
handler
.
NewOpenAIGatewayHandler
(
...
,
requestCaptureService
,
...
)
```
---
##
七、端点覆盖总览
##
十、部署检查清单
| 端点 | 协议 | 请求捕获 | 响应捕获 |
|---|---|---|---|
|
`POST /v1/messages`
→ Anthropic 账号 | Claude | ✅ | ✅ |
|
`POST /v1/messages`
→ CC 转发 | Claude→OpenAI CC | ✅ | ✅ |
|
`POST /v1/messages`
→ Antigravity 账号 | Claude→Gemini | ✅ | ✅ |
|
`POST /v1/messages`
→ GeminiCompat 账号 | Claude→Gemini | ✅ | ✅ |
|
`POST /openai/v1/chat/completions`
| OpenAI CC | ✅ | ✅ |
|
`POST /openai/v1/responses`
(Codex path) | OpenAI Responses | ✅ | ✅ |
|
`POST /openai/v1/responses`
(Messages path) | OpenAI→Anthropic | ✅ | ✅ |
部署前确认以下事项:
-
[ ] 执行数据库迁移(
`108_request_capture_log.sql`
),确认
`api_keys.capture_requests`
列存在
-
[ ] 确认
`request_capture_logs`
分区表及当月分区已创建
-
[ ] 确认服务重启后日志中出现
`request_capture: NFS storage enabled/disabled`
-
[ ] 若需 NFS 落盘,确认环境变量
`REQUEST_CAPTURE_NFS_PATH`
已设置且目录可写
-
[ ] 部署完成后通过脚本测试管理 API:
`./capture_requests.sh <key_id> on`
---
##
八
、测试
流程
##
十一
、测试
与验证
###
8
.1 前置条件
###
11
.1 前置条件
```
bash
#
1.
执行数据库迁移
# 执行数据库迁移
psql
-U
sub2api
-d
sub2api
-f
backend/migrations/108_request_capture_log.sql
# 2. 配置(选其一)
# 方式A:config.yaml 已有 request_capture 块,nfs_path 留空则只写 DB
# 方式B:环境变量
export
REQUEST_CAPTURE_NFS_PATH
=
/tmp/nfs_test/
# 留空则跳过 NFS
# 配置 NFS(可选,留空则只写 DB)
export
REQUEST_CAPTURE_NFS_PATH
=
/tmp/nfs_test/
#
3. 给测试用 API Key 开启采集标志
psql
-U
sub2api
-d
sub2api
-c
\
"UPDATE api_keys SET capture_requests = true WHERE id =
<your_key_id>
;"
#
开启测试用 Key 的采集(通过管理员 API,避免绕过缓存失效)
BASE_URL
=
http://localhost:8080
ADMIN_KEY
=
sk-admin
\
./capture_requests.sh
<your_key_id>
on
```
### 8.2 测试矩阵
每个端点各跑一次
**非流式**
和
**流式**
请求。
### 11.2 测试矩阵
```
bash
KEY
=
"your-capture-enabled-key"
BASE
=
"http://localhost:8080"
#
──
Claude
端点(非流式)──
# Claude
非流式调用
curl
-s
-X
POST
$BASE
/v1/messages
\
-H
"x-api-key:
$KEY
"
-H
"Content-Type: application/json"
\
-d
'{"model":"claude-
3-5-haiku-20241022
","max_tokens":50,
"messages":[{"role":"user","content":"
say hi
"}]}'
-d
'{"model":"claude-
sonnet-4-6
","max_tokens":50,
"messages":[{"role":"user","content":"
你到底是谁呀?
"}]}'
#
──
Claude
端点(流式)──
# Claude
流式调用
curl
-s
-X
POST
$BASE
/v1/messages
\
-H
"x-api-key:
$KEY
"
-H
"Content-Type: application/json"
\
-d
'{"model":"claude-3-5-haiku-20241022","max_tokens":50,"stream":true,
"messages":[{"role":"user","content":"say hi"}]}'
# ── OpenAI Responses 端点 ──
curl
-s
-X
POST
$BASE
/openai/v1/responses
\
-H
"Authorization: Bearer
$KEY
"
-H
"Content-Type: application/json"
\
-d
'{"model":"gpt-4o","input":[{"role":"user","content":"say hi"}]}'
# ── OpenAI Chat Completions 端点 ──
curl
-s
-X
POST
$BASE
/openai/v1/chat/completions
\
-H
"Authorization: Bearer
$KEY
"
-H
"Content-Type: application/json"
\
-d
'{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}]}'
-d
'{"model":"claude-sonnet-4-6","max_tokens":50,"stream":true,
"messages":[{"role":"user","content":"你用的什么模型"}]}'
# OpenAI非流式调用
curl
-X
POST
"
$BASE
/v1/chat/completions"
\
-H
"Authorization: Bearer
$KEY
"
\
-H
"Content-Type: application/json"
\
-d
'{
"model": "gpt-5.2",
"messages": [
{
"role": "user",
"content": "现在几点了"
}
],
"max_tokens": 1024
}'
# OpenAI流式调用
curl
-X
POST
"
$BASE
/v1/chat/completions"
\
-H
"Authorization: Bearer
$KEY
"
\
-H
"Content-Type: application/json"
\
-d
'{
"model": "gpt-5.2",
"messages": [
{
"role": "user",
"content": "你可以告诉我现在几点了吗"
}
],
"max_tokens": 1024,
"stream": true
}'
```
###
8
.3 DB 验证
###
11
.3 DB 验证
```
sql
--
①
请求后立即查(DB 写
入
是同步的,应立即有记录)
--
1.
请求后立即查(DB 写是同步的,应立即有记录)
SELECT
id
,
api_key_id
,
path
,
method
,
(
request_body
IS
NOT
NULL
)
AS
has_req
,
(
response_body
IS
NOT
NULL
)
AS
has_resp
,
...
...
@@ -273,10 +470,10 @@ FROM request_capture_logs
ORDER
BY
created_at
DESC
LIMIT
10
;
--
② 等待 ~1s
后查响应体(CaptureResponse 是异步的)
--
2. 约 1 秒
后查响应体(CaptureResponse 是异步的)
SELECT
id
,
length
(
request_body
)
AS
req_len
,
length
(
response_body
)
AS
resp_len
,
length
(
request_body
)
AS
req_len
,
length
(
response_body
)
AS
resp_len
,
left
(
response_body
,
100
)
AS
resp_preview
FROM
request_capture_logs
ORDER
BY
id
DESC
...
...
@@ -285,76 +482,78 @@ LIMIT 5;
**预期结果:**
| 场景 | has_req | has_resp | resp_preview |
|---|---|---|---|
| 非流式请求 |
`true`
|
`true`
(~1s 内) | JSON,以
`{`
开头 |
| 流式请求 |
`true`
|
`true`
(流结束后) | 纯文本,如
`"Hi! How can I..."`
|
| 场景 | has_req | has_resp(~1s 后) | resp_preview |
|---|:---:|:---:|---|
| 非流式请求 |
`true`
|
`true`
| JSON,以
`{`
开头 |
| 流式请求 |
`true`
|
`true`
| 纯文本,如
`"Hi! How can I..."`
|
| 中文流式请求 |
`true`
|
`true`
| 中文字符(无乱码) |
| 未开启 capture_requests 的 Key | 无记录 | — | — |
###
8
.4 NFS 验证(配置了 `nfs_path` 时)
###
11
.4 NFS 验证(配置了 `nfs_path` 时)
```
bash
DATE
=
$(
date
+%Y-%m-%d
)
API_KEY_ID
=
<your_key_id>
NFS_DIR
=
"
${
REQUEST_CAPTURE_NFS_PATH
}
/
${
DATE
}
/
${
API_KEY_ID
}
"
# 查看文件列表(
应有
请求文件和响应文件成对出现)
# 查看文件列表(请求文件和响应文件
应
成对出现)
ls
-la
"
$NFS_DIR
/"
#
查看请求文件结构
cat
"
$NFS_DIR
/"
*
.json | python3
-m
json.tool |
head
-
2
0
#
验证 JSON 格式
cat
"
$NFS_DIR
/"
*
.json | python3
-m
json.tool |
head
-
3
0
#
查看响应文件结构(_response 后缀)
cat
"
$NFS_DIR
/"
*
_response.json | python3
-m
json.tool |
head
-20
#
验证中文无乱码
grep
-r
"你好"
"
$NFS_DIR
/"
&&
echo
"中文正常"
```
**预期文件结构:**
### 11.5 管理员 API 验证(关键:验证缓存失效是否生效)
```
json
//
请求文件:
{
unixNano
}
_
{
requestID
}
.json
{
"api_key_id"
:
42
,
"user_id"
:
1
,
"request_id"
:
"req-xxx"
,
"created_at"
:
"2024-01-01T00:00:00Z"
,
"path"
:
"/v1/messages"
,
"method"
:
"POST"
,
"ip_address"
:
"127.0.0.1"
,
"body"
:
{
"model"
:
"..."
,
"messages"
:
[
...
]
}
}
```
bash
# 1. 开启
./capture_requests.sh 35 on
# 发一条请求,等 1 秒,查 DB 是否有记录
//
响应文件:
{
unixNano
}
_
{
requestID
}
_response.json
{
"capture_id"
:
123
,
"created_at"
:
"2024-01-01T00:00:01Z"
,
"body"
:
{
"id"
:
"msg_xxx"
,
"content"
:
[
...
]
}
//
非流式为完整JSON;流式为纯文本字符串
}
# 2. 关闭
./capture_requests.sh 35 off
# 再发一条请求,查 DB 新记录数不应增加
# 验证关闭生效(应无新记录)
psql
-c
"SELECT count(*) FROM request_capture_logs
WHERE api_key_id = 35 AND created_at > now() - interval '10 seconds';"
```
### 8.5 对照组(负向验证)
> **注意:** 若直接用 SQL `UPDATE api_keys SET capture_requests = false` 绕过服务层,
> 缓存不会失效,关闭最多需等 L2 Redis TTL(5 分钟)才生效。
> **必须通过管理员 API 或脚本操作,才能立即生效。**
### 11.6 负向验证
```
bash
NO_CAPTURE_KEY
=
"your-normal-key"
curl
-s
-X
POST
$BASE
/v1/messages
\
-H
"x-api-key:
$NO_CAPTURE_KEY
"
-H
"Content-Type: application/json"
\
-H
"x-api-key:
$NO_CAPTURE_KEY
"
\
-H
"Content-Type: application/json"
\
-d
'{"model":"claude-3-5-haiku-20241022","max_tokens":10,
"messages":[{"role":"user","content":"hi"}]}'
```
```
sql
-- 该 key 对应的 api_key_id 不应有任何记录
SELECT
count
(
*
)
FROM
request_capture_logs
WHERE
api_key_id
=
<
no_capture_key_id
>
;
SELECT
count
(
*
)
FROM
request_capture_logs
WHERE
api_key_id
=
<
no_capture_key_id
>
;
-- 预期:0
```
### 8.6 异常场景
---
## 十二、已知限制与边界行为
| 场景 |
预期
行为 |
| 场景 | 行为 |
|---|---|
| DB 写 request 失败 |
`captureID = 0`
,后续
`CaptureResponse`
自动跳过,请求正常返回 |
| DB 写 response 失败 | 记录 error 日志,请求已正常返回,不影响用户 |
| NFS 目录不存在 |
`MkdirAll`
自动创建;失败则 error 日志,不影响 DB 写入 |
| 流式请求客户端中途断开 | buffer 内已采集的文本会被写入,响应体为截断内容(属预期行为)|
|
`captureID`
泄漏(CaptureResponse 从未被调用)|
`sync.Map`
中的条目会滞留,但量级等同于并发请求数,可忽略 |
| 流式请求客户端中途断开 | buffer 内已采集的文本会被写入,响应体为截断内容(属预期行为) |
|
`captureID`
对应
`CaptureResponse`
从未被调用 |
`sync.Map`
中的条目滞留,但量级等同于并发请求数,可忽略 |
| 直接 SQL 修改
`capture_requests`
字段 | 缓存不失效,最多等 5 分钟(Redis TTL)才生效;
**应通过管理 API 操作**
|
| 流式纯文本响应写入 NFS |
`json.Valid()`
判断后以 string 类型存入
`body`
字段,不会引起编码错误 |
| 中文字符 |
`json.Encoder`
设置
`SetEscapeHTML(false)`
,中文原样存储,不转义为
`\uXXXX`
|
capture_requests.sh
View file @
55891dff
...
...
@@ -18,7 +18,7 @@ set -euo pipefail
# ---------- 默认配置(可通过环境变量覆盖)----------
DEFAULT_BASE_URL
=
"https://s2a-st.appbym.com"
DEFAULT_ADMIN_KEY
=
"admin-
bccab2aa363738a3f8140f014a8bab2b4e23f4bb8be15c72d1d79299981d38a0
"
DEFAULT_ADMIN_KEY
=
"admin-
0c19f7fca7f05050a946c7ded419693f6aa3893221e82b5718663c198b002ace
"
BASE_URL
=
"
${
BASE_URL
:-
$DEFAULT_BASE_URL
}
"
ADMIN_KEY
=
"
${
ADMIN_KEY
:-
$DEFAULT_ADMIN_KEY
}
"
...
...
@@ -90,13 +90,13 @@ echo ""
HTTP_RESPONSE
=
$(
curl
-s
-w
"
\n
%{http_code}"
\
-X
PUT
"
$ENDPOINT
"
\
-H
"
Authorization: Bearer
$ADMIN_KEY
"
\
-H
"
x-api-key:
$ADMIN_KEY
"
\
-H
"Content-Type: application/json"
\
-d
"{
\"
enabled
\"
:
$ENABLED
}"
)
# 分离响应体和状态码
HTTP_BODY
=
$(
echo
"
$HTTP_RESPONSE
"
|
sed
'$d'
)
HTTP_CODE
=
$(
echo
"
$HTTP_RESPONSE
"
|
tail
-n
1
)
HTTP_BODY
=
$(
echo
"
$HTTP_RESPONSE
"
|
sed
'$d'
|
tr
-d
'\r'
)
HTTP_CODE
=
$(
echo
"
$HTTP_RESPONSE
"
|
tail
-n
1
|
tr
-d
'\r'
)
# ---------- 结果输出 ----------
echo
"HTTP 状态码:
$HTTP_CODE
"
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment