diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index dca0b612..acb270a3 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "net/http" "net/url" "strings" @@ -53,6 +54,9 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie Post(s.tokenURL) if err != nil { + if shouldReturnOpenAINoProxyHint(ctx, proxyURL, err) { + return nil, newOpenAINoProxyHintError(err) + } return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err) } @@ -98,6 +102,9 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre Post(s.tokenURL) if err != nil { + if shouldReturnOpenAINoProxyHint(ctx, proxyURL, err) { + return nil, newOpenAINoProxyHintError(err) + } return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err) } @@ -114,3 +121,21 @@ func createOpenAIReqClient(proxyURL string) (*req.Client, error) { Timeout: 120 * time.Second, }) } + +func shouldReturnOpenAINoProxyHint(ctx context.Context, proxyURL string, err error) bool { + if strings.TrimSpace(proxyURL) != "" || err == nil { + return false + } + if ctx != nil && ctx.Err() != nil { + return false + } + return !errors.Is(err, context.Canceled) +} + +func newOpenAINoProxyHintError(cause error) error { + return infraerrors.New( + http.StatusBadGateway, + "OPENAI_OAUTH_PROXY_REQUIRED", + "OpenAI OAuth request failed: no proxy is configured and this server could not reach OpenAI directly. Select a proxy that can access OpenAI, then retry; if the authorization code has expired, regenerate the authorization URL.", + ).WithCause(cause) +} diff --git a/backend/internal/repository/openai_oauth_service_test.go b/backend/internal/repository/openai_oauth_service_test.go index c1901d71..b43e2b52 100644 --- a/backend/internal/repository/openai_oauth_service_test.go +++ b/backend/internal/repository/openai_oauth_service_test.go @@ -8,6 +8,7 @@ import ( "net/url" "testing" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -204,6 +205,17 @@ func (s *OpenAIOAuthServiceSuite) TestRequestError_ClosedServer() { require.ErrorContains(s.T(), err, "request failed") } +func (s *OpenAIOAuthServiceSuite) TestExchangeCode_RequestErrorWithoutProxyReturnsProxyHint() { + s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + s.srv.Close() + + _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "") + + require.Error(s.T(), err) + require.Equal(s.T(), "OPENAI_OAUTH_PROXY_REQUIRED", infraerrors.Reason(err)) + require.Contains(s.T(), infraerrors.Message(err), "no proxy is configured") +} + func (s *OpenAIOAuthServiceSuite) TestContextCancel() { started := make(chan struct{}) block := make(chan struct{}) diff --git a/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts b/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts index 3058819c..cf67aca4 100644 --- a/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts +++ b/frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts @@ -6,6 +6,19 @@ vi.mock('@/stores/app', () => ({ }) })) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => { + const messages: Record = { + 'admin.accounts.oauth.openai.failedToExchangeCode': 'OpenAI 授权码兑换失败', + 'admin.accounts.oauth.openai.errors.OPENAI_OAUTH_PROXY_REQUIRED': + '未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。' + } + return messages[key] ?? key + } + }) +})) + vi.mock('@/api/admin', () => ({ adminAPI: { accounts: { @@ -17,6 +30,7 @@ vi.mock('@/api/admin', () => ({ })) import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' +import { adminAPI } from '@/api/admin' describe('useOpenAIOAuth.buildCredentials', () => { it('should keep client_id when token response contains it', () => { @@ -46,3 +60,21 @@ describe('useOpenAIOAuth.buildCredentials', () => { expect(creds.refresh_token).toBe('rt') }) }) + +describe('useOpenAIOAuth.exchangeAuthCode', () => { + it('shows a clear proxy hint when code exchange fails without a proxy', async () => { + vi.mocked(adminAPI.accounts.exchangeCode).mockRejectedValueOnce({ + status: 502, + reason: 'OPENAI_OAUTH_PROXY_REQUIRED', + message: 'OpenAI OAuth token exchange failed: no proxy is configured.' + }) + const oauth = useOpenAIOAuth() + + const tokenInfo = await oauth.exchangeAuthCode('code', 'session-id', 'state') + + expect(tokenInfo).toBeNull() + expect(oauth.error.value).toBe( + '未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。' + ) + }) +}) diff --git a/frontend/src/composables/useOpenAIOAuth.ts b/frontend/src/composables/useOpenAIOAuth.ts index 060ddbd2..4483e4ad 100644 --- a/frontend/src/composables/useOpenAIOAuth.ts +++ b/frontend/src/composables/useOpenAIOAuth.ts @@ -1,6 +1,8 @@ import { ref } from 'vue' +import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError' export interface OpenAITokenInfo { access_token?: string @@ -26,6 +28,7 @@ export type OpenAIOAuthPlatform = 'openai' export function useOpenAIOAuth() { const appStore = useAppStore() + const { t } = useI18n() const endpointPrefix = '/admin/openai' // State @@ -78,7 +81,7 @@ export function useOpenAIOAuth() { } return true } catch (err: any) { - error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL' + error.value = extractApiErrorMessage(err, t('admin.accounts.oauth.openai.failedToGenerateUrl')) appStore.showError(error.value) return false } finally { @@ -114,7 +117,12 @@ export function useOpenAIOAuth() { const tokenInfo = await adminAPI.accounts.exchangeCode(`${endpointPrefix}/exchange-code`, payload) return tokenInfo as OpenAITokenInfo } catch (err: any) { - error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code' + error.value = extractI18nErrorMessage( + err, + t, + 'admin.accounts.oauth.openai.errors', + t('admin.accounts.oauth.openai.failedToExchangeCode') + ) appStore.showError(error.value) return null } finally { @@ -147,7 +155,12 @@ export function useOpenAIOAuth() { ) return tokenInfo as OpenAITokenInfo } catch (err: any) { - error.value = err.response?.data?.detail || err.message || 'Failed to validate refresh token' + error.value = extractI18nErrorMessage( + err, + t, + 'admin.accounts.oauth.openai.errors', + t('admin.accounts.oauth.openai.failedToValidateRT') + ) appStore.showError(error.value) return null } finally { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index bab6cbe4..63a50fbe 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2794,6 +2794,13 @@ export default { 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + failedToGenerateUrl: 'Failed to generate OpenAI auth URL', + failedToExchangeCode: 'Failed to exchange OpenAI auth code', + failedToValidateRT: 'Failed to validate refresh token', + errors: { + OPENAI_OAUTH_PROXY_REQUIRED: + 'No proxy is configured and this server could not reach OpenAI directly, so the OpenAI OAuth request failed. Select a proxy that can access OpenAI and retry; if the authorization code has expired, regenerate the authorization URL.' + }, // Refresh Token auth refreshTokenAuth: 'Manual RT Input', refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 1996dbd6..697058ec 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2929,6 +2929,13 @@ export default { authCodePlaceholder: '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', + failedToGenerateUrl: '生成 OpenAI 授权链接失败', + failedToExchangeCode: 'OpenAI 授权码兑换失败', + failedToValidateRT: '验证 Refresh Token 失败', + errors: { + OPENAI_OAUTH_PROXY_REQUIRED: + '未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。' + }, // Refresh Token auth refreshTokenAuth: '手动输入 RT', refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。',