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() {