From 239f11821b4e7df90a1b10afc28344cb59d5a1af Mon Sep 17 00:00:00 2001 From: hisen666 <2528646417@qq.com> Date: Sun, 12 Apr 2026 11:30:40 +0800 Subject: [PATCH 1/3] Add SUB2API panel mode for auth URL and callback flow --- .learnings/LEARNINGS.md | 22 +++ background.js | 232 ++++++++++++++++++++++- content/sub2api-panel.js | 395 +++++++++++++++++++++++++++++++++++++++ sidepanel/sidepanel.html | 31 ++- sidepanel/sidepanel.js | 102 ++++++++++ 5 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 .learnings/LEARNINGS.md create mode 100644 content/sub2api-panel.js diff --git a/.learnings/LEARNINGS.md b/.learnings/LEARNINGS.md new file mode 100644 index 00000000..04674550 --- /dev/null +++ b/.learnings/LEARNINGS.md @@ -0,0 +1,22 @@ +## [LRN-20260412-001] correction + +**Logged**: 2026-04-12T00:00:00+08:00 +**Priority**: high +**Status**: pending +**Area**: backend + +### Summary +SUB2API 的 OpenAI Auth 链路不能误用 antigravity 的 OAuth 接口 + +### Details +本项目新增 SUB2API 模式时,用户明确纠正:`/api/v1/admin/antigravity/oauth/exchange-code` 属于反重力平台,不是 OpenAI Auth。随后通过后台前端产物确认,OpenAI Auth 正确链路是 `/api/v1/admin/openai/generate-auth-url` 与 `/api/v1/admin/openai/exchange-code`,最终创建账号仍调用 `/api/v1/admin/accounts`,并带 `platform: openai`、`type: oauth`。 + +### Suggested Action +后续接入 SUB2API / OpenAI Auth 时,优先以后台页面实际前端产物和真实接口为准,不再把 antigravity / gemini / openai 三套 OAuth 接口混用。 + +### Metadata +- Source: user_feedback +- Related Files: background.js, content/sub2api-panel.js +- Tags: sub2api, openai-auth, correction + +--- diff --git a/background.js b/background.js index d5aaa817..7dd50f44 100644 --- a/background.js +++ b/background.js @@ -8,6 +8,9 @@ const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; const STEP7_RESTART_MAX_ROUNDS = 8; +const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts'; +const DEFAULT_SUB2API_GROUP_NAME = 'codex'; +const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback'; initializeSessionStorageAccess(); @@ -16,8 +19,14 @@ initializeSessionStorageAccess(); // ============================================================ const PERSISTED_SETTING_DEFAULTS = { + panelMode: 'cpa', // Step 1 / Step 9 的来源模式:cpa | sub2api。 vpsUrl: '', // VPS 面板地址,可手动填写。 vpsPassword: '', // VPS 面板登录密码,可手动填写。 + sub2apiUrl: DEFAULT_SUB2API_URL, // SUB2API 管理后台地址。 + sub2apiEmail: '', // SUB2API 登录邮箱。 + sub2apiPassword: '', // SUB2API 登录密码。 + sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME, // SUB2API 创建账号时绑定的分组名。 + sub2apiRedirectUri: DEFAULT_SUB2API_REDIRECT_URI, // SUB2API OpenAI Auth 回调地址。 customPassword: '', // 自定义账号密码;留空时由程序自动生成随机密码。 autoRunSkipFailures: false, // 自动运行遇到失败步骤后,是否继续执行后续流程。 mailProvider: '163', // 验证码邮箱来源,当前支持 163 / inbucket。 @@ -41,6 +50,10 @@ const DEFAULT_STATE = { lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 localhostUrl: null, // 运行时捕获到的 localhost 回调地址,不要手动预填。 + sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。 + sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 + sub2apiGroupId: null, // SUB2API 目标分组 ID。 + sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 flowStartTime: null, // 当前流程开始时间。 tabRegistry: {}, // 程序维护的标签页注册表。 sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 @@ -219,6 +232,39 @@ function parseUrlSafely(rawUrl) { } } +function normalizeSub2ApiUrl(rawUrl) { + const input = (rawUrl || '').trim() || DEFAULT_SUB2API_URL; + const withProtocol = /^https?:\/\//i.test(input) ? input : `https://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/') { + parsed.pathname = '/admin/accounts'; + } + parsed.hash = ''; + return parsed.toString(); +} + +function normalizeSub2ApiRedirectUri(rawUrl) { + const input = (rawUrl || '').trim() || DEFAULT_SUB2API_REDIRECT_URI; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/') { + parsed.pathname = '/auth/callback'; + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback'); + } + return parsed.toString(); +} + +function getPanelMode(state = {}) { + return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa'; +} + +function getPanelModeLabel(modeOrState) { + const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState); + return mode === 'sub2api' ? 'SUB2API' : 'CPA'; +} + function isSignupPageHost(hostname = '') { return ['auth0.openai.com', 'auth.openai.com', 'accounts.openai.com'].includes(hostname); } @@ -271,6 +317,14 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) { return Boolean(reference) && candidate.origin === reference.origin && candidate.pathname === reference.pathname; + case 'sub2api-panel': + return Boolean(reference) + && candidate.origin === reference.origin + && ( + candidate.pathname.startsWith('/admin/accounts') + || candidate.pathname.startsWith('/login') + || candidate.pathname === '/' + ); default: return false; } @@ -876,6 +930,7 @@ function getSourceLabel(source) { 'sidepanel': '侧边栏', 'signup-page': '认证页', 'vps-panel': 'CPA 面板', + 'sub2api-panel': 'SUB2API 后台', 'qq-mail': 'QQ 邮箱', 'mail-163': '163 邮箱', 'inbucket-mail': 'Inbucket 邮箱', @@ -950,6 +1005,10 @@ function getDownstreamStateResets(step) { if (step <= 1) { return { oauthUrl: null, + sub2apiSessionId: null, + sub2apiOAuthState: null, + sub2apiGroupId: null, + sub2apiDraftName: null, flowStartTime: null, password: null, lastEmailTimestamp: null, @@ -1321,8 +1380,14 @@ async function handleMessage(message, sender) { case 'SAVE_SETTING': { const updates = {}; + if (message.payload.panelMode !== undefined) updates.panelMode = message.payload.panelMode; if (message.payload.vpsUrl !== undefined) updates.vpsUrl = message.payload.vpsUrl; if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword; + if (message.payload.sub2apiUrl !== undefined) updates.sub2apiUrl = message.payload.sub2apiUrl; + if (message.payload.sub2apiEmail !== undefined) updates.sub2apiEmail = message.payload.sub2apiEmail; + if (message.payload.sub2apiPassword !== undefined) updates.sub2apiPassword = message.payload.sub2apiPassword; + if (message.payload.sub2apiGroupName !== undefined) updates.sub2apiGroupName = message.payload.sub2apiGroupName; + if (message.payload.sub2apiRedirectUri !== undefined) updates.sub2apiRedirectUri = message.payload.sub2apiRedirectUri; if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; @@ -1372,12 +1437,21 @@ async function handleMessage(message, sender) { async function handleStepData(step, payload) { switch (step) { - case 1: + case 1: { + const updates = {}; if (payload.oauthUrl) { - await setState({ oauthUrl: payload.oauthUrl }); + updates.oauthUrl = payload.oauthUrl; broadcastDataUpdate({ oauthUrl: payload.oauthUrl }); } + if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; + if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; + if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; + if (Object.keys(updates).length) { + await setState(updates); + } break; + } case 3: if (payload.email) await setEmailState(payload.email); break; @@ -1991,10 +2065,17 @@ async function resumeAutoRun() { } // ============================================================ -// Step 1: Get OAuth Link (via vps-panel.js) +// Step 1: Get OAuth Link // ============================================================ async function executeStep1(state) { + if (getPanelMode(state) === 'sub2api') { + return executeSub2ApiStep1(state); + } + return executeCpaStep1(state); +} + +async function executeCpaStep1(state) { if (!state.vpsUrl) { throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); } @@ -2040,6 +2121,67 @@ async function executeStep1(state) { } } +async function executeSub2ApiStep1(state) { + const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); + const sub2apiRedirectUri = normalizeSub2ApiRedirectUri(state.sub2apiRedirectUri); + const groupName = (state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME).trim() || DEFAULT_SUB2API_GROUP_NAME; + + if (!state.sub2apiEmail) { + throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。'); + } + if (!state.sub2apiPassword) { + throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); + } + + await addLog('步骤 1:正在打开 SUB2API 后台...'); + + const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; + + await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl); + + const tab = await chrome.tabs.create({ url: sub2apiUrl, active: true }); + const tabId = tab.id; + await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); + + await addLog('步骤 1:SUB2API 页面已打开,正在等待页面进入目标地址...'); + const matchedTab = await waitForTabUrlFamily('sub2api-panel', tabId, sub2apiUrl, { + timeoutMs: 15000, + retryDelayMs: 400, + }); + if (!matchedTab) { + await addLog('步骤 1:SUB2API 页面尚未稳定,继续尝试连接内容脚本...', 'warn'); + } + + await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { + inject: injectFiles, + injectSource: 'sub2api-panel', + timeoutMs: 45000, + retryDelayMs: 900, + logMessage: '步骤 1:SUB2API 页面仍在加载,正在重试连接内容脚本...', + }); + + const result = await sendToContentScriptResilient('sub2api-panel', { + type: 'EXECUTE_STEP', + step: 1, + source: 'background', + payload: { + sub2apiUrl, + sub2apiEmail: state.sub2apiEmail, + sub2apiPassword: state.sub2apiPassword, + sub2apiGroupName: groupName, + sub2apiRedirectUri, + }, + }, { + timeoutMs: 40000, + retryDelayMs: 700, + logMessage: '步骤 1:SUB2API 页面通信未就绪,正在等待页面恢复...', + }); + + if (result?.error) { + throw new Error(result.error); + } +} + // ============================================================ // Step 2: Open Signup Page (Background opens tab, signup-page.js clicks Register) // ============================================================ @@ -2415,11 +2557,7 @@ async function executeStep5(state) { // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { - if (!state.vpsUrl) { - throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。'); - } - - await addLog('步骤 6:正在刷新登录用的 CPA OAuth 链接...'); + await addLog(`步骤 6:正在刷新登录用的 ${getPanelModeLabel(state)} OAuth 链接...`); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] preparing fresh OAuth via step 1'); const waitForFreshOAuth = waitForStepComplete(1, 120000); console.log(LOG_PREFIX, '[refreshOAuthUrlBeforeStep6] executing step 1 for fresh OAuth'); @@ -2801,10 +2939,17 @@ async function executeStep8(state) { } // ============================================================ -// Step 9: CPA 回调验证(通过 vps-panel.js) +// Step 9: 平台回调验证 // ============================================================ async function executeStep9(state) { + if (getPanelMode(state) === 'sub2api') { + return executeSub2ApiStep9(state); + } + return executeCpaStep9(state); +} + +async function executeCpaStep9(state) { if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { throw new Error('步骤 8 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 8。'); } @@ -2853,6 +2998,75 @@ async function executeStep9(state) { } } +async function executeSub2ApiStep9(state) { + if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) { + throw new Error('步骤 8 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 8。'); + } + if (!state.localhostUrl) { + throw new Error('缺少 localhost 回调地址,请先完成步骤 8。'); + } + if (!state.sub2apiSessionId) { + throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。'); + } + if (!state.sub2apiEmail) { + throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。'); + } + if (!state.sub2apiPassword) { + throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); + } + + const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); + const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; + + await addLog('步骤 9:正在打开 SUB2API 后台...'); + + let tabId = await getTabId('sub2api-panel'); + const alive = tabId && await isTabAlive('sub2api-panel'); + + if (!alive) { + tabId = await reuseOrCreateTab('sub2api-panel', sub2apiUrl, { + inject: injectFiles, + injectSource: 'sub2api-panel', + reloadIfSameUrl: true, + }); + } else { + await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl, { excludeTabIds: [tabId] }); + await chrome.tabs.update(tabId, { active: true }); + await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); + } + + await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { + inject: injectFiles, + injectSource: 'sub2api-panel', + }); + + await addLog('步骤 9:正在向 SUB2API 提交回调并创建账号...'); + const result = await sendToContentScriptResilient('sub2api-panel', { + type: 'EXECUTE_STEP', + step: 9, + source: 'background', + payload: { + localhostUrl: state.localhostUrl, + sub2apiUrl, + sub2apiEmail: state.sub2apiEmail, + sub2apiPassword: state.sub2apiPassword, + sub2apiGroupName: state.sub2apiGroupName, + sub2apiSessionId: state.sub2apiSessionId, + sub2apiOAuthState: state.sub2apiOAuthState, + sub2apiGroupId: state.sub2apiGroupId, + sub2apiDraftName: state.sub2apiDraftName, + }, + }, { + timeoutMs: 40000, + retryDelayMs: 700, + logMessage: '步骤 9:SUB2API 页面通信未就绪,正在等待页面恢复...', + }); + + if (result?.error) { + throw new Error(result.error); + } +} + // ============================================================ // Open Side Panel on extension icon click // ============================================================ diff --git a/content/sub2api-panel.js b/content/sub2api-panel.js new file mode 100644 index 00000000..747b994d --- /dev/null +++ b/content/sub2api-panel.js @@ -0,0 +1,395 @@ +// content/sub2api-panel.js — 页内脚本:SUB2API 后台(步骤 1、9) + +console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href); + +const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener'; +const SUB2API_DEFAULT_GROUP_NAME = 'codex'; +const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback'; + +if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '1') { + document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1'); + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'EXECUTE_STEP') { + resetStopState(); + handleStep(message.step, message.payload).then(() => { + sendResponse({ ok: true }); + }).catch((err) => { + if (isStopError(err)) { + log(`步骤 ${message.step}:已被用户停止。`, 'warn'); + sendResponse({ stopped: true, error: err.message }); + return; + } + reportError(message.step, err.message); + sendResponse({ error: err.message }); + }); + return true; + } + }); +} else { + console.log('[MultiPage:sub2api-panel] 消息监听已存在,跳过重复注册'); +} + +function getSub2ApiOrigin(payload = {}) { + const rawUrl = payload.sub2apiUrl || location.href; + try { + return new URL(rawUrl).origin; + } catch { + return location.origin; + } +} + +function normalizeRedirectUri(rawUrl) { + const input = (rawUrl || '').trim() || SUB2API_DEFAULT_REDIRECT_URI; + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/') { + parsed.pathname = '/auth/callback'; + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback'); + } + return parsed.toString(); +} + +async function handleStep(step, payload = {}) { + switch (step) { + case 1: + return step1_generateOpenAiAuthUrl(payload); + case 9: + return step9_submitOpenAiCallback(payload); + default: + throw new Error(`sub2api-panel.js 不处理步骤 ${step}`); + } +} + +async function requestJson(origin, path, options = {}) { + throwIfStopped(); + const { + method = 'GET', + token = '', + body = undefined, + } = options; + + const response = await fetch(`${origin}${path}`, { + method, + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + + if (json && typeof json === 'object' && 'code' in json) { + if (json.code === 0) { + return json.data; + } + throw new Error(json.message || json.detail || `请求失败(${path})`); + } + + if (!response.ok) { + throw new Error((json && (json.message || json.detail)) || `请求失败(HTTP ${response.status}):${path}`); + } + + return json; +} + +function storeAuthSession(loginData) { + if (!loginData?.access_token) { + throw new Error('SUB2API 登录返回缺少 access_token。'); + } + + localStorage.setItem('auth_token', loginData.access_token); + if (loginData.refresh_token) { + localStorage.setItem('refresh_token', loginData.refresh_token); + } else { + localStorage.removeItem('refresh_token'); + } + if (loginData.expires_in) { + localStorage.setItem('token_expires_at', String(Date.now() + Number(loginData.expires_in) * 1000)); + } + if (loginData.user) { + localStorage.setItem('auth_user', JSON.stringify(loginData.user)); + } + sessionStorage.removeItem('auth_expired'); +} + +async function loginSub2Api(payload = {}) { + const email = (payload.sub2apiEmail || '').trim(); + const password = payload.sub2apiPassword || ''; + const origin = getSub2ApiOrigin(payload); + + if (!email) { + throw new Error('缺少 SUB2API 登录邮箱,请先在侧边栏填写。'); + } + if (!password) { + throw new Error('缺少 SUB2API 登录密码,请先在侧边栏填写。'); + } + + log('步骤:正在登录 SUB2API 后台...'); + const loginData = await requestJson(origin, '/api/v1/auth/login', { + method: 'POST', + body: { + email, + password, + }, + }); + storeAuthSession(loginData); + + return { + origin, + token: loginData.access_token, + user: loginData.user || null, + }; +} + +async function getGroupByName(origin, token, groupName) { + const targetName = (groupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME; + const groups = await requestJson(origin, '/api/v1/admin/groups/all', { + method: 'GET', + token, + }); + + const normalized = targetName.toLowerCase(); + const group = (groups || []).find((item) => { + const itemName = String(item?.name || '').trim().toLowerCase(); + if (!itemName) return false; + if (itemName !== normalized) return false; + return !item.platform || item.platform === 'openai'; + }); + + if (!group) { + throw new Error(`SUB2API 中未找到名为“${targetName}”的 openai 分组。`); + } + + return group; +} + +function buildDraftAccountName(groupName) { + const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME) + .trim() + .replace(/[^\w\u4e00-\u9fa5-]+/g, '-') + .replace(/^-+|-+$/g, '') || SUB2API_DEFAULT_GROUP_NAME; + const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14); + const random = Math.floor(Math.random() * 9000 + 1000); + return `${prefix}-${stamp}-${random}`; +} + +function extractStateFromAuthUrl(authUrl) { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } +} + +function parseLocalhostCallback(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error('提供的回调 URL 不是合法链接。'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('回调 URL 协议不正确。'); + } + if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) { + throw new Error('步骤 9 只接受 localhost / 127.0.0.1 回调地址。'); + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('回调 URL 路径必须是 /auth/callback。'); + } + + const code = (parsed.searchParams.get('code') || '').trim(); + const state = (parsed.searchParams.get('state') || '').trim(); + if (!code || !state) { + throw new Error('回调 URL 中缺少 code 或 state。'); + } + + return { + url: parsed.toString(), + code, + state, + }; +} + +function buildOpenAiCredentials(exchangeData) { + const credentials = {}; + const allowedKeys = [ + 'access_token', + 'refresh_token', + 'id_token', + 'expires_at', + 'email', + 'chatgpt_account_id', + 'chatgpt_user_id', + 'organization_id', + 'plan_type', + 'client_id', + ]; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + credentials[key] = exchangeData[key]; + } + } + + if (!credentials.access_token) { + throw new Error('SUB2API 交换授权码后未返回 access_token。'); + } + + return credentials; +} + +function buildOpenAiExtra(exchangeData) { + const extra = {}; + const allowedKeys = ['email', 'name', 'privacy_mode']; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + extra[key] = exchangeData[key]; + } + } + + return Object.keys(extra).length ? extra : undefined; +} + +async function getBackgroundState() { + try { + return await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sub2api-panel' }); + } catch { + return {}; + } +} + +function openAccountsPageSoon(origin) { + const accountsUrl = `${origin}/admin/accounts`; + if (location.href === accountsUrl || location.pathname.startsWith('/admin/accounts')) { + return; + } + setTimeout(() => { + try { + location.replace(accountsUrl); + } catch { } + }, 500); +} + +async function step1_generateOpenAiAuthUrl(payload = {}) { + const redirectUri = normalizeRedirectUri(payload.sub2apiRedirectUri); + const groupName = (payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME; + + const { origin, token } = await loginSub2Api(payload); + const group = await getGroupByName(origin, token, groupName); + const draftName = buildDraftAccountName(group.name || groupName); + + log(`步骤 1:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`); + log(`步骤 1:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`); + + const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', { + method: 'POST', + token, + body: { + redirect_uri: redirectUri, + }, + }); + + const oauthUrl = String(authData?.auth_url || '').trim(); + const sessionId = String(authData?.session_id || '').trim(); + const oauthState = String(authData?.state || extractStateFromAuthUrl(oauthUrl)).trim(); + + if (!oauthUrl || !sessionId) { + throw new Error('SUB2API 未返回完整的 auth_url / session_id。'); + } + + log(`步骤 1:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok'); + reportComplete(1, { + oauthUrl, + sub2apiSessionId: sessionId, + sub2apiOAuthState: oauthState, + sub2apiGroupId: group.id, + sub2apiDraftName: draftName, + }); + openAccountsPageSoon(origin); +} + +async function step9_submitOpenAiCallback(payload = {}) { + const callback = parseLocalhostCallback(payload.localhostUrl || ''); + const backgroundState = await getBackgroundState(); + const flowEmail = String(backgroundState.email || '').trim(); + + const sessionId = String(payload.sub2apiSessionId || backgroundState.sub2apiSessionId || '').trim(); + const expectedState = String(payload.sub2apiOAuthState || backgroundState.sub2apiOAuthState || '').trim(); + const accountName = flowEmail + || String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim() + || buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME); + + const { origin, token } = await loginSub2Api(payload); + const group = payload.sub2apiGroupId + ? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME } + : await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME); + + if (!sessionId) { + throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。'); + } + if (expectedState && expectedState !== callback.state) { + throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。'); + } + + log('步骤 9:正在向 SUB2API 交换 OpenAI 授权码...'); + const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', { + method: 'POST', + token, + body: { + session_id: sessionId, + code: callback.code, + state: callback.state, + }, + }); + + const credentials = buildOpenAiCredentials(exchangeData); + const extra = buildOpenAiExtra(exchangeData); + const groupId = Number(group.id); + if (!Number.isFinite(groupId) || groupId <= 0) { + throw new Error('SUB2API 返回的目标分组 ID 无效。'); + } + const createPayload = { + name: accountName, + notes: '', + platform: 'openai', + type: 'oauth', + credentials, + group_ids: [groupId], + auto_pause_on_expired: true, + }; + + if (extra) { + createPayload.extra = extra; + } + + log(`步骤 9:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`); + const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', { + method: 'POST', + token, + body: createPayload, + }); + + const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; + log(`步骤 9:${verifiedStatus}`, 'ok'); + reportComplete(9, { + localhostUrl: callback.url, + verifiedStatus, + }); + openAccountsPageSoon(origin); +} + +reportReady(); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 98a4d9c9..cf196e6b 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -45,16 +45,43 @@

多页面

+ 来源 + +
+
CPA
-
+
管理密钥
+ + + + +
邮箱服务