feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理
- ModelChannel 新增 proxy_url 字段,v48 迁移自动加列 - outbound 新增 OutboundHTTPClientWithProxy,socks5/socks5h/http/https 均支持; 目标主机名不做本地解析,直接交给代理服务器远端解析,本地开启 TUN/VPN 时仍可用 - 系统代理、Provider 出站、管理员模型测试路径均使用渠道级代理 - 渠道管理表单新增"代理地址"输入框,留空回退全局代理
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<ChannelHeadersEditor />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="proxyUrl"
|
||||
label="代理地址"
|
||||
extra="为该渠道单独配置出站代理,支持 socks5://、http://、https://。留空则使用全局环境变量代理。"
|
||||
>
|
||||
<Input placeholder="例如:socks5://127.0.0.1:1080 或 http://127.0.0.1:7890" />
|
||||
</Form.Item>
|
||||
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -88,6 +88,7 @@ export type ModelChannel = {
|
||||
hasApiKey?: boolean;
|
||||
hasSecretKey?: boolean;
|
||||
concurrencyLimit?: number;
|
||||
proxyUrl?: string;
|
||||
modelCosts?: Array<{
|
||||
model: string;
|
||||
displayName?: string;
|
||||
|
||||
Reference in New Issue
Block a user