Explorar o código

Add SUB2API panel mode for auth URL and callback flow

hisen666 hai 4 meses
pai
achega
b56802f221
Modificáronse 5 ficheiros con 771 adicións e 11 borrados
  1. 22 0
      .learnings/LEARNINGS.md
  2. 223 9
      background.js
  3. 395 0
      content/sub2api-panel.js
  4. 29 2
      sidepanel/sidepanel.html
  5. 102 0
      sidepanel/sidepanel.js

+ 22 - 0
.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
+
+---

+ 223 - 9
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
 // ============================================================

+ 395 - 0
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();

+ 29 - 2
sidepanel/sidepanel.html

@@ -45,16 +45,43 @@
   <section id="data-section">
     <div class="data-card">
       <div class="data-row">
+        <span class="data-label">来源</span>
+        <select id="select-panel-mode" class="data-select">
+          <option value="cpa">CPA 面板</option>
+          <option value="sub2api">SUB2API</option>
+        </select>
+      </div>
+      <div class="data-row" id="row-vps-url">
         <span class="data-label">CPA</span>
         <div class="input-with-icon">
           <input type="password" id="input-vps-url" class="data-input data-input-with-icon" placeholder="http://ip:port/management.html#/oauth" />
           <button id="btn-toggle-vps-url" class="input-icon-btn" type="button" aria-label="显示 CPA 地址" title="显示 CPA 地址"></button>
         </div>
       </div>
-      <div class="data-row">
+      <div class="data-row" id="row-vps-password">
         <span class="data-label">管理密钥</span>
         <input type="password" id="input-vps-password" class="data-input" placeholder="请输入 CPA 管理密钥" />
       </div>
+      <div class="data-row" id="row-sub2api-url" style="display:none;">
+        <span class="data-label">SUB2API</span>
+        <input type="text" id="input-sub2api-url" class="data-input" placeholder="https://sub2api.hisence.fun/admin/accounts" />
+      </div>
+      <div class="data-row" id="row-sub2api-email" style="display:none;">
+        <span class="data-label">账号</span>
+        <input type="text" id="input-sub2api-email" class="data-input" placeholder="请输入 SUB2API 登录邮箱" />
+      </div>
+      <div class="data-row" id="row-sub2api-password" style="display:none;">
+        <span class="data-label">密码</span>
+        <input type="password" id="input-sub2api-password" class="data-input" placeholder="请输入 SUB2API 登录密码" />
+      </div>
+      <div class="data-row" id="row-sub2api-group" style="display:none;">
+        <span class="data-label">分组</span>
+        <input type="text" id="input-sub2api-group" class="data-input" placeholder="默认 codex" />
+      </div>
+      <div class="data-row" id="row-sub2api-redirect-uri" style="display:none;">
+        <span class="data-label">回调</span>
+        <input type="text" id="input-sub2api-redirect-uri" class="data-input" placeholder="默认 http://localhost:1455/auth/callback" />
+      </div>
       <div class="data-row">
         <span class="data-label">邮箱服务</span>
         <select id="select-mail-provider" class="data-select">
@@ -164,7 +191,7 @@
       </div>
       <div class="step-row" data-step="9">
         <div class="step-indicator" data-step="9"><span class="step-num">9</span></div>
-        <button class="step-btn" data-step="9">CPA 回调验证</button>
+        <button class="step-btn" data-step="9">平台回调验证</button>
         <span class="step-status" data-step="9"></span>
       </div>
     </div>

+ 102 - 0
sidepanel/sidepanel.js

@@ -28,8 +28,21 @@ const btnAutoRun = document.getElementById('btn-auto-run');
 const btnAutoContinue = document.getElementById('btn-auto-continue');
 const autoContinueBar = document.getElementById('auto-continue-bar');
 const btnClearLog = document.getElementById('btn-clear-log');
+const selectPanelMode = document.getElementById('select-panel-mode');
+const rowVpsUrl = document.getElementById('row-vps-url');
 const inputVpsUrl = document.getElementById('input-vps-url');
+const rowVpsPassword = document.getElementById('row-vps-password');
 const inputVpsPassword = document.getElementById('input-vps-password');
+const rowSub2ApiUrl = document.getElementById('row-sub2api-url');
+const inputSub2ApiUrl = document.getElementById('input-sub2api-url');
+const rowSub2ApiEmail = document.getElementById('row-sub2api-email');
+const inputSub2ApiEmail = document.getElementById('input-sub2api-email');
+const rowSub2ApiPassword = document.getElementById('row-sub2api-password');
+const inputSub2ApiPassword = document.getElementById('input-sub2api-password');
+const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
+const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
+const rowSub2ApiRedirectUri = document.getElementById('row-sub2api-redirect-uri');
+const inputSub2ApiRedirectUri = document.getElementById('input-sub2api-redirect-uri');
 const selectMailProvider = document.getElementById('select-mail-provider');
 const rowInbucketHost = document.getElementById('row-inbucket-host');
 const inputInbucketHost = document.getElementById('input-inbucket-host');
