From 9df34abd6b012abb92628acc6c9ff62265d3d194 Mon Sep 17 00:00:00 2001 From: dxiaom <1473919287@qq.com> Date: Sat, 10 Oct 2026 00:59:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20=E6=B8=A0=E9=81=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=A8=A1=E5=9E=8B=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=B8=A0=E9=81=93=E7=BA=A7=20SOCKS5/HTTP=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelChannel 新增 proxy_url 字段,v48 迁移自动加列 - outbound 新增 OutboundHTTPClientWithProxy,socks5/socks5h/http/https 均支持; 目标主机名不做本地解析,直接交给代理服务器远端解析,本地开启 TUN/VPN 时仍可用 - 系统代理、Provider 出站、管理员模型测试路径均使用渠道级代理 - 渠道管理表单新增"代理地址"输入框,留空回退全局代理 --- backend/internal/app/admin.go | 7 ++ .../internal/app/channel_models_test_call.go | 2 +- backend/internal/app/channel_outbound.go | 6 +- backend/internal/app/outbound_alias.go | 4 ++ backend/internal/app/provider_errors.go | 6 ++ backend/internal/app/provider_http_client.go | 4 +- backend/internal/database/migrations.go | 8 ++- backend/internal/handler/auth.go | 2 +- backend/internal/model/models_channel.go | 5 +- backend/internal/outbound/outbound.go | 69 +++++++++++++++++++ backend/internal/service/aliases_funcs.go | 1 + .../pages/admin/channels/channels-page.tsx | 12 +++- web/src/stores/use-config-store.ts | 1 + 13 files changed, 119 insertions(+), 8 deletions(-) diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 0d2ce2f34..9f365916a 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 } @@ -255,6 +261,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model APIKey: apiKey, APIFormat: channel.APIFormat, ConcurrencyLimit: channel.ConcurrencyLimit, + ProxyURL: channel.ProxyURL, Models: models, ModelCosts: modelCosts, Headers: headers, 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/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/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; From d590d435b9cfa3a3994577cac2bbdaba0e0b5185 Mon Sep 17 00:00:00 2001 From: dxiaom <1473919287@qq.com> Date: Sat, 10 Oct 2026 01:03:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(admin):=20=E6=B8=A0=E9=81=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E7=94=9F=E6=88=90=E7=BB=93=E6=9E=9C=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E4=B8=8B=E8=BD=BD=E8=B7=9F=E9=9A=8F=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=BA=A7=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/task_media_storage.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 }