diff --git a/internal/domain/backup.go b/internal/domain/backup.go index 1ad19a4f..d889fca4 100644 --- a/internal/domain/backup.go +++ b/internal/domain/backup.go @@ -85,6 +85,7 @@ type BackupAPIToken struct { Description string `json:"description"` ProjectSlug string `json:"projectSlug"` // empty = global IsEnabled bool `json:"isEnabled"` + DevMode bool `json:"devMode"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` } diff --git a/internal/domain/model.go b/internal/domain/model.go index 925af17e..2f26b545 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -353,6 +353,9 @@ type ProxyRequest struct { // 使用的 API Token ID,0 表示未使用 Token APITokenID uint64 `json:"apiTokenID"` + + // 是否开发者模式请求(由 Token 开关决定) + DevMode bool `json:"devMode"` } type ProxyUpstreamAttempt struct { @@ -722,6 +725,9 @@ type APIToken struct { // 是否启用 IsEnabled bool `json:"isEnabled"` + // 开发者模式(开启时该令牌请求详情永久保留) + DevMode bool `json:"devMode"` + // 过期时间,nil 表示永不过期 ExpiresAt *time.Time `json:"expiresAt,omitempty"` diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a24451c0..2fa74c67 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -314,7 +314,12 @@ func (e *Executor) processAdapterEvents(eventChan domain.AdapterEventChan, attem // processAdapterEventsRealtime processes events in real-time during adapter execution // It broadcasts updates immediately when RequestInfo/ResponseInfo are received -func (e *Executor) processAdapterEventsRealtime(eventChan domain.AdapterEventChan, attempt *domain.ProxyUpstreamAttempt, done chan struct{}) { +func (e *Executor) processAdapterEventsRealtime( + eventChan domain.AdapterEventChan, + attempt *domain.ProxyUpstreamAttempt, + done chan struct{}, + clearDetail bool, +) { defer close(done) if eventChan == nil || attempt == nil { @@ -352,12 +357,12 @@ func (e *Executor) processAdapterEventsRealtime(eventChan domain.AdapterEventCha switch ev.Type { case domain.EventRequestInfo: - if !e.shouldClearRequestDetail() && ev.RequestInfo != nil { + if !clearDetail && ev.RequestInfo != nil { attempt.RequestInfo = ev.RequestInfo dirty = true } case domain.EventResponseInfo: - if !e.shouldClearRequestDetail() && ev.ResponseInfo != nil { + if !clearDetail && ev.ResponseInfo != nil { attempt.ResponseInfo = ev.ResponseInfo dirty = true } @@ -407,7 +412,15 @@ func (e *Executor) getRequestDetailRetentionSeconds() int { return seconds } -// shouldClearRequestDetail 检查是否应该立即清理请求详情 +// shouldClearRequestDetailFor 检查是否应该立即清理请求详情(考虑 Token 开发者模式) +func (e *Executor) shouldClearRequestDetailFor(state *execState) bool { + if state != nil && state.apiTokenDevMode { + return false + } + return e.shouldClearRequestDetail() +} + +// shouldClearRequestDetail 检查是否应该立即清理请求详情(全局配置) // 当设置为 0 时返回 true func (e *Executor) shouldClearRequestDetail() bool { return e.getRequestDetailRetentionSeconds() == 0 diff --git a/internal/executor/flow_state.go b/internal/executor/flow_state.go index 0508eb6d..b05d6161 100644 --- a/internal/executor/flow_state.go +++ b/internal/executor/flow_state.go @@ -22,6 +22,7 @@ type execState struct { requestModel string isStream bool apiTokenID uint64 + apiTokenDevMode bool requestBody []byte originalRequestBody []byte requestHeaders http.Header diff --git a/internal/executor/middleware_dispatch.go b/internal/executor/middleware_dispatch.go index 3d025cdb..cd4d7330 100644 --- a/internal/executor/middleware_dispatch.go +++ b/internal/executor/middleware_dispatch.go @@ -25,6 +25,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { proxyReq := state.proxyReq ctx := state.ctx + clearDetail := e.shouldClearRequestDetailFor(state) for _, matchedRoute := range state.routes { if ctx.Err() != nil { @@ -127,7 +128,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { c.Set(flow.KeyEventChan, eventChan) c.Set(flow.KeyBroadcaster, e.broadcaster) eventDone := make(chan struct{}) - go e.processAdapterEventsRealtime(eventChan, attemptRecord, eventDone) + go e.processAdapterEventsRealtime(eventChan, attemptRecord, eventDone, clearDetail) var responseWriter http.ResponseWriter var convertingWriter *ConvertingResponseWriter @@ -179,7 +180,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { attemptRecord.Multiplier = result.Multiplier } - if e.shouldClearRequestDetail() { + if clearDetail { attemptRecord.RequestInfo = nil attemptRecord.ResponseInfo = nil } @@ -200,7 +201,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { proxyReq.Multiplier = attemptRecord.Multiplier proxyReq.ResponseModel = mappedModel - if !e.shouldClearRequestDetail() { + if !clearDetail { proxyReq.ResponseInfo = &domain.ResponseInfo{ Status: responseCapture.StatusCode(), Headers: responseCapture.CapturedHeaders(), @@ -220,10 +221,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { proxyReq.Cost = attemptRecord.Cost proxyReq.TTFT = attemptRecord.TTFT - if e.shouldClearRequestDetail() { - proxyReq.RequestInfo = nil - proxyReq.ResponseInfo = nil - } + clearProxyRequestDetail(proxyReq, clearDetail) _ = e.proxyRequestRepo.Update(proxyReq) if e.broadcaster != nil { @@ -265,7 +263,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { attemptRecord.Multiplier = result.Multiplier } - if e.shouldClearRequestDetail() { + if clearDetail { attemptRecord.RequestInfo = nil attemptRecord.ResponseInfo = nil } @@ -282,7 +280,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { if responseCapture.Body() != "" { proxyReq.StatusCode = responseCapture.StatusCode() - if !e.shouldClearRequestDetail() { + if !clearDetail { proxyReq.ResponseInfo = &domain.ResponseInfo{ Status: responseCapture.StatusCode(), Headers: responseCapture.CapturedHeaders(), @@ -301,6 +299,8 @@ func (e *Executor) dispatch(c *flow.Ctx) { proxyReq.Cost = attemptRecord.Cost proxyReq.TTFT = attemptRecord.TTFT + clearProxyRequestDetail(proxyReq, clearDetail) + _ = e.proxyRequestRepo.Update(proxyReq) if e.broadcaster != nil { e.broadcaster.BroadcastProxyRequest(proxyReq) @@ -318,6 +318,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { } else { proxyReq.Error = ctx.Err().Error() } + clearProxyRequestDetail(proxyReq, clearDetail) _ = e.proxyRequestRepo.Update(proxyReq) if e.broadcaster != nil { e.broadcaster.BroadcastProxyRequest(proxyReq) @@ -365,6 +366,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { } else { proxyReq.Error = ctx.Err().Error() } + clearProxyRequestDetail(proxyReq, clearDetail) _ = e.proxyRequestRepo.Update(proxyReq) if e.broadcaster != nil { e.broadcaster.BroadcastProxyRequest(proxyReq) @@ -384,10 +386,7 @@ func (e *Executor) dispatch(c *flow.Ctx) { if state.lastErr != nil { proxyReq.Error = state.lastErr.Error() } - if e.shouldClearRequestDetail() { - proxyReq.RequestInfo = nil - proxyReq.ResponseInfo = nil - } + clearProxyRequestDetail(proxyReq, clearDetail) _ = e.proxyRequestRepo.Update(proxyReq) if e.broadcaster != nil { e.broadcaster.BroadcastProxyRequest(proxyReq) @@ -399,3 +398,11 @@ func (e *Executor) dispatch(c *flow.Ctx) { state.ctx = ctx c.Err = state.lastErr } + +func clearProxyRequestDetail(req *domain.ProxyRequest, clearDetail bool) { + if !clearDetail || req == nil { + return + } + req.RequestInfo = nil + req.ResponseInfo = nil +} diff --git a/internal/executor/middleware_ingress.go b/internal/executor/middleware_ingress.go index 00a959d7..27859f99 100644 --- a/internal/executor/middleware_ingress.go +++ b/internal/executor/middleware_ingress.go @@ -51,6 +51,11 @@ func (e *Executor) ingress(c *flow.Ctx) { state.apiTokenID = id } } + if v, ok := c.Get(flow.KeyAPITokenDevMode); ok { + if devMode, ok := v.(bool); ok { + state.apiTokenDevMode = devMode + } + } if v, ok := c.Get(flow.KeyRequestBody); ok { if body, ok := v.([]byte); ok { state.requestBody = body @@ -86,9 +91,11 @@ func (e *Executor) ingress(c *flow.Ctx) { IsStream: state.isStream, Status: "PENDING", APITokenID: state.apiTokenID, + DevMode: state.apiTokenDevMode, } - if !e.shouldClearRequestDetail() { + clearDetail := e.shouldClearRequestDetailFor(state) + if !clearDetail { requestURI := state.requestURI requestHeaders := state.requestHeaders requestBody := state.requestBody diff --git a/internal/flow/keys.go b/internal/flow/keys.go index caaec1cf..af533381 100644 --- a/internal/flow/keys.go +++ b/internal/flow/keys.go @@ -18,6 +18,7 @@ const ( KeyRequestURI = "request_uri" KeyIsStream = "is_stream" KeyAPITokenID = "api_token_id" + KeyAPITokenDevMode = "api_token_dev_mode" KeyProxyRequest = "proxy_request" KeyUpstreamAttempt = "upstream_attempt" KeyEventChan = "event_chan" diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 521e300c..f18dadc1 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -1069,6 +1069,7 @@ func (h *AdminHandler) handleAPITokens(w http.ResponseWriter, r *http.Request, i Description *string `json:"description"` ProjectID *uint64 `json:"projectID"` IsEnabled *bool `json:"isEnabled"` + DevMode *bool `json:"devMode"` ExpiresAt *string `json:"expiresAt"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -1091,6 +1092,9 @@ func (h *AdminHandler) handleAPITokens(w http.ResponseWriter, r *http.Request, i if body.IsEnabled != nil { existing.IsEnabled = *body.IsEnabled } + if body.DevMode != nil { + existing.DevMode = *body.DevMode + } if body.ExpiresAt != nil { if *body.ExpiresAt == "" { existing.ExpiresAt = nil diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go index 88489d19..9f5cbecd 100644 --- a/internal/handler/proxy.go +++ b/internal/handler/proxy.go @@ -144,6 +144,7 @@ func (h *ProxyHandler) ingress(c *flow.Ctx) { if apiToken != nil { apiTokenID = apiToken.ID log.Printf("[Proxy] Token authenticated: id=%d, name=%s, projectID=%d", apiToken.ID, apiToken.Name, apiToken.ProjectID) + c.Set(flow.KeyAPITokenDevMode, apiToken.DevMode) } } diff --git a/internal/repository/sqlite/api_token.go b/internal/repository/sqlite/api_token.go index 2f4d0429..686d7ab3 100644 --- a/internal/repository/sqlite/api_token.go +++ b/internal/repository/sqlite/api_token.go @@ -39,6 +39,7 @@ func (r *APITokenRepository) Update(t *domain.APIToken) error { "description": LongText(t.Description), "project_id": t.ProjectID, "is_enabled": boolToInt(t.IsEnabled), + "dev_mode": boolToInt(t.DevMode), "expires_at": toTimestampPtr(t.ExpiresAt), }).Error } @@ -115,6 +116,7 @@ func (r *APITokenRepository) toModel(t *domain.APIToken) *APIToken { Description: LongText(t.Description), ProjectID: t.ProjectID, IsEnabled: boolToInt(t.IsEnabled), + DevMode: boolToInt(t.DevMode), ExpiresAt: toTimestampPtr(t.ExpiresAt), LastUsedAt: toTimestampPtr(t.LastUsedAt), UseCount: t.UseCount, @@ -133,6 +135,7 @@ func (r *APITokenRepository) toDomain(m *APIToken) *domain.APIToken { Description: string(m.Description), ProjectID: m.ProjectID, IsEnabled: m.IsEnabled == 1, + DevMode: m.DevMode == 1, ExpiresAt: fromTimestampPtr(m.ExpiresAt), LastUsedAt: fromTimestampPtr(m.LastUsedAt), UseCount: m.UseCount, diff --git a/internal/repository/sqlite/models.go b/internal/repository/sqlite/models.go index 4cb956e5..a1725252 100644 --- a/internal/repository/sqlite/models.go +++ b/internal/repository/sqlite/models.go @@ -139,6 +139,7 @@ type APIToken struct { Description LongText ProjectID uint64 IsEnabled int `gorm:"default:1"` + DevMode int `gorm:"default:0"` ExpiresAt int64 LastUsedAt int64 UseCount uint64 @@ -227,6 +228,7 @@ type ProxyRequest struct { StatusCode int ProjectID uint64 APITokenID uint64 + DevMode int `gorm:"default:0"` } func (ProxyRequest) TableName() string { return "proxy_requests" } diff --git a/internal/repository/sqlite/proxy_request.go b/internal/repository/sqlite/proxy_request.go index 96fdbe68..dd70d58f 100644 --- a/internal/repository/sqlite/proxy_request.go +++ b/internal/repository/sqlite/proxy_request.go @@ -426,7 +426,7 @@ func (r *ProxyRequestRepository) ClearDetailOlderThan(before time.Time) (int64, now := time.Now().UnixMilli() result := r.db.gorm.Model(&ProxyRequest{}). - Where("created_at < ? AND (request_info IS NOT NULL OR response_info IS NOT NULL)", beforeTs). + Where("created_at < ? AND (request_info IS NOT NULL OR response_info IS NOT NULL) AND dev_mode = 0", beforeTs). Updates(map[string]any{ "request_info": nil, "response_info": nil, @@ -474,6 +474,7 @@ func (r *ProxyRequestRepository) toModel(p *domain.ProxyRequest) *ProxyRequest { Multiplier: p.Multiplier, Cost: p.Cost, APITokenID: p.APITokenID, + DevMode: boolToInt(p.DevMode), } } @@ -513,6 +514,7 @@ func (r *ProxyRequestRepository) toDomain(m *ProxyRequest) *domain.ProxyRequest Multiplier: m.Multiplier, Cost: m.Cost, APITokenID: m.APITokenID, + DevMode: m.DevMode == 1, } } diff --git a/internal/repository/sqlite/proxy_upstream_attempt.go b/internal/repository/sqlite/proxy_upstream_attempt.go index 6c828773..12566b1b 100644 --- a/internal/repository/sqlite/proxy_upstream_attempt.go +++ b/internal/repository/sqlite/proxy_upstream_attempt.go @@ -241,8 +241,13 @@ func (r *ProxyUpstreamAttemptRepository) ClearDetailOlderThan(before time.Time) beforeTs := toTimestamp(before) now := time.Now().UnixMilli() + devModeOffRequests := r.db.gorm.Model(&ProxyRequest{}). + Select("id"). + Where("dev_mode = 0") + result := r.db.gorm.Model(&ProxyUpstreamAttempt{}). Where("created_at < ? AND (request_info IS NOT NULL OR response_info IS NOT NULL)", beforeTs). + Where("proxy_request_id IN (?)", devModeOffRequests). Updates(map[string]any{ "request_info": nil, "response_info": nil, diff --git a/internal/service/backup.go b/internal/service/backup.go index ee0f7a84..2986b078 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -194,6 +194,7 @@ func (s *BackupService) Export() (*domain.BackupFile, error) { Description: t.Description, ProjectSlug: projectIDToSlug[t.ProjectID], IsEnabled: t.IsEnabled, + DevMode: t.DevMode, ExpiresAt: t.ExpiresAt, }) } @@ -739,6 +740,7 @@ func (s *BackupService) importAPITokens(tokens []domain.BackupAPIToken, opts dom Description: bt.Description, ProjectID: projectID, IsEnabled: bt.IsEnabled, + DevMode: bt.DevMode, ExpiresAt: bt.ExpiresAt, } diff --git a/web/src/lib/transport/types.ts b/web/src/lib/transport/types.ts index b6f725cc..ca66dd80 100644 --- a/web/src/lib/transport/types.ts +++ b/web/src/lib/transport/types.ts @@ -616,6 +616,7 @@ export interface APIToken { description: string; projectID: number; isEnabled: boolean; + devMode: boolean; expiresAt?: string; lastUsedAt?: string; useCount: number; @@ -804,6 +805,7 @@ export interface BackupAPIToken { description: string; projectSlug: string; isEnabled: boolean; + devMode?: boolean; expiresAt?: string; } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f8f815a1..35b155af 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -807,6 +807,9 @@ "enableAuthPrompt": "Enable API Token Authentication to require valid tokens for proxy requests. This adds an extra layer of security by ensuring only authorized clients can access the proxy.", "tokenName": "Name", "tokenPrefix": "Token Prefix", + "devMode": "Dev Mode", + "devModeEnabled": "Dev Mode", + "devModeDisabled": "Normal", "project": "Project", "usage": "Usage", "lastUsed": "Last Used", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 98ed6e58..101b2df8 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -807,6 +807,9 @@ "enableAuthPrompt": "启用 API 令牌身份验证以要求代理请求使用有效令牌。这通过确保只有授权客户端可以访问代理来增加额外的安全层。", "tokenName": "名称", "tokenPrefix": "令牌前缀", + "devMode": "开发者模式", + "devModeEnabled": "开发者模式", + "devModeDisabled": "普通模式", "project": "项目", "usage": "使用次数", "lastUsed": "最后使用", diff --git a/web/src/pages/api-tokens/index.tsx b/web/src/pages/api-tokens/index.tsx index b7e452ff..cfc47da2 100644 --- a/web/src/pages/api-tokens/index.tsx +++ b/web/src/pages/api-tokens/index.tsx @@ -139,6 +139,13 @@ export function APITokensPage() { }); }; + const handleToggleDevMode = (token: APIToken) => { + updateToken.mutate({ + id: token.id, + data: { devMode: !token.devMode }, + }); + }; + const handleDelete = () => { if (!deletingToken) return; deleteToken.mutate(deletingToken.id, { @@ -261,6 +268,7 @@ export function APITokensPage() { {t('apiTokens.tokenPrefix')} {t('apiTokens.project')} {t('common.status')} + {t('apiTokens.devMode')} {t('apiTokens.usage')} {t('apiTokens.lastUsed')} {t('common.actions')} @@ -324,6 +332,27 @@ export function APITokensPage() { )} + +
+ handleToggleDevMode(token)} + disabled={updateToken.isPending} + /> + {token.devMode ? ( + + {t('apiTokens.devModeEnabled')} + + ) : ( + + {t('apiTokens.devModeDisabled')} + + )} +
+