@@ -276,8 +289,14 @@ function setDefaultAutoRunButton() {
 
 function collectSettingsPayload() {
   return {
+    panelMode: selectPanelMode.value,
     vpsUrl: inputVpsUrl.value.trim(),
     vpsPassword: inputVpsPassword.value,
+    sub2apiUrl: inputSub2ApiUrl.value.trim(),
+    sub2apiEmail: inputSub2ApiEmail.value.trim(),
+    sub2apiPassword: inputSub2ApiPassword.value,
+    sub2apiGroupName: inputSub2ApiGroup.value.trim(),
+    sub2apiRedirectUri: inputSub2ApiRedirectUri.value.trim(),
     customPassword: inputPassword.value,
     mailProvider: selectMailProvider.value,
     inbucketHost: inputInbucketHost.value.trim(),
@@ -328,6 +347,7 @@ async function saveSettings(options = {}) {
 
     syncLatestState(payload);
     markSettingsDirty(false);
+    updatePanelModeUI();
     updateMailProviderUI();
     updateButtonStates();
     if (!silent) {
@@ -441,6 +461,24 @@ async function restoreState() {
     if (state.vpsPassword) {
       inputVpsPassword.value = state.vpsPassword;
     }
+    if (state.panelMode) {
+      selectPanelMode.value = state.panelMode;
+    }
+    if (state.sub2apiUrl) {
+      inputSub2ApiUrl.value = state.sub2apiUrl;
+    }
+    if (state.sub2apiEmail) {
+      inputSub2ApiEmail.value = state.sub2apiEmail;
+    }
+    if (state.sub2apiPassword) {
+      inputSub2ApiPassword.value = state.sub2apiPassword;
+    }
+    if (state.sub2apiGroupName) {
+      inputSub2ApiGroup.value = state.sub2apiGroupName;
+    }
+    if (state.sub2apiRedirectUri) {
+      inputSub2ApiRedirectUri.value = state.sub2apiRedirectUri;
+    }
     if (state.mailProvider) {
       selectMailProvider.value = state.mailProvider;
     }
@@ -468,6 +506,7 @@ async function restoreState() {
     markSettingsDirty(false);
     updateStatusDisplay(latestState);
     updateProgressCounter();
+    updatePanelModeUI();
     updateMailProviderUI();
     updateButtonStates();
   } catch (err) {
@@ -485,6 +524,22 @@ function updateMailProviderUI() {
   rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
 }
 
+function updatePanelModeUI() {
+  const useSub2Api = selectPanelMode.value === 'sub2api';
+  rowVpsUrl.style.display = useSub2Api ? 'none' : '';
+  rowVpsPassword.style.display = useSub2Api ? 'none' : '';
+  rowSub2ApiUrl.style.display = useSub2Api ? '' : 'none';
+  rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
+  rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
+  rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none';
+  rowSub2ApiRedirectUri.style.display = useSub2Api ? '' : 'none';
+
+  const step9Btn = document.querySelector('.step-btn[data-step="9"]');
+  if (step9Btn) {
+    step9Btn.textContent = useSub2Api ? 'SUB2API 回调验证' : 'CPA 回调验证';
+  }
+}
+
 // ============================================================
 // UI Updates
 // ============================================================
@@ -952,6 +1007,52 @@ selectMailProvider.addEventListener('change', () => {
   saveSettings({ silent: true }).catch(() => { });
 });
 
+selectPanelMode.addEventListener('change', () => {
+  updatePanelModeUI();
+  markSettingsDirty(true);
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputSub2ApiUrl.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiUrl.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputSub2ApiEmail.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiEmail.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputSub2ApiPassword.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiPassword.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputSub2ApiGroup.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiGroup.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
+inputSub2ApiRedirectUri.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiRedirectUri.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
 inputInbucketMailbox.addEventListener('input', () => {
   markSettingsDirty(true);
   scheduleSettingsAutoSave();
@@ -1106,6 +1207,7 @@ updateSaveButtonState();
 restoreState().then(() => {
   syncPasswordToggleLabel();
   syncVpsUrlToggleLabel();
+  updatePanelModeUI();
   updateButtonStates();
   updateStatusDisplay(latestState);
 });