merge: 合并官方 upstream/main 的 6 个功能更新
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / golangci-lint (pull_request) Has been cancelled

合并内容:
1. feat(gateway): Claude Code 系统提示词智能注入
2. fix: 修复创建账号 schedulable 默认值为 false 的 bug
3. fix(frontend): 修复跨时区日期范围筛选问题
4. feat(proxy): SOCKS5H 代理支持(统一代理配置)
5. fix(oauth): 修复 Claude Cookie 添加账号时会话混淆
6. fix(test): 修复 OAuth 账号测试刷新 token 的 bug

新增文件:
- backend/internal/pkg/proxyutil/* (SOCKS5H 支持)
- backend/internal/service/gateway_prompt_test.go (测试)

来自 upstream: Wei-Shaw/sub2api commits d9b1587..a527559
This commit is contained in:
huangzhenpc
2026-01-04 18:48:05 +08:00
18 changed files with 10346 additions and 9797 deletions

View File

@@ -1,157 +1,138 @@
// Package httpclient 提供共享 HTTP 客户端池
//
// 性能优化说明:
// 原实现在多个服务中重复创建 http.Client
// 1. proxy_probe_service.go: 每次探测创建新客户端
// 2. pricing_service.go: 每次请求创建新客户端
// 3. turnstile_service.go: 每次验证创建新客户端
// 4. github_release_service.go: 每次请求创建新客户端
// 5. claude_usage_service.go: 每次请求创建新客户端
//
// 新实现使用统一的客户端池:
// 1. 相同配置复用同一 http.Client 实例
// 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销
// 3. 支持 HTTP/HTTPS/SOCKS5 代理
// 4. 支持严格代理模式(代理失败则返回错误
package httpclient
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/net/proxy"
)
// Transport 连接池默认配置
const (
defaultMaxIdleConns = 100 // 最大空闲连接
defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间
)
// Options 定义共享 HTTP 客户端的构建参数
type Options struct {
ProxyURL string // 代理 URL支持 http/https/socks5
Timeout time.Duration // 请求总超时时间
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
InsecureSkipVerify bool // 是否跳过 TLS 证书验证
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
// 可选的连接池参数(不设置则使用默认值
MaxIdleConns int // 最大空闲连接总数(默认 100
MaxIdleConnsPerHost int // 每主机最大空闲连接(默认 10
MaxConnsPerHost int // 每主机最大连接数(默认 0 无限制)
}
// sharedClients 存储按配置参数缓存的 http.Client 实例
var sharedClients sync.Map
// GetClient 返回共享的 HTTP 客户端实例
// 性能优化:相同配置复用同一客户端,避免重复创建 Transport
func GetClient(opts Options) (*http.Client, error) {
key := buildClientKey(opts)
if cached, ok := sharedClients.Load(key); ok {
if client, ok := cached.(*http.Client); ok {
return client, nil
}
}
client, err := buildClient(opts)
if err != nil {
if opts.ProxyStrict {
return nil, err
}
fallback := opts
fallback.ProxyURL = ""
client, _ = buildClient(fallback)
}
actual, _ := sharedClients.LoadOrStore(key, client)
if c, ok := actual.(*http.Client); ok {
return c, nil
}
return client, nil
}
func buildClient(opts Options) (*http.Client, error) {
transport, err := buildTransport(opts)
if err != nil {
return nil, err
}
return &http.Client{
Transport: transport,
Timeout: opts.Timeout,
}, nil
}
func buildTransport(opts Options) (*http.Transport, error) {
// 使用自定义值或默认值
maxIdleConns := opts.MaxIdleConns
if maxIdleConns <= 0 {
maxIdleConns = defaultMaxIdleConns
}
maxIdleConnsPerHost := opts.MaxIdleConnsPerHost
if maxIdleConnsPerHost <= 0 {
maxIdleConnsPerHost = defaultMaxIdleConnsPerHost
}
transport := &http.Transport{
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制
IdleConnTimeout: defaultIdleConnTimeout,
ResponseHeaderTimeout: opts.ResponseHeaderTimeout,
}
if opts.InsecureSkipVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
proxyURL := strings.TrimSpace(opts.ProxyURL)
if proxyURL == "" {
return transport, nil
}
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
transport.Proxy = http.ProxyURL(parsed)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(parsed, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy protocol: %s", parsed.Scheme)
}
return transport, nil
}
func buildClientKey(opts Options) string {
return fmt.Sprintf("%s|%s|%s|%t|%t|%d|%d|%d",
strings.TrimSpace(opts.ProxyURL),
opts.Timeout.String(),
opts.ResponseHeaderTimeout.String(),
opts.InsecureSkipVerify,
opts.ProxyStrict,
opts.MaxIdleConns,
opts.MaxIdleConnsPerHost,
opts.MaxConnsPerHost,
)
}
// Package httpclient 提供共享 HTTP 客户端池
//
// 性能优化说明:
// 原实现在多个服务中重复创建 http.Client
// 1. proxy_probe_service.go: 每次探测创建新客户端
// 2. pricing_service.go: 每次请求创建新客户端
// 3. turnstile_service.go: 每次验证创建新客户端
// 4. github_release_service.go: 每次请求创建新客户端
// 5. claude_usage_service.go: 每次请求创建新客户端
//
// 新实现使用统一的客户端池:
// 1. 相同配置复用同一 http.Client 实例
// 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销
// 3. 支持 HTTP/HTTPS/SOCKS5/SOCKS5H 代理
// 4. 代理配置失败时直接返回错误,不会回退到直连(避免 IP 关联风险
package httpclient
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
)
// Transport 连接池默认配置
const (
defaultMaxIdleConns = 100 // 最大空闲连接数
defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间
)
// Options 定义共享 HTTP 客户端的构建参数
type Options struct {
ProxyURL string // 代理 URL支持 http/https/socks5/socks5h
Timeout time.Duration // 请求总超时时间
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
InsecureSkipVerify bool // 是否跳过 TLS 证书验证
// 可选的连接池参数(不设置则使用默认值)
MaxIdleConns int // 最大空闲连接总数(默认 100
MaxIdleConnsPerHost int // 每主机最大空闲连接(默认 10
MaxConnsPerHost int // 每主机最大连接数(默认 0 无限制
}
// sharedClients 存储按配置参数缓存的 http.Client 实例
var sharedClients sync.Map
// GetClient 返回共享的 HTTP 客户端实例
// 性能优化:相同配置复用同一客户端,避免重复创建 Transport
// 安全说明:代理配置失败时直接返回错误,不会回退到直连,避免 IP 关联风险
func GetClient(opts Options) (*http.Client, error) {
key := buildClientKey(opts)
if cached, ok := sharedClients.Load(key); ok {
if client, ok := cached.(*http.Client); ok {
return client, nil
}
}
client, err := buildClient(opts)
if err != nil {
return nil, err
}
actual, _ := sharedClients.LoadOrStore(key, client)
if c, ok := actual.(*http.Client); ok {
return c, nil
}
return client, nil
}
func buildClient(opts Options) (*http.Client, error) {
transport, err := buildTransport(opts)
if err != nil {
return nil, err
}
return &http.Client{
Transport: transport,
Timeout: opts.Timeout,
}, nil
}
func buildTransport(opts Options) (*http.Transport, error) {
// 使用自定义值或默认值
maxIdleConns := opts.MaxIdleConns
if maxIdleConns <= 0 {
maxIdleConns = defaultMaxIdleConns
}
maxIdleConnsPerHost := opts.MaxIdleConnsPerHost
if maxIdleConnsPerHost <= 0 {
maxIdleConnsPerHost = defaultMaxIdleConnsPerHost
}
transport := &http.Transport{
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制
IdleConnTimeout: defaultIdleConnTimeout,
ResponseHeaderTimeout: opts.ResponseHeaderTimeout,
}
if opts.InsecureSkipVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
proxyURL := strings.TrimSpace(opts.ProxyURL)
if proxyURL == "" {
return transport, nil
}
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
return nil, err
}
return transport, nil
}
func buildClientKey(opts Options) string {
return fmt.Sprintf("%s|%s|%s|%t|%d|%d|%d",
strings.TrimSpace(opts.ProxyURL),
opts.Timeout.String(),
opts.ResponseHeaderTimeout.String(),
opts.InsecureSkipVerify,
opts.MaxIdleConns,
opts.MaxIdleConnsPerHost,
opts.MaxConnsPerHost,
)
}

View File

@@ -0,0 +1,62 @@
// Package proxyutil 提供统一的代理配置功能
//
// 支持的代理协议:
// - HTTP/HTTPS: 通过 Transport.Proxy 设置
// - SOCKS5/SOCKS5H: 通过 Transport.DialContext 设置(服务端解析 DNS
package proxyutil
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"golang.org/x/net/proxy"
)
// ConfigureTransportProxy 根据代理 URL 配置 Transport
//
// 支持的协议:
// - http/https: 设置 transport.Proxy
// - socks5/socks5h: 设置 transport.DialContext由代理服务端解析 DNS
//
// 参数:
// - transport: 需要配置的 http.Transport
// - proxyURL: 代理地址nil 表示直连
//
// 返回:
// - error: 代理配置错误(协议不支持或 dialer 创建失败)
func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error {
if proxyURL == nil {
return nil
}
scheme := strings.ToLower(proxyURL.Scheme)
switch scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
return nil
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return fmt.Errorf("create socks5 dialer: %w", err)
}
// 优先使用支持 context 的 DialContext以支持请求取消和超时
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
transport.DialContext = contextDialer.DialContext
} else {
// 回退路径:如果 dialer 不支持 ContextDialer则包装为简单的 DialContext
// 注意:此回退不支持请求取消和超时控制
transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
}
return nil
default:
return fmt.Errorf("unsupported proxy scheme: %s", scheme)
}
}

View File

@@ -0,0 +1,204 @@
package proxyutil
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigureTransportProxy_Nil(t *testing.T) {
transport := &http.Transport{}
err := ConfigureTransportProxy(transport, nil)
require.NoError(t, err)
assert.Nil(t, transport.Proxy, "nil proxy should not set Proxy")
assert.Nil(t, transport.DialContext, "nil proxy should not set DialContext")
}
func TestConfigureTransportProxy_HTTP(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse("http://proxy.example.com:8080")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.NotNil(t, transport.Proxy, "HTTP proxy should set Proxy")
assert.Nil(t, transport.DialContext, "HTTP proxy should not set DialContext")
}
func TestConfigureTransportProxy_HTTPS(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse("https://secure-proxy.example.com:8443")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.NotNil(t, transport.Proxy, "HTTPS proxy should set Proxy")
assert.Nil(t, transport.DialContext, "HTTPS proxy should not set DialContext")
}
func TestConfigureTransportProxy_SOCKS5(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse("socks5://socks.example.com:1080")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.Nil(t, transport.Proxy, "SOCKS5 proxy should not set Proxy")
assert.NotNil(t, transport.DialContext, "SOCKS5 proxy should set DialContext")
}
func TestConfigureTransportProxy_SOCKS5H(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse("socks5h://socks.example.com:1080")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.Nil(t, transport.Proxy, "SOCKS5H proxy should not set Proxy")
assert.NotNil(t, transport.DialContext, "SOCKS5H proxy should set DialContext")
}
func TestConfigureTransportProxy_CaseInsensitive(t *testing.T) {
testCases := []struct {
scheme string
useProxy bool // true = uses Transport.Proxy, false = uses DialContext
}{
{"HTTP://proxy.example.com:8080", true},
{"Http://proxy.example.com:8080", true},
{"HTTPS://proxy.example.com:8443", true},
{"Https://proxy.example.com:8443", true},
{"SOCKS5://socks.example.com:1080", false},
{"Socks5://socks.example.com:1080", false},
{"SOCKS5H://socks.example.com:1080", false},
{"Socks5h://socks.example.com:1080", false},
}
for _, tc := range testCases {
t.Run(tc.scheme, func(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse(tc.scheme)
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
if tc.useProxy {
assert.NotNil(t, transport.Proxy)
assert.Nil(t, transport.DialContext)
} else {
assert.Nil(t, transport.Proxy)
assert.NotNil(t, transport.DialContext)
}
})
}
}
func TestConfigureTransportProxy_Unsupported(t *testing.T) {
testCases := []string{
"ftp://ftp.example.com",
"file:///path/to/file",
"unknown://example.com",
}
for _, tc := range testCases {
t.Run(tc, func(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse(tc)
err := ConfigureTransportProxy(transport, proxyURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported proxy scheme")
})
}
}
func TestConfigureTransportProxy_WithAuth(t *testing.T) {
transport := &http.Transport{}
proxyURL, _ := url.Parse("socks5://user:password@socks.example.com:1080")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.NotNil(t, transport.DialContext, "SOCKS5 with auth should set DialContext")
}
func TestConfigureTransportProxy_EmptyScheme(t *testing.T) {
transport := &http.Transport{}
// 空 scheme 的 URL
proxyURL := &url.URL{Host: "proxy.example.com:8080"}
err := ConfigureTransportProxy(transport, proxyURL)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported proxy scheme")
}
func TestConfigureTransportProxy_PreservesExistingConfig(t *testing.T) {
// 验证代理配置不会覆盖 Transport 的其他配置
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
proxyURL, _ := url.Parse("socks5://socks.example.com:1080")
err := ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.Equal(t, 100, transport.MaxIdleConns, "MaxIdleConns should be preserved")
assert.Equal(t, 10, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost should be preserved")
assert.NotNil(t, transport.DialContext, "DialContext should be set")
}
func TestConfigureTransportProxy_IPv6(t *testing.T) {
testCases := []struct {
name string
proxyURL string
}{
{"SOCKS5H with IPv6 loopback", "socks5h://[::1]:1080"},
{"SOCKS5 with full IPv6", "socks5://[2001:db8::1]:1080"},
{"HTTP with IPv6", "http://[::1]:8080"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
transport := &http.Transport{}
proxyURL, err := url.Parse(tc.proxyURL)
require.NoError(t, err, "URL should be parseable")
err = ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
})
}
}
func TestConfigureTransportProxy_SpecialCharsInPassword(t *testing.T) {
testCases := []struct {
name string
proxyURL string
}{
// 密码包含 @ 符号URL 编码为 %40
{"password with @", "socks5://user:p%40ssword@proxy.example.com:1080"},
// 密码包含 : 符号URL 编码为 %3A
{"password with :", "socks5://user:pass%3Aword@proxy.example.com:1080"},
// 密码包含 / 符号URL 编码为 %2F
{"password with /", "socks5://user:pass%2Fword@proxy.example.com:1080"},
// 复杂密码
{"complex password", "socks5h://admin:P%40ss%3Aw0rd%2F123@proxy.example.com:1080"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
transport := &http.Transport{}
proxyURL, err := url.Parse(tc.proxyURL)
require.NoError(t, err, "URL should be parseable")
err = ConfigureTransportProxy(transport, proxyURL)
require.NoError(t, err)
assert.NotNil(t, transport.DialContext, "SOCKS5 should set DialContext")
})
}
}