Commit 55891dff authored by 陈曦's avatar 陈曦
Browse files

整理capture requests实现和apikey capture的标识位设置sh

parent aa6d2cf7
# Request/Response Capture 功能变更报告 # Request/Response Capture 功能变更报告
> 功能:对指定 API Key 开启请求体与响应体的双路采集,支持数据库和 NFS 两种存储方式,覆盖 Claude、OpenAI 及 Gemini 全部网关路径。 > **功能概述:** 对指定 API Key 开启请求体与响应体的双路采集,支持数据库和 NFS 两种存储方式,
> 覆盖 Claude / OpenAI / Gemini 全部网关路径。支持通过管理员 API 动态开关,变更立即生效(缓存强制失效)。
--- ---
...@@ -51,7 +52,7 @@ type RequestCaptureConfig struct { ...@@ -51,7 +52,7 @@ type RequestCaptureConfig struct {
```yaml ```yaml
request_capture: request_capture:
nfs_path: "/app/logs/nfs/" nfs_path: "" # 留空则跳过 NFS,仅写 DB
worker_timeout_seconds: 5 worker_timeout_seconds: 5
``` ```
...@@ -62,23 +63,29 @@ REQUEST_CAPTURE_NFS_PATH= ...@@ -62,23 +63,29 @@ REQUEST_CAPTURE_NFS_PATH=
REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5 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`(全新文件) ### 3.1 `backend/internal/service/request_capture_service.go`(全新文件)
| 方法 | 描述 | | 方法 | 行为 |
|---|---| |---|---|
| `Capture(...)` | 同步写 DB(返回 captureID),异步写 NFS 请求文件;若有 NFS 路径则将 `captureID→nfsFilePath` 存入 `sync.Map` | | `Capture(apiKeyID, userID, requestID, path, method, ipAddr, body)` | **同步**写 DB(返回 captureID),**异步**写 NFS 请求文件 |
| `CaptureResponse(captureID, responseBody)` | 异步:更新 DB `response_body`;若有 NFS 路径则写 `<原文件名>_response.json` | | `CaptureResponse(captureID, responseBody)` | **全异步**:更新 DB `response_body` + 写 NFS 响应文件 |
| `nfsResponseFilePath(requestPath)` | `xxx.json``xxx_response.json` | | `nfsResponseFilePath(requestPath)` | `xxx.json``xxx_response.json` |
| `writeResponseToNFS(...)` | 写 `nfsResponseEnvelope{capture_id, created_at, body}` | | `writeResponseToNFS(...)` | 写 `nfsResponseEnvelope{capture_id, created_at, body}` |
**关键设计:** **关键设计:**
- `Capture()`**同步** DB 写入,保证返回 captureID 后立即可用
- `CaptureResponse()`**全异步**,不阻塞请求响应链路 - `Capture()`**同步** DB 写入,保证调用方拿到 captureID 后立即可用于 `CaptureResponse()`
- `sync.Map` 以 captureID 为 key 暂存 nfsFilePath,`LoadAndDelete` 一次性消费,避免内存泄漏 - `CaptureResponse()`**全异步**(goroutine),不阻塞响应链路
- `sync.Map``captureID` 为 key 暂存 nfsFilePath,`LoadAndDelete` 一次性消费,避免内存泄漏
- `captureID == 0``CaptureResponse()` 静默跳过(DB 写失败时的降级)
- NFS 响应文件 `Body` 字段类型为 `any`:先用 `json.Valid()` 判断——合法 JSON(非流式)用 `json.RawMessage` 保留结构,否则(流式纯文本)直接存 `string`,避免编码失败
**NFS 文件组织结构:** **NFS 文件组织结构:**
...@@ -90,19 +97,90 @@ REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5 ...@@ -90,19 +97,90 @@ REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5
└── {unixNano}_{requestID}_response.json ← 响应体 └── {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`(全新文件) ### 3.2 `backend/internal/repository/request_capture_log_repo.go`(全新文件)
```go ```go
// 实现 RequestCaptureLogRepository 接口 Create(ctx, params CreateRequestCaptureLogParams) (int64, error)
Create(ctx, params) (int64, error) UpdateResponseBody(ctx, id int64, responseBody string) error
UpdateResponseBody(ctx, id, responseBody) 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 ```go
// ResponseCaptureBuffer 流式响应中收集 assistant 文本,供 request_capture 使用。 // ResponseCaptureBuffer 流式响应中收集 assistant 文本,供 request_capture 使用。
...@@ -110,80 +188,184 @@ UpdateResponseBody(ctx, id, responseBody) error ...@@ -110,80 +188,184 @@ UpdateResponseBody(ctx, id, responseBody) error
ResponseCaptureBuffer Key = "ctx_response_capture_buffer" ResponseCaptureBuffer Key = "ctx_response_capture_buffer"
``` ```
流式请求的文本采集流程: **流式文本采集流程:**
``` ```
handler 注入 *strings.Builder 到 context 1. handler 判断 apiKey.CaptureRequests && captureID > 0
service streaming handler 追加 text_delta 2. 注入 *strings.Builder 到 context(WithValue)
Forward() / ForwardGemini() 读取 builder.String() 3. service streaming handler 解析 SSE 事件
在 content_block_delta + text_delta 事件中 captureBuilder.WriteString(text)
ForwardResult.ResponseBody 4. Forward() / forwardXxx() 读取 builder.String()
赋值给 ForwardResult.ResponseBody
handler CaptureResponse() 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;流式注入 `ResponseCaptureBuffer`Anthropic success path 调用 `CaptureResponse()` | | `service/bedrock_stream.go` | 从 context 读取 `captureBuilder``content_block_delta + text_delta` 中追加文本 |
| `handler/gateway_handler.go`(Gemini success path)| **新增** `CaptureResponse(captureID, result.ResponseBody)`(约第513行) | | `service/gateway_service.go``forwardBedrock`) | 非流式使用 `handleBedrockNonStreamingResponse` 返回的 string;流式读 context buffer;`ForwardResult.ResponseBody` 填充 |
| `service/gateway_service.go` | `ForwardResult` 新增 `ResponseBody string`;非流式读响应字节,流式读 context buffer | | `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()` | | `handler/gateway_handler.go` | 复用 6.1 的 `captureID` + `CaptureResponse` 调用 |
| `service/gateway_forward_as_chat_completions.go` | 非流式:marshal `ccResp``ResponseBody`;流式:`textBuilder` 收集 `Delta.Content` | | `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()` | | `handler/openai_chat_completions.go` | `Capture()` + `CaptureResponse()` |
| `service/openai_gateway_chat_completions.go` | 非流式:marshal `ccResp`;流式:`textBuilder` 收集 `Delta.Content` | | `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()` | | `handler/openai_gateway_handler.go``Responses` 函数) | `captureID` 变量声明保存返回值;success block 调用 `CaptureResponse()` |
| `service/openai_gateway_messages.go``handleAnthropicBufferedResponse`)| `c.JSON` 改为 `json.Marshal + c.Data`**填充 `ResponseBody`** | | `service/gateway_forward_as_responses.go` | 流式:`textBuilder` 收集 `content_block_delta + text_delta` |
| `service/openai_gateway_messages.go``handleAnthropicStreamingResponse`)| **新增** `textBuilder``processDataLine` 中捕获 `content_block_delta / text_delta``resultWithUsage()``ResponseBody: textBuilder.String()` |
### 5.5 `/v1/messages` → Antigravity(Gemini)账号(**本次新增**) ### 6.7 `POST /openai/v1/responses`(Messages path)→ Anthropic 路由
| 位置 | 变更 | | 文件 | 变更 |
|---|---| |---|---|
| `handler/gateway_handler.go` | 复用 5.1 的 `captureID` + `CaptureResponse` | | `handler/openai_gateway_handler.go``Messages` 函数) | **新增** `captureID` 声明及 `Capture()` 调用;success block 调用 `CaptureResponse()` |
| `service/antigravity_gateway_service.go` | `antigravityStreamResult` 新增 `responseBody string`;import 加 `ctxkey` | | `service/openai_gateway_messages.go``handleAnthropicBufferedResponse`) | `c.JSON` 改为 `json.Marshal + c.Data`,填充 `ResponseBody` |
| `handleClaudeStreamToNonStreaming` | `responseBody: string(claudeResp)` | | `service/openai_gateway_messages.go``handleAnthropicStreamingResponse`) | 新增 `textBuilder`;在 `processDataLine` 中捕获 `content_block_delta + text_delta``resultWithUsage()``ResponseBody` |
| `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` 填充 | ## 七、管理员 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` | | `handler/admin/apikey_handler.go` | 新增 `AdminSetCaptureRequestsRequest` 结构体 + `SetCaptureRequests` handler |
| `service/gemini_messages_compat_service.go` | `geminiStreamResult` 新增 `responseBody string` | | `service/admin_service.go` | 接口新增 `AdminSetCaptureRequests`;实现:`GetByID → Update → InvalidateAuthCacheByKey` |
| `handleStreamingResponse` | `responseBody: seenText`(函数内已有完整文本累积) | | `server/routes/admin.go` | `apiKeys.PUT("/:id/capture-requests", h.Admin.APIKey.SetCaptureRequests)` |
| `handleNonStreamingResponse` | 签名改为 `(string, *ClaudeUsage, error)``c.JSON` 改为 `json.Marshal + c.Data` | | `handler/admin/admin_service_stub_test.go` | 新增 stub 实现 |
| `Forward()` | 三条 non-streaming 子路径均填 `responseBody``ForwardResult.ResponseBody` 填充 |
### 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`**(已生成,确认包含) **`backend/cmd/server/wire_gen.go`**(已生成,确认包含)
...@@ -197,73 +379,88 @@ openAIGatewayHandler := handler.NewOpenAIGatewayHandler(..., requestCaptureServi ...@@ -197,73 +379,88 @@ openAIGatewayHandler := handler.NewOpenAIGatewayHandler(..., requestCaptureServi
--- ---
## 七、端点覆盖总览 ## 十、部署检查清单
| 端点 | 协议 | 请求捕获 | 响应捕获 | 部署前确认以下事项:
|---|---|---|---|
| `POST /v1/messages` → Anthropic 账号 | Claude | ✅ | ✅ | - [ ] 执行数据库迁移(`108_request_capture_log.sql`),确认 `api_keys.capture_requests` 列存在
| `POST /v1/messages` → CC 转发 | Claude→OpenAI CC | ✅ | ✅ | - [ ] 确认 `request_capture_logs` 分区表及当月分区已创建
| `POST /v1/messages` → Antigravity 账号 | Claude→Gemini | ✅ | ✅ | - [ ] 确认服务重启后日志中出现 `request_capture: NFS storage enabled/disabled`
| `POST /v1/messages` → GeminiCompat 账号 | Claude→Gemini | ✅ | ✅ | - [ ] 若需 NFS 落盘,确认环境变量 `REQUEST_CAPTURE_NFS_PATH` 已设置且目录可写
| `POST /openai/v1/chat/completions` | OpenAI CC | ✅ | ✅ | - [ ] 部署完成后通过脚本测试管理 API:`./capture_requests.sh <key_id> on`
| `POST /openai/v1/responses`(Codex path) | OpenAI Responses | ✅ | ✅ |
| `POST /openai/v1/responses`(Messages path) | OpenAI→Anthropic | ✅ | ✅ |
--- ---
## 、测试流程 ## 十一、测试与验证
### 8.1 前置条件 ### 11.1 前置条件
```bash ```bash
# 1. 执行数据库迁移 # 执行数据库迁移
psql -U sub2api -d sub2api -f backend/migrations/108_request_capture_log.sql psql -U sub2api -d sub2api -f backend/migrations/108_request_capture_log.sql
# 2. 配置(选其一) # 配置 NFS(可选,留空则只写 DB)
# 方式A:config.yaml 已有 request_capture 块,nfs_path 留空则只写 DB export REQUEST_CAPTURE_NFS_PATH=/tmp/nfs_test/
# 方式B:环境变量
export REQUEST_CAPTURE_NFS_PATH=/tmp/nfs_test/ # 留空则跳过 NFS
# 3. 给测试用 API Key 开启采集标志 # 开启测试用 Key 的采集(通过管理员 API,避免绕过缓存失效)
psql -U sub2api -d sub2api -c \ BASE_URL=http://localhost:8080 ADMIN_KEY=sk-admin \
"UPDATE api_keys SET capture_requests = true WHERE id = <your_key_id>;" ./capture_requests.sh <your_key_id> on
``` ```
### 8.2 测试矩阵 ### 11.2 测试矩阵
每个端点各跑一次**非流式****流式**请求。
```bash ```bash
KEY="your-capture-enabled-key" KEY="your-capture-enabled-key"
BASE="http://localhost:8080" BASE="http://localhost:8080"
# ── Claude 端点(非流式)── # Claude非流式调用
curl -s -X POST $BASE/v1/messages \ curl -s -X POST $BASE/v1/messages \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"model":"claude-3-5-haiku-20241022","max_tokens":50, -d '{"model":"claude-sonnet-4-6","max_tokens":50,
"messages":[{"role":"user","content":"say hi"}]}' "messages":[{"role":"user","content":"你到底是谁呀?"}]}'
# ── Claude 端点(流式)── # Claude流式调用
curl -s -X POST $BASE/v1/messages \ curl -s -X POST $BASE/v1/messages \
-H "x-api-key: $KEY" -H "Content-Type: application/json" \ -H "x-api-key: $KEY" -H "Content-Type: application/json" \
-d '{"model":"claude-3-5-haiku-20241022","max_tokens":50,"stream":true, -d '{"model":"claude-sonnet-4-6","max_tokens":50,"stream":true,
"messages":[{"role":"user","content":"say hi"}]}' "messages":[{"role":"user","content":"你用的什么模型"}]}'
# ── OpenAI Responses 端点 ── # OpenAI非流式调用
curl -s -X POST $BASE/openai/v1/responses \ curl -X POST "$BASE/v1/chat/completions" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -H "Authorization: Bearer $KEY" \
-d '{"model":"gpt-4o","input":[{"role":"user","content":"say hi"}]}' -H "Content-Type: application/json" \
-d '{
# ── OpenAI Chat Completions 端点 ── "model": "gpt-5.2",
curl -s -X POST $BASE/openai/v1/chat/completions \ "messages": [
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ {
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}]}' "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 ```sql
-- 请求后立即查(DB 写是同步的,应立即有记录) -- 1. 请求后立即查(DB 写是同步的,应立即有记录)
SELECT id, api_key_id, path, method, SELECT id, api_key_id, path, method,
(request_body IS NOT NULL) AS has_req, (request_body IS NOT NULL) AS has_req,
(response_body IS NOT NULL) AS has_resp, (response_body IS NOT NULL) AS has_resp,
...@@ -273,7 +470,7 @@ FROM request_capture_logs ...@@ -273,7 +470,7 @@ FROM request_capture_logs
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 10; LIMIT 10;
-- ② 等待 ~1s 后查响应体(CaptureResponse 是异步的) -- 2. 约 1 秒后查响应体(CaptureResponse 是异步的)
SELECT id, SELECT id,
length(request_body) AS req_len, length(request_body) AS req_len,
length(response_body) AS resp_len, length(response_body) AS resp_len,
...@@ -285,76 +482,78 @@ LIMIT 5; ...@@ -285,76 +482,78 @@ LIMIT 5;
**预期结果:** **预期结果:**
| 场景 | has_req | has_resp | resp_preview | | 场景 | has_req | has_resp(~1s 后) | resp_preview |
|---|---|---|---| |---|:---:|:---:|---|
| 非流式请求 | `true` | `true`(~1s 内) | JSON,以 `{` 开头 | | 非流式请求 | `true` | `true` | JSON,以 `{` 开头 |
| 流式请求 | `true` | `true`(流结束后) | 纯文本,如 `"Hi! How can I..."` | | 流式请求 | `true` | `true` | 纯文本,如 `"Hi! How can I..."` |
| 中文流式请求 | `true` | `true` | 中文字符(无乱码) |
| 未开启 capture_requests 的 Key | 无记录 | — | — | | 未开启 capture_requests 的 Key | 无记录 | — | — |
### 8.4 NFS 验证(配置了 `nfs_path` 时) ### 11.4 NFS 验证(配置了 `nfs_path` 时)
```bash ```bash
DATE=$(date +%Y-%m-%d) DATE=$(date +%Y-%m-%d)
API_KEY_ID=<your_key_id> API_KEY_ID=<your_key_id>
NFS_DIR="${REQUEST_CAPTURE_NFS_PATH}/${DATE}/${API_KEY_ID}" NFS_DIR="${REQUEST_CAPTURE_NFS_PATH}/${DATE}/${API_KEY_ID}"
# 查看文件列表(应有请求文件和响应文件成对出现) # 查看文件列表(请求文件和响应文件成对出现)
ls -la "$NFS_DIR/" ls -la "$NFS_DIR/"
# 查看请求文件结构 # 验证 JSON 格式
cat "$NFS_DIR/"*.json | python3 -m json.tool | head -20 cat "$NFS_DIR/"*.json | python3 -m json.tool | head -30
# 查看响应文件结构(_response 后缀) # 验证中文无乱码
cat "$NFS_DIR/"*_response.json | python3 -m json.tool | head -20 grep -r "你好" "$NFS_DIR/" && echo "中文正常"
``` ```
**预期文件结构:** ### 11.5 管理员 API 验证(关键:验证缓存失效是否生效)
```json ```bash
// 请求文件:{unixNano}_{requestID}.json # 1. 开启
{ ./capture_requests.sh 35 on
"api_key_id": 42, # 发一条请求,等 1 秒,查 DB 是否有记录
"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": [...] }
}
// 响应文件:{unixNano}_{requestID}_response.json # 2. 关闭
{ ./capture_requests.sh 35 off
"capture_id": 123, # 再发一条请求,查 DB 新记录数不应增加
"created_at": "2024-01-01T00:00:01Z",
"body": { "id": "msg_xxx", "content": [...] } // 非流式为完整JSON;流式为纯文本字符串 # 验证关闭生效(应无新记录)
} 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 ```bash
NO_CAPTURE_KEY="your-normal-key" NO_CAPTURE_KEY="your-normal-key"
curl -s -X POST $BASE/v1/messages \ 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, -d '{"model":"claude-3-5-haiku-20241022","max_tokens":10,
"messages":[{"role":"user","content":"hi"}]}' "messages":[{"role":"user","content":"hi"}]}'
``` ```
```sql ```sql
-- 该 key 对应的 api_key_id 不应有任何记录 -- 该 key 对应的 api_key_id 不应有任何记录
SELECT count(*) FROM request_capture_logs SELECT count(*) FROM request_capture_logs WHERE api_key_id = <no_capture_key_id>;
WHERE api_key_id = <no_capture_key_id>;
-- 预期:0 -- 预期:0
``` ```
### 8.6 异常场景 ---
## 十二、已知限制与边界行为
| 场景 | 预期行为 | | 场景 | 行为 |
|---|---| |---|---|
| DB 写 request 失败 | `captureID = 0`,后续 `CaptureResponse` 自动跳过,请求正常返回 | | DB 写 request 失败 | `captureID = 0`,后续 `CaptureResponse` 自动跳过,请求正常返回 |
| DB 写 response 失败 | 记录 error 日志,请求已正常返回,不影响用户 | | DB 写 response 失败 | 记录 error 日志,请求已正常返回,不影响用户 |
| NFS 目录不存在 | `MkdirAll` 自动创建;失败则 error 日志,不影响 DB 写入 | | NFS 目录不存在 | `MkdirAll` 自动创建;失败则 error 日志,不影响 DB 写入 |
| 流式请求客户端中途断开 | buffer 内已采集的文本会被写入,响应体为截断内容(属预期行为)| | 流式请求客户端中途断开 | buffer 内已采集的文本会被写入,响应体为截断内容(属预期行为) |
| `captureID` 泄漏(CaptureResponse 从未被调用)| `sync.Map` 中的条目会滞留,但量级等同于并发请求数,可忽略 | | `captureID` 对应 `CaptureResponse` 从未被调用 | `sync.Map` 中的条目滞留,但量级等同于并发请求数,可忽略 |
| 直接 SQL 修改 `capture_requests` 字段 | 缓存不失效,最多等 5 分钟(Redis TTL)才生效;**应通过管理 API 操作** |
| 流式纯文本响应写入 NFS | `json.Valid()` 判断后以 string 类型存入 `body` 字段,不会引起编码错误 |
| 中文字符 | `json.Encoder` 设置 `SetEscapeHTML(false)`,中文原样存储,不转义为 `\uXXXX` |
...@@ -18,7 +18,7 @@ set -euo pipefail ...@@ -18,7 +18,7 @@ set -euo pipefail
# ---------- 默认配置(可通过环境变量覆盖)---------- # ---------- 默认配置(可通过环境变量覆盖)----------
DEFAULT_BASE_URL="https://s2a-st.appbym.com" 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}" BASE_URL="${BASE_URL:-$DEFAULT_BASE_URL}"
ADMIN_KEY="${ADMIN_KEY:-$DEFAULT_ADMIN_KEY}" ADMIN_KEY="${ADMIN_KEY:-$DEFAULT_ADMIN_KEY}"
...@@ -90,13 +90,13 @@ echo "" ...@@ -90,13 +90,13 @@ echo ""
HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \
-X PUT "$ENDPOINT" \ -X PUT "$ENDPOINT" \
-H "Authorization: Bearer $ADMIN_KEY" \ -H "x-api-key: $ADMIN_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"enabled\": $ENABLED}") -d "{\"enabled\": $ENABLED}")
# 分离响应体和状态码 # 分离响应体和状态码
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d') HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d' | tr -d '\r')
HTTP_CODE=$(echo "$HTTP_RESPONSE" | tail -n 1) HTTP_CODE=$(echo "$HTTP_RESPONSE" | tail -n 1 | tr -d '\r')
# ---------- 结果输出 ---------- # ---------- 结果输出 ----------
echo "HTTP 状态码: $HTTP_CODE" echo "HTTP 状态码: $HTTP_CODE"
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment