feat(admin): 渠道管理 - 支持渠道级出站代理

This commit is contained in:
ddcat666
2026-10-10 04:55:57 +08:00
16 changed files with 144 additions and 12 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {

View File

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

View File

@@ -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"`

View File

@@ -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"} {

View File

@@ -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

View File

@@ -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`,不替历史流程补造同意记录。

View File

@@ -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>

View File

@@ -88,6 +88,7 @@ export type ModelChannel = {
hasApiKey?: boolean;
hasSecretKey?: boolean;
concurrencyLimit?: number;
proxyUrl?: string;
modelCosts?: Array<{
model: string;
displayName?: string;