Commit 3b7a5fff authored by 陈曦's avatar 陈曦
Browse files

补充openai、gemini以及流失请求的采集数据以及nfs落库

parent 8519a8eb
Pipeline #82284 failed with stage
in 2 minutes and 21 seconds
docs/claude-relay-service/
.codex
# ===================
# Go 后端
......
# Request/Response Capture 功能变更报告
> 功能:对指定 API Key 开启请求体与响应体的双路采集,支持数据库和 NFS 两种存储方式,覆盖 Claude、OpenAI 及 Gemini 全部网关路径。
---
## 一、数据库迁移
**文件:`backend/migrations/108_request_capture_log.sql`**
```sql
-- api_keys 新增开关字段
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS capture_requests boolean NOT NULL DEFAULT false;
-- 按月分区的捕获日志表
CREATE TABLE IF NOT EXISTS request_capture_logs (
id bigserial NOT NULL,
api_key_id bigint NOT NULL,
user_id bigint NOT NULL,
request_id varchar(64),
path varchar(100),
method varchar(10),
ip_address varchar(45),
request_body text,
response_body text,
nfs_file_path varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX IF NOT EXISTS idx_rcl_api_key_created ON request_capture_logs (api_key_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rcl_user_id ON request_capture_logs (user_id);
```
预建前、当、后三个月分区,后续需定期维护新分区。
---
## 二、配置
### 2.1 `backend/internal/config/config.go`(新增结构体)
```go
type RequestCaptureConfig struct {
NFSPath string `mapstructure:"nfs_path"`
WorkerTimeoutSeconds int `mapstructure:"worker_timeout_seconds"`
}
```
### 2.2 `backend/config.yaml`(新增块)
```yaml
request_capture:
nfs_path: "/app/logs/nfs/"
worker_timeout_seconds: 5
```
### 2.3 `deploy/.env.example`(新增两行)
```env
REQUEST_CAPTURE_NFS_PATH=
REQUEST_CAPTURE_WORKER_TIMEOUT_SECONDS=5
```
---
## 三、核心服务
### 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` |
| `nfsResponseFilePath(requestPath)` | `xxx.json``xxx_response.json` |
| `writeResponseToNFS(...)` | 写 `nfsResponseEnvelope{capture_id, created_at, body}` |
**关键设计:**
- `Capture()`**同步** DB 写入,保证返回 captureID 后立即可用
- `CaptureResponse()`**全异步**,不阻塞请求响应链路
- `sync.Map` 以 captureID 为 key 暂存 nfsFilePath,`LoadAndDelete` 一次性消费,避免内存泄漏
**NFS 文件组织结构:**
```
{nfsPath}/
└── {YYYY-MM-DD}/
└── {apiKeyID}/
├── {unixNano}_{requestID}.json ← 请求体
└── {unixNano}_{requestID}_response.json ← 响应体
```
### 3.2 `backend/internal/repository/request_capture_log_repo.go`(全新文件)
```go
// 实现 RequestCaptureLogRepository 接口
Create(ctx, params) (int64, error)
UpdateResponseBody(ctx, id, responseBody) error
```
---
## 四、Context Key
**`backend/internal/pkg/ctxkey/ctxkey.go`(新增)**
```go
// ResponseCaptureBuffer 流式响应中收集 assistant 文本,供 request_capture 使用。
// 值类型为 *strings.Builder,由 handler 层注入,service 层只负责追加文本。
ResponseCaptureBuffer Key = "ctx_response_capture_buffer"
```
流式请求的文本采集流程:
```
handler 注入 *strings.Builder 到 context
service streaming handler 追加 text_delta
Forward() / ForwardGemini() 读取 builder.String()
ForwardResult.ResponseBody
handler CaptureResponse()
```
---
## 五、各端点覆盖详情
### 5.1 `/v1/messages` → Anthropic 账号
| 位置 | 变更 |
|---|---|
| `handler/gateway_handler.go` | `Capture()` 保存 captureID;流式注入 `ResponseCaptureBuffer`;Anthropic success path 调用 `CaptureResponse()` |
| `handler/gateway_handler.go`(Gemini success path)| **新增** `CaptureResponse(captureID, result.ResponseBody)`(约第513行) |
| `service/gateway_service.go` | `ForwardResult` 新增 `ResponseBody string`;非流式读响应字节,流式读 context buffer |
### 5.2 `/v1/messages` → CC 转发(Anthropic CC 协议)
| 位置 | 变更 |
|---|---|
| `handler/gateway_handler_chat_completions.go` | `Capture()` + `CaptureResponse()` |
| `service/gateway_forward_as_chat_completions.go` | 非流式:marshal `ccResp``ResponseBody`;流式:`textBuilder` 收集 `Delta.Content` |
### 5.3 `/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`(**本次重点修复**)
**问题:** `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()` |
### 5.5 `/v1/messages` → Antigravity(Gemini)账号(**本次新增**)
| 位置 | 变更 |
|---|---|
| `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` 填充 |
### 5.6 `/v1/messages` → GeminiMessagesCompat 账号(**本次新增**)
| 位置 | 变更 |
|---|---|
| `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` 填充 |
---
## 六、依赖注入
**`backend/cmd/server/wire_gen.go`**(已生成,确认包含)
```go
requestCaptureLogRepository := repository.NewRequestCaptureLogRepository(client)
requestCaptureService := service.NewRequestCaptureService(requestCaptureLogRepository, configConfig)
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 | ✅ | ✅ |
---
## 八、测试流程
### 8.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
# 3. 给测试用 API Key 开启采集标志
psql -U sub2api -d sub2api -c \
"UPDATE api_keys SET capture_requests = true WHERE id = <your_key_id>;"
```
### 8.2 测试矩阵
每个端点各跑一次**非流式****流式**请求。
```bash
KEY="your-capture-enabled-key"
BASE="http://localhost:8080"
# ── 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"}]}'
# ── 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"}]}'
```
### 8.3 DB 验证
```sql
-- ① 请求后立即查(DB 写入是同步的,应立即有记录)
SELECT id, api_key_id, path, method,
(request_body IS NOT NULL) AS has_req,
(response_body IS NOT NULL) AS has_resp,
nfs_file_path,
created_at
FROM request_capture_logs
ORDER BY created_at DESC
LIMIT 10;
-- ② 等待 ~1s 后查响应体(CaptureResponse 是异步的)
SELECT id,
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
LIMIT 5;
```
**预期结果:**
| 场景 | has_req | has_resp | resp_preview |
|---|---|---|---|
| 非流式请求 | `true` | `true`(~1s 内) | JSON,以 `{` 开头 |
| 流式请求 | `true` | `true`(流结束后) | 纯文本,如 `"Hi! How can I..."` |
| 未开启 capture_requests 的 Key | 无记录 | — | — |
### 8.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 -20
# 查看响应文件结构(_response 后缀)
cat "$NFS_DIR/"*_response.json | python3 -m json.tool | head -20
```
**预期文件结构:**
```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": [...] }
}
// 响应文件:{unixNano}_{requestID}_response.json
{
"capture_id": 123,
"created_at": "2024-01-01T00:00:01Z",
"body": { "id": "msg_xxx", "content": [...] } // 非流式为完整JSON;流式为纯文本字符串
}
```
### 8.5 对照组(负向验证)
```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" \
-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>;
-- 预期:0
```
### 8.6 异常场景
| 场景 | 预期行为 |
|---|---|
| DB 写 request 失败 | `captureID = 0`,后续 `CaptureResponse` 自动跳过,请求正常返回 |
| DB 写 response 失败 | 记录 error 日志,请求已正常返回,不影响用户 |
| NFS 目录不存在 | `MkdirAll` 自动创建;失败则 error 日志,不影响 DB 写入 |
| 流式请求客户端中途断开 | buffer 内已采集的文本会被写入,响应体为截断内容(属预期行为)|
| `captureID` 泄漏(CaptureResponse 从未被调用)| `sync.Map` 中的条目会滞留,但量级等同于并发请求数,可忽略 |
......@@ -33,7 +33,7 @@ func main() {
}()
userRepo := repository.NewUserRepository(client, sqlDB)
authService := service.NewAuthService(client, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
authService := service.NewAuthService(client, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
......
......@@ -69,7 +69,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService)
affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
......@@ -80,7 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpCache := repository.NewTotpCache(redisClient)
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
......@@ -91,6 +93,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
......@@ -192,7 +197,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
registry := payment.ProvideRegistry()
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
......@@ -221,21 +226,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
sqlDB, err := repository.ProvideSQLDB(client)
if err != nil {
return nil, err
}
channelMonitorRepository := repository.NewChannelMonitorRepository(client, sqlDB)
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, sqlDB)
channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
availableChannelUserHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
requestCaptureLogRepository := repository.NewRequestCaptureLogRepository(client)
requestCaptureService := service.NewRequestCaptureService(requestCaptureLogRepository, configConfig)
......@@ -247,9 +244,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelUserHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
......@@ -265,6 +263,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
application := &Application{
Server: httpServer,
......
......@@ -652,6 +652,7 @@ func (h *AccountHandler) Delete(c *gin.Context) {
type TestAccountRequest struct {
ModelID string `json:"model_id"`
Prompt string `json:"prompt"`
Mode string `json:"mode"`
}
type SyncFromCRSRequest struct {
......@@ -682,7 +683,7 @@ func (h *AccountHandler) Test(c *gin.Context) {
_ = c.ShouldBindJSON(&req)
// Use AccountTestService to test the account with SSE streaming
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt); err != nil {
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil {
// Error already sent via SSE, just log
return
}
......
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AffiliateHandler handles admin affiliate (邀请返利) management:
// listing users with custom settings, updating per-user invite codes
// and exclusive rebate rates, and batch operations.
type AffiliateHandler struct {
affiliateService *service.AffiliateService
adminService service.AdminService
}
// NewAffiliateHandler creates a new admin affiliate handler.
func NewAffiliateHandler(affiliateService *service.AffiliateService, adminService service.AdminService) *AffiliateHandler {
return &AffiliateHandler{
affiliateService: affiliateService,
adminService: adminService,
}
}
// ListUsers returns paginated users with custom affiliate settings.
// GET /api/v1/admin/affiliates/users
func (h *AffiliateHandler) ListUsers(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
search := c.Query("search")
entries, total, err := h.affiliateService.AdminListCustomUsers(c.Request.Context(), service.AffiliateAdminFilter{
Search: search,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, entries, total, page, pageSize)
}
// UpdateUserSettings updates a user's affiliate settings.
// PUT /api/v1/admin/affiliates/users/:user_id
//
// Both fields are optional and applied independently.
type UpdateAffiliateUserRequest struct {
AffCode *string `json:"aff_code"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
// ClearRebateRate explicitly clears the per-user rate (sets it to NULL).
// Used to disambiguate from "field not provided".
ClearRebateRate bool `json:"clear_rebate_rate"`
}
func (h *AffiliateHandler) UpdateUserSettings(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
var req UpdateAffiliateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.AffCode != nil {
if err := h.affiliateService.AdminUpdateUserAffCode(c.Request.Context(), userID, *req.AffCode); err != nil {
response.ErrorFrom(c, err)
return
}
}
if req.ClearRebateRate {
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
response.ErrorFrom(c, err)
return
}
} else if req.AffRebateRatePercent != nil {
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, req.AffRebateRatePercent); err != nil {
response.ErrorFrom(c, err)
return
}
}
response.Success(c, gin.H{"user_id": userID})
}
// ClearUserSettings removes ALL of a user's custom affiliate settings — clears
// the exclusive rebate rate AND regenerates the invite code as a new system
// random one. Conceptually this "removes the user from the custom list".
//
// Both writes happen in this handler; failure of one leaves the other applied,
// but the operation is idempotent so the admin can re-run it safely.
// DELETE /api/v1/admin/affiliates/users/:user_id
func (h *AffiliateHandler) ClearUserSettings(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
if err != nil || userID <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
response.ErrorFrom(c, err)
return
}
if _, err := h.affiliateService.AdminResetUserAffCode(c.Request.Context(), userID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"user_id": userID})
}
// BatchSetRate applies the same rebate rate (or clears it) to multiple users.
//
// Protocol: pass `clear: true` to clear rates (aff_rebate_rate_percent is
// ignored). Otherwise aff_rebate_rate_percent is required and applied to
// every user_id. The explicit `clear` flag exists because Go's JSON unmarshal
// can't distinguish a missing field from `null`, and a silent clear from a
// frontend that forgot to include the rate would be a footgun.
//
// POST /api/v1/admin/affiliates/users/batch-rate
type BatchSetRateRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required"`
AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
Clear bool `json:"clear"`
}
func (h *AffiliateHandler) BatchSetRate(c *gin.Context) {
var req BatchSetRateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.UserIDs) == 0 {
response.BadRequest(c, "user_ids cannot be empty")
return
}
if !req.Clear && req.AffRebateRatePercent == nil {
response.BadRequest(c, "aff_rebate_rate_percent is required unless clear=true")
return
}
rate := req.AffRebateRatePercent
if req.Clear {
rate = nil
}
if err := h.affiliateService.AdminBatchSetUserRebateRate(c.Request.Context(), req.UserIDs, rate); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": len(req.UserIDs)})
}
// AffiliateUserSummary is the minimal user shape returned by LookupUsers,
// shared with the frontend's add-custom-user picker.
type AffiliateUserSummary struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
// LookupUsers searches users by email/username for the "add custom user" modal.
// GET /api/v1/admin/affiliates/users/lookup?q=
func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
keyword := c.Query("q")
if keyword == "" {
response.Success(c, []AffiliateUserSummary{})
return
}
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 20, service.UserListFilters{Search: keyword}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
result := make([]AffiliateUserSummary, len(users))
for i, u := range users {
result[i] = AffiliateUserSummary{ID: u.ID, Email: u.Email, Username: u.Username}
}
response.Success(c, result)
}
......@@ -185,6 +185,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: settings.EnableModelFallback,
......@@ -241,6 +245,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
}
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
}
......@@ -338,6 +344,10 @@ type UpdateSettingsRequest struct {
// 默认配置
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
......@@ -439,6 +449,9 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
}
// UpdateSettings 更新系统设置
......@@ -468,6 +481,43 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.DefaultBalance < 0 {
req.DefaultBalance = 0
}
affiliateRebateRate := previousSettings.AffiliateRebateRate
if req.AffiliateRebateRate != nil {
affiliateRebateRate = *req.AffiliateRebateRate
}
if affiliateRebateRate < service.AffiliateRebateRateMin {
affiliateRebateRate = service.AffiliateRebateRateMin
}
if affiliateRebateRate > service.AffiliateRebateRateMax {
affiliateRebateRate = service.AffiliateRebateRateMax
}
affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours
if req.AffiliateRebateFreezeHours != nil {
affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours
}
if affiliateRebateFreezeHours < 0 {
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault
}
if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax {
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax
}
affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays
if req.AffiliateRebateDurationDays != nil {
affiliateRebateDurationDays = *req.AffiliateRebateDurationDays
}
if affiliateRebateDurationDays < 0 {
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault
}
if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax {
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax
}
affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap
if req.AffiliateRebatePerInviteeCap != nil {
affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap
}
if affiliateRebatePerInviteeCap < 0 {
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if req.TableDefaultPageSize <= 0 {
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
......@@ -1119,6 +1169,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance,
AffiliateRebateRate: affiliateRebateRate,
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
AffiliateRebateDurationDays: affiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
DefaultSubscriptions: defaultSubscriptions,
EnableModelFallback: req.EnableModelFallback,
......@@ -1252,6 +1306,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
}
return previousSettings.AffiliateEnabled
}(),
}
authSourceDefaults := &service.AuthSourceDefaultSettings{
......@@ -1433,6 +1493,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance,
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
DefaultSubscriptions: updatedDefaultSubscriptions,
EnableModelFallback: updatedSettings.EnableModelFallback,
......@@ -1488,6 +1552,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
}
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
}
......@@ -1738,6 +1804,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.DefaultBalance != after.DefaultBalance {
changed = append(changed, "default_balance")
}
if before.AffiliateRebateRate != after.AffiliateRebateRate {
changed = append(changed, "affiliate_rebate_rate")
}
if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours {
changed = append(changed, "affiliate_rebate_freeze_hours")
}
if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays {
changed = append(changed, "affiliate_rebate_duration_days")
}
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
changed = append(changed, "affiliate_rebate_per_invitee_cap")
}
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
changed = append(changed, "default_subscriptions")
}
......@@ -1853,6 +1931,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed
}
......
......@@ -48,6 +48,7 @@ type RegisterRequest struct {
TurnstileToken string `json:"turnstile_token"`
PromoCode string `json:"promo_code"` // 注册优惠码
InvitationCode string `json:"invitation_code"` // 邀请码
AffCode string `json:"aff_code"` // 邀请返利码
}
// SendVerifyCodeRequest 发送验证码请求
......@@ -164,7 +165,15 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
_, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode, req.InvitationCode)
_, user, err := h.authService.RegisterWithVerification(
c.Request.Context(),
req.Email,
req.Password,
req.VerifyCode,
req.PromoCode,
req.InvitationCode,
req.AffCode,
)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -435,6 +435,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
type completeLinuxDoOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
......@@ -518,7 +519,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -67,6 +67,7 @@ type createPendingOAuthAccountRequest struct {
VerifyCode string `json:"verify_code,omitempty"`
Password string `json:"password" binding:"required,min=6"`
InvitationCode string `json:"invitation_code,omitempty"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
......@@ -1751,6 +1752,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
user,
strings.TrimSpace(req.InvitationCode),
strings.TrimSpace(session.ProviderType),
strings.TrimSpace(req.AffCode),
); err != nil {
_ = tx.Rollback()
if rollbackCreatedUser(err) {
......
......@@ -2210,6 +2210,7 @@ CREATE TABLE IF NOT EXISTS user_avatars (
nil,
nil,
options.defaultSubAssigner,
nil,
)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
var totpSvc *service.TotpService
......
......@@ -582,6 +582,7 @@ func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
type completeOIDCOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
......@@ -665,7 +666,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -35,7 +35,7 @@ func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
ExpireHour: 1,
},
}
authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil)
authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil, nil)
handler := &AuthHandler{authService: authService}
recorder := httptest.NewRecorder()
......
......@@ -481,6 +481,7 @@ func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService
type completeWeChatOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
......@@ -547,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -1399,6 +1399,7 @@ func newWeChatOAuthTestHandlerWithSettings(t *testing.T, invitationEnabled bool,
nil,
nil,
nil,
nil,
)
return &AuthHandler{
......
......@@ -108,6 +108,10 @@ type SystemSettings struct {
DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"`
AffiliateRebateRate float64 `json:"affiliate_rebate_rate"`
AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"`
AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"`
AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"`
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
......@@ -191,6 +195,9 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
}
type DefaultSubscriptionSetting struct {
......@@ -243,6 +250,8 @@ type PublicSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO
......
......@@ -175,6 +175,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
reqStream := parsedReq.Stream
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
// 若 request_capture 已启用且为流式请求,注入响应体采集 buffer 到 context
// service 层的 handleStreamingResponse 会将 text_delta 内容写入此 buffer
if captureID > 0 && reqStream {
captureRespBuilder := &strings.Builder{}
c.Request = c.Request.WithContext(
context.WithValue(c.Request.Context(), ctxkey.ResponseCaptureBuffer, captureRespBuilder),
)
}
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
......@@ -500,6 +509,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
}
// 异步写入响应体到捕获记录
if captureID > 0 && h.requestCaptureService != nil {
h.requestCaptureService.CaptureResponse(captureID, result.ResponseBody)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
h.submitUsageRecordTask(func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
......
......@@ -62,9 +62,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
}
// 异步捕获请求体(仅当该 API Key 开启了 capture_requests)
var captureID int64
if apiKey.CaptureRequests && h.requestCaptureService != nil {
requestID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
h.requestCaptureService.Capture(
captureID = h.requestCaptureService.Capture(
apiKey.ID, subject.UserID,
requestID,
c.Request.URL.Path,
......@@ -267,6 +268,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
// 异步写入响应体到捕获记录
if captureID > 0 && h.requestCaptureService != nil {
h.requestCaptureService.CaptureResponse(captureID, result.ResponseBody)
}
h.submitUsageRecordTask(func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
......
......@@ -34,6 +34,7 @@ type AdminHandlers struct {
ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
}
// Handlers contains all HTTP handlers
......
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