feat(proxy): 集中代理 URL 验证并实现全局 fail-fast

提取 proxyurl.Parse() 公共包,将分散在 6 处的代理 URL 验证逻辑
统一收敛,确保无效代理配置在创建时立即失败,永不静默回退直连。

主要变更:
- 新增 proxyurl 包:统一 TrimSpace → url.Parse → Host 校验 → Scheme 白名单
- socks5:// 自动升级为 socks5h://,防止 DNS 泄漏(大小写不敏感)
- antigravity: http.ProxyURL → proxyutil.ConfigureTransportProxy 支持 SOCKS5
- openai_oauth: 删除 newOpenAIOAuthHTTPClient,收编至 httpclient.GetClient
- 移除未使用的 ProxyStrict 字段(fail-fast 已是全局默认行为)
- 补充 15 个 proxyurl 测试 + pricing/usage fail-fast 测试
This commit is contained in:
QTom
2026-03-02 15:53:26 +08:00
parent 445bfdf242
commit fdcbf7aacf
31 changed files with 633 additions and 157 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
@@ -28,11 +29,14 @@ func NewClaudeOAuthClient() service.ClaudeOAuthClient {
type claudeOAuthService struct {
baseURL string
tokenURL string
clientFactory func(proxyURL string) *req.Client
clientFactory func(proxyURL string) (*req.Client, error)
}
func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) {
client := s.clientFactory(proxyURL)
client, err := s.clientFactory(proxyURL)
if err != nil {
return "", fmt.Errorf("create HTTP client: %w", err)
}
var orgs []struct {
UUID string `json:"uuid"`
@@ -88,7 +92,10 @@ func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey
}
func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) {
client := s.clientFactory(proxyURL)
client, err := s.clientFactory(proxyURL)
if err != nil {
return "", fmt.Errorf("create HTTP client: %w", err)
}
authURL := fmt.Sprintf("%s/v1/oauth/%s/authorize", s.baseURL, orgUUID)
@@ -165,7 +172,10 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
}
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
client := s.clientFactory(proxyURL)
client, err := s.clientFactory(proxyURL)
if err != nil {
return nil, fmt.Errorf("create HTTP client: %w", err)
}
// Parse code which may contain state in format "authCode#state"
authCode := code
@@ -223,7 +233,10 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
}
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
client := s.clientFactory(proxyURL)
client, err := s.clientFactory(proxyURL)
if err != nil {
return nil, fmt.Errorf("create HTTP client: %w", err)
}
reqBody := map[string]any{
"grant_type": "refresh_token",
@@ -253,16 +266,20 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
return &tokenResp, nil
}
func createReqClient(proxyURL string) *req.Client {
func createReqClient(proxyURL string) (*req.Client, error) {
// 禁用 CookieJar确保每次授权都是干净的会话
client := req.C().
SetTimeout(60 * time.Second).
ImpersonateChrome().
SetCookieJar(nil) // 禁用 CookieJar
if strings.TrimSpace(proxyURL) != "" {
client.SetProxyURL(strings.TrimSpace(proxyURL))
trimmed, _, err := proxyurl.Parse(proxyURL)
if err != nil {
return nil, err
}
if trimmed != "" {
client.SetProxyURL(trimmed)
}
return client
return client, nil
}