diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 0d2ce2f34..aae99a2a2 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -93,6 +93,7 @@ type ChannelRequest struct { Models []string `json:"models"` Headers []OutboundHeader `json:"headers"` Enabled *bool `json:"enabled"` + ProxyURL string `json:"proxyUrl"` } type PublicModelChannel struct { @@ -106,6 +107,7 @@ type PublicModelChannel struct { APIKey string `json:"apiKey"` APIFormat string `json:"apiFormat"` ConcurrencyLimit int `json:"concurrencyLimit"` + ProxyURL string `json:"proxyUrl"` Models []string `json:"models"` ModelCosts []PublicChannelModelPrice `json:"modelCosts"` Headers []OutboundHeader `json:"headers,omitempty"` @@ -187,6 +189,7 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan } channel.ModelsJSON = string(modelsJSON) channel.HeadersJSON = headersJSON + channel.ProxyURL = strings.TrimSpace(req.ProxyURL) if req.Enabled != nil { channel.Enabled = *req.Enabled } @@ -206,6 +209,9 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel if req.Headers == nil { req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON) } + if strings.TrimSpace(req.ProxyURL) == "" { + req.ProxyURL = channel.ProxyURL + } return req } @@ -232,6 +238,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } apiKey := "" baseURL := channel.BaseURL + proxyURL := "" var headers []OutboundHeader if channel.Scope == model.ChannelScopeSystem { if !admin { @@ -244,6 +251,9 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } else if admin { apiKey = channel.APIKey } + if admin { + proxyURL = channel.ProxyURL + } return PublicModelChannel{ ID: channel.ID, UserID: channel.UserID, @@ -255,6 +265,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model APIKey: apiKey, APIFormat: channel.APIFormat, ConcurrencyLimit: channel.ConcurrencyLimit, + ProxyURL: proxyURL, Models: models, ModelCosts: modelCosts, Headers: headers, diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index dea966670..7712e658b 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -189,16 +189,22 @@ func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) { } } -func TestPublicChannelOnlyReturnsSystemHeadersToAdmin(t *testing.T) { - channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`} +func TestPublicChannelOnlyReturnsSystemHeadersAndProxyToAdmin(t *testing.T) { + channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`, ProxyURL: "socks5://user:secret@127.0.0.1:1080"} adminView := publicChannel(channel, true, nil) if len(adminView.Headers) != 1 || adminView.Headers[0].Name != "X-Gateway-Tenant" { t.Fatalf("admin headers = %#v", adminView.Headers) } + if adminView.ProxyURL != channel.ProxyURL { + t.Fatalf("admin proxy URL = %q", adminView.ProxyURL) + } userView := publicChannel(channel, false, nil) if len(userView.Headers) != 0 { t.Fatalf("user headers = %#v", userView.Headers) } + if userView.ProxyURL != "" { + t.Fatalf("user proxy URL = %q", userView.ProxyURL) + } } func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) { diff --git a/backend/internal/app/channel_models_test_call.go b/backend/internal/app/channel_models_test_call.go index effb32ae7..a1ae55e5c 100644 --- a/backend/internal/app/channel_models_test_call.go +++ b/backend/internal/app/channel_models_test_call.go @@ -112,7 +112,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, defer cancel() testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{ Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability, - Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, + Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, ProxyURL: channel.ProxyURL, }) testCtx = withProtocolRegistry(testCtx, s.protocolRegistry()) startedAt := time.Now() diff --git a/backend/internal/app/channel_outbound.go b/backend/internal/app/channel_outbound.go index 7355962a9..9753dd734 100644 --- a/backend/internal/app/channel_outbound.go +++ b/backend/internal/app/channel_outbound.go @@ -12,6 +12,8 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) { return ValidateOutboundURL(rawURL) } -func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL) *http.Client { - return OutboundHTTPClient(timeout) +// OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。 +// proxyURL 非空时使用渠道级代理(socks5/http/https),否则回退到全局客户端。 +func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client { + return OutboundHTTPClientWithProxy(timeout, proxyURL) } diff --git a/backend/internal/app/outbound_alias.go b/backend/internal/app/outbound_alias.go index 61df35279..be699048b 100644 --- a/backend/internal/app/outbound_alias.go +++ b/backend/internal/app/outbound_alias.go @@ -30,6 +30,10 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client { return outbound.OutboundHTTPClient(timeout) } +func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.Client { + return outbound.OutboundHTTPClientWithProxy(timeout, proxyURL) +} + func ApplyDefaultOutboundHeaders(req *http.Request) { outbound.ApplyDefaultOutboundHeaders(req) } diff --git a/backend/internal/app/provider_errors.go b/backend/internal/app/provider_errors.go index 90ac49e13..9dca0403e 100644 --- a/backend/internal/app/provider_errors.go +++ b/backend/internal/app/provider_errors.go @@ -78,6 +78,7 @@ type providerAnalyticsContext struct { Capability string Operation string ChannelID string + ProxyURL string Model string VideoSeconds int RequestKind string @@ -107,6 +108,11 @@ func withProviderAnalytics(ctx context.Context, service *Service, task model.Tas if normalized := normalizeCapability(input.Mode); normalized != "" { metadata.Capability = normalized } + if metadata.ChannelID != "" && service != nil { + if ch, err := service.repo.SystemChannel(metadata.ChannelID); err == nil { + metadata.ProxyURL = ch.ProxyURL + } + } } return context.WithValue(ctx, providerAnalyticsKey{}, metadata) } diff --git a/backend/internal/app/provider_http_client.go b/backend/internal/app/provider_http_client.go index 503ae93ee..d7d8a261f 100644 --- a/backend/internal/app/provider_http_client.go +++ b/backend/internal/app/provider_http_client.go @@ -257,10 +257,12 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by cacheManagementRequest := requestKind == "cache-create" || requestKind == "cache-delete" responseLimit := maxProviderResponseBytes channelID := "" + channelProxyURL := "" if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil { runtimeService = metadata.Service coordinator = metadata.Service.coordinator channelID = metadata.ChannelID + channelProxyURL = metadata.ProxyURL policy, err := metadata.Service.RuntimePolicy() if err != nil { err = fmt.Errorf("读取生成资源限制失败:%w", err) @@ -302,7 +304,7 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by return nil, "", err } ApplyDefaultOutboundHeaders(req) - client := OutboundHTTPClient(requestTimeout) + client := OutboundHTTPClientWithProxy(requestTimeout, channelProxyURL) resp, err := client.Do(req) if err != nil { err = providerConnectionError(err) diff --git a/backend/internal/app/task_media_storage.go b/backend/internal/app/task_media_storage.go index 77f8eb9f6..3e14a6fb6 100644 --- a/backend/internal/app/task_media_storage.go +++ b/backend/internal/app/task_media_storage.go @@ -130,7 +130,18 @@ func (s *Service) downloadTaskMedia(ctx context.Context, config providerConfig, ApplyOutboundHeaders(req, config.Headers) } ApplyDefaultOutboundHeaders(req) - response, err := OutboundHTTPClient(3 * time.Minute).Do(req) + // 结果文件可能托管在渠道同域(sameProviderOrigin 时还带渠道鉴权), + // 渠道配置了出站代理时下载必须走同一代理,否则配代理渠道的结果无法取回。 + proxyURL := "" + if metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok { + proxyURL = metadata.ProxyURL + } + if strings.TrimSpace(proxyURL) == "" && strings.TrimSpace(config.ChannelID) != "" { + if ch, err := s.repo.SystemChannel(config.ChannelID); err == nil { + proxyURL = ch.ProxyURL + } + } + response, err := OutboundHTTPClientWithProxy(3*time.Minute, proxyURL).Do(req) if err != nil { return "", "", err } diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 4cc3666aa..7599fa228 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 47 +const CurrentSchemaVersion int64 = 48 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -151,6 +151,12 @@ var schemaMigrations = []migration{ }}, {version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration}, {version: 47, name: "skill_curation_roots", checksum: "sha256:skill-curation-roots-v47-20261005", apply: migrateSkillCurationRoots}, + {version: 48, name: "channel_proxy_url", checksum: "sha256:channel-proxy-url-v48-20261009", apply: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&model.ModelChannel{}, "ProxyURL") { + return tx.Migrator().AddColumn(&model.ModelChannel{}, "ProxyURL") + } + return nil + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 2a41c79ee..e65203e6e 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -434,7 +434,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us status := model.ApiCallStatusSucceeded statusCode := 0 errorText := "" - resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget).Do(upstreamReq) + resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.ProxyURL).Do(upstreamReq) if err != nil { status = model.ApiCallStatusFailed errorText = err.Error() diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index fec005805..4c5f66f50 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -20,7 +20,10 @@ type ModelChannel struct { SecretKey string `json:"-"` APIFormat string `json:"apiFormat" gorm:"size:24"` ConcurrencyLimit int `json:"concurrencyLimit"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` + // ProxyURL 是渠道级出站代理地址,支持 socks5://、http://、https://。 + // 留空时使用全局环境变量代理。 + ProxyURL string `json:"proxyUrl" gorm:"size:512"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 RetiredModelsJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"` diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 125e252ef..9f11f95c6 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -17,6 +17,7 @@ import ( "yingce/backend/internal/kernel" "golang.org/x/net/http/httpproxy" + "golang.org/x/net/proxy" ) const ( @@ -109,6 +110,30 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client { } } +// OutboundHTTPClientWithProxy 创建使用指定代理的出站客户端。 +// proxyURL 支持 socks5://、http://、https:// 格式;空字符串时回退到全局客户端。 +func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.Client { + proxyURL = strings.TrimSpace(proxyURL) + if proxyURL == "" { + return OutboundHTTPClient(timeout) + } + transport := newOutboundTransportWithProxy(proxyURL) + if transport == nil { + return OutboundHTTPClient(timeout) + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxOutboundRedirects { + return errors.New("外部服务重定向次数过多") + } + _, err := ValidateOutboundURL(req.URL.String()) + return err + }, + } +} + func CustomRelayHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ Transport: customRelayTransport, @@ -284,6 +309,50 @@ func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) { return httpproxy.FromEnvironment().ProxyFunc()(req.URL) } +// newOutboundTransportWithProxy 创建使用指定代理的 Transport。 +// 支持 socks5://、socks5h://、http://、https://;返回 nil 表示代理 URL 无效。 +// 目标主机名不做本地解析,直接交给代理服务器远端解析(socks5 与 socks5h 同义)。 +// 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代 +// 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。 +func newOutboundTransportWithProxy(proxyURL string) *http.Transport { + parsed, err := url.Parse(proxyURL) + if err != nil || parsed.Host == "" { + return nil + } + scheme := strings.ToLower(parsed.Scheme) + forwardDialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} + + transport := &http.Transport{ + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ExpectContinueTimeout: time.Second, + } + + switch scheme { + case "socks5", "socks5h": + transport.Proxy = nil + d, derr := proxy.FromURL(parsed, forwardDialer) + if derr != nil { + return nil + } + contextDialer, ok := d.(proxy.ContextDialer) + if !ok { + return nil + } + transport.DialContext = contextDialer.DialContext + case "http", "https": + transport.Proxy = http.ProxyURL(parsed) + transport.DialContext = forwardDialer.DialContext + default: + return nil + } + + return transport +} + func configuredProxyHost(host string) bool { host = normalizeOutboundHost(host) for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} { diff --git a/backend/internal/service/aliases_funcs.go b/backend/internal/service/aliases_funcs.go index f95b2b126..7fe6f35a2 100644 --- a/backend/internal/service/aliases_funcs.go +++ b/backend/internal/service/aliases_funcs.go @@ -47,6 +47,7 @@ var ( NormalizeOutboundHeaders = app.NormalizeOutboundHeaders NotFound = app.NotFound OutboundHTTPClient = app.OutboundHTTPClient + OutboundHTTPClientWithProxy = app.OutboundHTTPClientWithProxy ParseOutboundHeadersJSON = app.ParseOutboundHeadersJSON PaymentNotificationResponseFor = app.PaymentNotificationResponseFor PaymentNotificationResponseForWithRegistry = app.PaymentNotificationResponseForWithRegistry diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 4211cb0e6..6950bc7a7 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -31,7 +31,7 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex ## 结构迁移与版本 -当前结构版本为 42。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 +当前结构版本为 48。版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 9655d6fc6..d001ab62d 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -25,6 +25,7 @@ type ChannelFormValues = { useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean; + proxyUrl?: string; }; export function adminChannelSavePayload(values: ChannelFormValues) { @@ -37,6 +38,7 @@ export function adminChannelSavePayload(values: ChannelFormValues) { useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false, + proxyUrl: values.proxyUrl?.trim() || "", }; } @@ -129,8 +131,9 @@ export default function ChannelsPage() { useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false, + proxyUrl: channel.proxyUrl || "", } - : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true }, + : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true, proxyUrl: "" }, ); setDrawerOpen(true); }; @@ -393,6 +396,13 @@ export default function ChannelsPage() { + + + diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 5092cb426..f974d4d93 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -88,6 +88,7 @@ export type ModelChannel = { hasApiKey?: boolean; hasSecretKey?: boolean; concurrencyLimit?: number; + proxyUrl?: string; modelCosts?: Array<{ model: string; displayName?: string;