Use direct SUB2API admin API flow
This commit is contained in:
@@ -17,6 +17,7 @@ importScripts(
|
|||||||
'background/paypal-account-store.js',
|
'background/paypal-account-store.js',
|
||||||
'background/ip-proxy-provider-711proxy.js',
|
'background/ip-proxy-provider-711proxy.js',
|
||||||
'background/ip-proxy-core.js',
|
'background/ip-proxy-core.js',
|
||||||
|
'background/sub2api-api.js',
|
||||||
'background/panel-bridge.js',
|
'background/panel-bridge.js',
|
||||||
'background/registration-email-state.js',
|
'background/registration-email-state.js',
|
||||||
'background/runtime-state.js',
|
'background/runtime-state.js',
|
||||||
@@ -927,6 +928,7 @@ const DEFAULT_STATE = {
|
|||||||
sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。
|
sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。
|
||||||
sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
|
sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
|
||||||
sub2apiGroupId: null, // SUB2API 目标分组 ID。
|
sub2apiGroupId: null, // SUB2API 目标分组 ID。
|
||||||
|
sub2apiGroupIds: [], // SUB2API 多目标分组 ID。
|
||||||
sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。
|
sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。
|
||||||
sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。
|
sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。
|
||||||
codex2apiSessionId: null, // Codex2API OAuth 会话 ID。
|
codex2apiSessionId: null, // Codex2API OAuth 会话 ID。
|
||||||
@@ -8270,7 +8272,9 @@ function getDownstreamStateResets(step, state = {}) {
|
|||||||
sub2apiSessionId: null,
|
sub2apiSessionId: null,
|
||||||
sub2apiOAuthState: null,
|
sub2apiOAuthState: null,
|
||||||
sub2apiGroupId: null,
|
sub2apiGroupId: null,
|
||||||
|
sub2apiGroupIds: [],
|
||||||
sub2apiDraftName: null,
|
sub2apiDraftName: null,
|
||||||
|
sub2apiProxyId: null,
|
||||||
codex2apiSessionId: null,
|
codex2apiSessionId: null,
|
||||||
codex2apiOAuthState: null,
|
codex2apiOAuthState: null,
|
||||||
flowStartTime: null,
|
flowStartTime: null,
|
||||||
@@ -9171,6 +9175,9 @@ async function handleStepData(step, payload) {
|
|||||||
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
|
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
|
||||||
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
|
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
|
||||||
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
||||||
|
if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds)
|
||||||
|
? payload.sub2apiGroupIds
|
||||||
|
: [];
|
||||||
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
||||||
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
||||||
if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
|
if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
|
||||||
@@ -11849,6 +11856,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({
|
|||||||
sendToContentScript,
|
sendToContentScript,
|
||||||
sendToContentScriptResilient,
|
sendToContentScriptResilient,
|
||||||
shouldBypassStep9ForLocalCpa,
|
shouldBypassStep9ForLocalCpa,
|
||||||
|
DEFAULT_SUB2API_GROUP_NAME,
|
||||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
const stepExecutorsByKey = {
|
const stepExecutorsByKey = {
|
||||||
|
|||||||
@@ -435,6 +435,9 @@
|
|||||||
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
|
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
|
||||||
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
|
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
|
||||||
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
||||||
|
if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds)
|
||||||
|
? payload.sub2apiGroupIds
|
||||||
|
: [];
|
||||||
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
||||||
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
||||||
if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
|
if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
|
||||||
|
|||||||
+24
-46
@@ -19,6 +19,25 @@
|
|||||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
let sub2ApiApi = null;
|
||||||
|
|
||||||
|
function getSub2ApiApi() {
|
||||||
|
if (sub2ApiApi) {
|
||||||
|
return sub2ApiApi;
|
||||||
|
}
|
||||||
|
const factory = deps.createSub2ApiApi
|
||||||
|
|| self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi;
|
||||||
|
if (typeof factory !== 'function') {
|
||||||
|
throw new Error('SUB2API 直连接口模块未加载,无法生成 OAuth 链接。');
|
||||||
|
}
|
||||||
|
sub2ApiApi = factory({
|
||||||
|
addLog,
|
||||||
|
normalizeSub2ApiUrl,
|
||||||
|
DEFAULT_SUB2API_GROUP_NAME,
|
||||||
|
});
|
||||||
|
return sub2ApiApi;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAdminKey(value = '') {
|
function normalizeAdminKey(value = '') {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -250,7 +269,6 @@
|
|||||||
async function requestSub2ApiOAuthUrl(state, options = {}) {
|
async function requestSub2ApiOAuthUrl(state, options = {}) {
|
||||||
const { logLabel = 'OAuth 刷新' } = options;
|
const { logLabel = 'OAuth 刷新' } = options;
|
||||||
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
|
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
|
||||||
const groupName = (state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME).trim() || DEFAULT_SUB2API_GROUP_NAME;
|
|
||||||
|
|
||||||
if (!sub2apiUrl) {
|
if (!sub2apiUrl) {
|
||||||
throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.');
|
throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.');
|
||||||
@@ -262,54 +280,14 @@
|
|||||||
throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。');
|
throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。');
|
||||||
}
|
}
|
||||||
|
|
||||||
await addLog(`${logLabel}:正在打开 SUB2API 后台...`);
|
const api = getSub2ApiApi();
|
||||||
|
return api.generateOpenAiAuthUrl({
|
||||||
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
|
...state,
|
||||||
await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl);
|
|
||||||
|
|
||||||
const tab = typeof createAutomationTab === 'function'
|
|
||||||
? await createAutomationTab({ url: sub2apiUrl, active: true })
|
|
||||||
: await chrome.tabs.create({ url: sub2apiUrl, active: true });
|
|
||||||
const tabId = tab.id;
|
|
||||||
await rememberSourceLastUrl('sub2api-panel', sub2apiUrl);
|
|
||||||
|
|
||||||
await addLog(`${logLabel}:SUB2API 页面已打开,正在等待页面进入目标地址...`);
|
|
||||||
const matchedTab = await waitForTabUrlFamily('sub2api-panel', tabId, sub2apiUrl, {
|
|
||||||
timeoutMs: 15000,
|
|
||||||
retryDelayMs: 400,
|
|
||||||
});
|
|
||||||
if (!matchedTab) {
|
|
||||||
await addLog(`${logLabel}:SUB2API 页面尚未稳定,继续尝试连接内容脚本...`, 'warn');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureContentScriptReadyOnTab('sub2api-panel', tabId, {
|
|
||||||
inject: injectFiles,
|
|
||||||
injectSource: 'sub2api-panel',
|
|
||||||
timeoutMs: 45000,
|
|
||||||
retryDelayMs: 900,
|
|
||||||
logMessage: `${logLabel}:SUB2API 页面仍在加载,正在重试连接内容脚本...`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendToContentScript('sub2api-panel', {
|
|
||||||
type: 'REQUEST_OAUTH_URL',
|
|
||||||
source: 'background',
|
|
||||||
payload: {
|
|
||||||
sub2apiUrl,
|
sub2apiUrl,
|
||||||
sub2apiEmail: state.sub2apiEmail,
|
|
||||||
sub2apiPassword: state.sub2apiPassword,
|
|
||||||
sub2apiGroupName: groupName,
|
|
||||||
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
|
|
||||||
sub2apiAccountPriority: state.sub2apiAccountPriority,
|
|
||||||
logStep: 7,
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
logLabel,
|
||||||
|
timeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
return result || {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,9 +19,29 @@
|
|||||||
sendToContentScript,
|
sendToContentScript,
|
||||||
sendToContentScriptResilient,
|
sendToContentScriptResilient,
|
||||||
shouldBypassStep9ForLocalCpa,
|
shouldBypassStep9ForLocalCpa,
|
||||||
|
DEFAULT_SUB2API_GROUP_NAME = 'codex',
|
||||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
let sub2ApiApi = null;
|
||||||
|
|
||||||
|
function getSub2ApiApi() {
|
||||||
|
if (sub2ApiApi) {
|
||||||
|
return sub2ApiApi;
|
||||||
|
}
|
||||||
|
const factory = deps.createSub2ApiApi
|
||||||
|
|| self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi;
|
||||||
|
if (typeof factory !== 'function') {
|
||||||
|
throw new Error('SUB2API 直连接口模块未加载,无法提交回调。');
|
||||||
|
}
|
||||||
|
sub2ApiApi = factory({
|
||||||
|
addLog,
|
||||||
|
normalizeSub2ApiUrl,
|
||||||
|
DEFAULT_SUB2API_GROUP_NAME,
|
||||||
|
});
|
||||||
|
return sub2ApiApi;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeString(value = '') {
|
function normalizeString(value = '') {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -346,62 +366,22 @@
|
|||||||
if (!sub2apiUrl) {
|
if (!sub2apiUrl) {
|
||||||
throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.');
|
throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.');
|
||||||
}
|
}
|
||||||
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
|
const api = getSub2ApiApi();
|
||||||
|
|
||||||
await addStepLog(visibleStep, '正在打开 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 addStepLog(visibleStep, '正在向 SUB2API 提交回调并创建账号...');
|
|
||||||
const requestMessage = {
|
|
||||||
type: 'EXECUTE_STEP',
|
|
||||||
step: platformVerifyStep,
|
|
||||||
source: 'background',
|
|
||||||
payload: {
|
|
||||||
localhostUrl: state.localhostUrl,
|
|
||||||
visibleStep,
|
|
||||||
sub2apiUrl,
|
|
||||||
sub2apiEmail: state.sub2apiEmail,
|
|
||||||
sub2apiPassword: state.sub2apiPassword,
|
|
||||||
sub2apiGroupName: state.sub2apiGroupName,
|
|
||||||
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
|
|
||||||
sub2apiAccountPriority: state.sub2apiAccountPriority,
|
|
||||||
sub2apiProxyId: state.sub2apiProxyId,
|
|
||||||
sub2apiSessionId: state.sub2apiSessionId,
|
|
||||||
sub2apiOAuthState: state.sub2apiOAuthState,
|
|
||||||
sub2apiGroupId: state.sub2apiGroupId,
|
|
||||||
sub2apiGroupIds: state.sub2apiGroupIds,
|
|
||||||
sub2apiDraftName: state.sub2apiDraftName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const maxExchangeAttempts = 3;
|
const maxExchangeAttempts = 3;
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const result = await sendToContentScript('sub2api-panel', requestMessage, {
|
const result = await api.submitOpenAiCallback({
|
||||||
responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
...state,
|
||||||
|
visibleStep,
|
||||||
|
sub2apiUrl,
|
||||||
|
}, {
|
||||||
|
visibleStep,
|
||||||
|
logLabel: `步骤 ${visibleStep}`,
|
||||||
|
logOptions: { step: visibleStep, stepKey: 'platform-verify' },
|
||||||
|
timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
await completeStepFromBackground(platformVerifyStep, result);
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
(function attachBackgroundSub2ApiApi(root, factory) {
|
||||||
|
root.MultiPageBackgroundSub2ApiApi = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundSub2ApiApiModule() {
|
||||||
|
function createSub2ApiApi(deps = {}) {
|
||||||
|
const {
|
||||||
|
addLog = async () => {},
|
||||||
|
normalizeSub2ApiUrl = (value) => value,
|
||||||
|
DEFAULT_SUB2API_GROUP_NAME = 'codex',
|
||||||
|
fetchImpl = (...args) => fetch(...args),
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||||
|
const DEFAULT_PROXY_NAME = '';
|
||||||
|
const DEFAULT_CONCURRENCY = 10;
|
||||||
|
const DEFAULT_PRIORITY = 1;
|
||||||
|
const DEFAULT_RATE_MULTIPLIER = 1;
|
||||||
|
|
||||||
|
function normalizeString(value = '') {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStateFromAuthUrl(authUrl = '') {
|
||||||
|
try {
|
||||||
|
return new URL(authUrl).searchParams.get('state') || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRedirectUri(input = 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSub2ApiOrigin(rawUrl = '') {
|
||||||
|
const sub2apiUrl = normalizeSub2ApiUrl(rawUrl);
|
||||||
|
if (!sub2apiUrl) {
|
||||||
|
throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(sub2apiUrl).origin;
|
||||||
|
} catch {
|
||||||
|
throw new Error('SUB2API URL 格式无效,请先在侧边栏检查。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSub2ApiErrorMessage(payload, responseStatus = 500, path = '') {
|
||||||
|
const candidates = [
|
||||||
|
payload?.message,
|
||||||
|
payload?.detail,
|
||||||
|
payload?.error,
|
||||||
|
payload?.reason,
|
||||||
|
];
|
||||||
|
const message = candidates.map(normalizeString).find(Boolean);
|
||||||
|
return message || `SUB2API 请求失败(HTTP ${responseStatus}):${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(origin, path, options = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = normalizeString(options.token);
|
||||||
|
const response = await fetchImpl(`${origin}${path}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'code')) {
|
||||||
|
if (Number(payload.code) === 0) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
throw new Error(getSub2ApiErrorMessage(payload, response.status, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(getSub2ApiErrorMessage(payload, response.status, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`SUB2API 请求超时:${path}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginSub2Api(state = {}, options = {}) {
|
||||||
|
const email = normalizeString(state.sub2apiEmail);
|
||||||
|
const password = String(state.sub2apiPassword || '');
|
||||||
|
const origin = getSub2ApiOrigin(state.sub2apiUrl);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。');
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = await requestJson(origin, '/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
body: { email, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = normalizeString(loginData?.access_token || loginData?.accessToken);
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('SUB2API 登录返回缺少 access_token。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
origin,
|
||||||
|
token,
|
||||||
|
user: loginData?.user || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSub2ApiGroupNames(value) {
|
||||||
|
const source = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: String(value || '').split(/[\r\n,,;;]+/);
|
||||||
|
const seen = new Set();
|
||||||
|
const names = [];
|
||||||
|
for (const item of source) {
|
||||||
|
const name = normalizeString(item);
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (!name || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
return names.length ? names : [DEFAULT_SUB2API_GROUP_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroupsByNames(origin, token, groupNames, options = {}) {
|
||||||
|
const targetNames = normalizeSub2ApiGroupNames(groupNames);
|
||||||
|
const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
|
||||||
|
method: 'GET',
|
||||||
|
token,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
});
|
||||||
|
const matched = [];
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
for (const targetName of targetNames) {
|
||||||
|
const normalized = targetName.toLowerCase();
|
||||||
|
const group = (Array.isArray(groups) ? groups : []).find((item) => {
|
||||||
|
const itemName = normalizeString(item?.name).toLowerCase();
|
||||||
|
if (!itemName || itemName !== normalized) return false;
|
||||||
|
return !item.platform || item.platform === 'openai';
|
||||||
|
});
|
||||||
|
if (group) {
|
||||||
|
matched.push(group);
|
||||||
|
} else {
|
||||||
|
missing.push(targetName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`SUB2API 中未找到以下 openai 分组:${missing.join('、')}。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSub2ApiProxyPreference(value) {
|
||||||
|
return normalizeString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSub2ApiProxyPreference(state = {}) {
|
||||||
|
if (state.sub2apiDefaultProxyName !== undefined) {
|
||||||
|
return normalizeSub2ApiProxyPreference(state.sub2apiDefaultProxyName);
|
||||||
|
}
|
||||||
|
return DEFAULT_PROXY_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSub2ApiAccountPriority(state = {}) {
|
||||||
|
const rawValue = normalizeString(state.sub2apiAccountPriority);
|
||||||
|
if (!rawValue) {
|
||||||
|
return DEFAULT_PRIORITY;
|
||||||
|
}
|
||||||
|
const numeric = Number(rawValue);
|
||||||
|
if (!Number.isSafeInteger(numeric) || numeric < 1) {
|
||||||
|
throw new Error('SUB2API 账号优先级必须是大于等于 1 的整数。');
|
||||||
|
}
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyId(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = Number(value);
|
||||||
|
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxyDisplayName(proxy = {}) {
|
||||||
|
const id = normalizeProxyId(proxy.id);
|
||||||
|
const name = normalizeString(proxy.name);
|
||||||
|
const protocol = normalizeString(proxy.protocol);
|
||||||
|
const host = normalizeString(proxy.host);
|
||||||
|
const port = proxy.port === undefined || proxy.port === null ? '' : normalizeString(proxy.port);
|
||||||
|
const address = protocol && host && port ? `${protocol}://${host}:${port}` : '';
|
||||||
|
return [
|
||||||
|
name || '(未命名代理)',
|
||||||
|
id ? `#${id}` : '',
|
||||||
|
address,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxySearchText(proxy = {}) {
|
||||||
|
return [
|
||||||
|
proxy.id,
|
||||||
|
proxy.name,
|
||||||
|
proxy.protocol,
|
||||||
|
proxy.host,
|
||||||
|
proxy.port,
|
||||||
|
buildProxyDisplayName(proxy),
|
||||||
|
]
|
||||||
|
.filter((value) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map((value) => normalizeString(value).toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveProxy(proxy = {}) {
|
||||||
|
const status = normalizeString(proxy.status).toLowerCase();
|
||||||
|
return !status || status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSub2ApiProxy(proxies = [], preference = '') {
|
||||||
|
const activeProxies = (Array.isArray(proxies) ? proxies : [])
|
||||||
|
.filter(isActiveProxy)
|
||||||
|
.filter((proxy) => normalizeProxyId(proxy.id));
|
||||||
|
const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase();
|
||||||
|
const preferredId = normalizeProxyId(normalizedPreference);
|
||||||
|
|
||||||
|
if (preferredId) {
|
||||||
|
const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId);
|
||||||
|
return {
|
||||||
|
proxy: matchedById || null,
|
||||||
|
reason: matchedById ? 'id' : 'missing-id',
|
||||||
|
candidates: activeProxies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPreference) {
|
||||||
|
const exactMatches = activeProxies.filter((proxy) => normalizeString(proxy.name).toLowerCase() === normalizedPreference);
|
||||||
|
if (exactMatches.length === 1) {
|
||||||
|
return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies };
|
||||||
|
}
|
||||||
|
if (exactMatches.length > 1) {
|
||||||
|
return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference));
|
||||||
|
if (fuzzyMatches.length === 1) {
|
||||||
|
return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies };
|
||||||
|
}
|
||||||
|
if (fuzzyMatches.length > 1) {
|
||||||
|
return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { proxy: null, reason: 'missing-name', candidates: activeProxies };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeProxies.length === 1) {
|
||||||
|
return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
proxy: null,
|
||||||
|
reason: activeProxies.length ? 'no-preference' : 'none-active',
|
||||||
|
candidates: activeProxies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSub2ApiProxy(origin, token, preference = '', options = {}) {
|
||||||
|
const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', {
|
||||||
|
method: 'GET',
|
||||||
|
token,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
});
|
||||||
|
if (!Array.isArray(proxies)) {
|
||||||
|
throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference);
|
||||||
|
if (proxy) {
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)';
|
||||||
|
const available = (candidates || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(buildProxyDisplayName)
|
||||||
|
.join(';') || '无可用代理';
|
||||||
|
if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') {
|
||||||
|
throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`);
|
||||||
|
}
|
||||||
|
if (reason === 'missing-id') {
|
||||||
|
throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`);
|
||||||
|
}
|
||||||
|
if (reason === 'missing-name') {
|
||||||
|
throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`);
|
||||||
|
}
|
||||||
|
if (reason === 'no-preference') {
|
||||||
|
throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID;留空则不使用代理。可用代理:${available}`);
|
||||||
|
}
|
||||||
|
throw new Error('SUB2API 没有可用代理;请检查默认代理配置,或将其留空以禁用代理。');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftAccountName(groupName) {
|
||||||
|
const prefix = normalizeString(groupName || DEFAULT_SUB2API_GROUP_NAME)
|
||||||
|
.replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || DEFAULT_SUB2API_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 parseLocalhostCallback(rawUrl, visibleStep = 10) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('回调 URL 协议不正确。');
|
||||||
|
}
|
||||||
|
if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
|
||||||
|
throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`);
|
||||||
|
}
|
||||||
|
if (parsed.pathname !== '/auth/callback') {
|
||||||
|
throw new Error('回调 URL 路径必须是 /auth/callback。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = normalizeString(parsed.searchParams.get('code'));
|
||||||
|
const state = normalizeString(parsed.searchParams.get('state'));
|
||||||
|
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 logWithOptions(message, level = 'info', options = {}) {
|
||||||
|
await addLog(message, level, options.logOptions || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateOpenAiAuthUrl(state = {}, options = {}) {
|
||||||
|
const logLabel = normalizeString(options.logLabel) || 'OAuth 刷新';
|
||||||
|
const redirectUri = normalizeRedirectUri(options.redirectUri || DEFAULT_REDIRECT_URI);
|
||||||
|
const groupNames = normalizeSub2ApiGroupNames(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME);
|
||||||
|
const groupName = groupNames[0] || DEFAULT_SUB2API_GROUP_NAME;
|
||||||
|
|
||||||
|
await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口登录并生成 OpenAI Auth 链接...`, 'info', options);
|
||||||
|
const { origin, token } = await loginSub2Api(state, options);
|
||||||
|
const groups = await getGroupsByNames(origin, token, groupNames, options);
|
||||||
|
const group = groups[0];
|
||||||
|
const proxyPreference = resolveSub2ApiProxyPreference(state);
|
||||||
|
const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference, options) : null;
|
||||||
|
const proxyId = normalizeProxyId(proxy?.id);
|
||||||
|
const draftName = buildDraftAccountName(group.name || groupName);
|
||||||
|
const groupLabel = groups.map((item) => `${item.name}(#${item.id})`).join('、');
|
||||||
|
|
||||||
|
await logWithOptions(`${logLabel}:已登录 SUB2API,使用分组 ${groupLabel}。`, 'info', options);
|
||||||
|
if (proxy) {
|
||||||
|
await logWithOptions(`${logLabel}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options);
|
||||||
|
} else {
|
||||||
|
await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestBody = { redirect_uri: redirectUri };
|
||||||
|
if (proxyId) {
|
||||||
|
authRequestBody.proxy_id = proxyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
body: authRequestBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const oauthUrl = normalizeString(authData?.auth_url || authData?.authUrl);
|
||||||
|
const sessionId = normalizeString(authData?.session_id || authData?.sessionId);
|
||||||
|
const oauthState = normalizeString(authData?.state || extractStateFromAuthUrl(oauthUrl));
|
||||||
|
|
||||||
|
if (!oauthUrl || !sessionId) {
|
||||||
|
throw new Error('SUB2API 未返回完整的 auth_url / session_id。');
|
||||||
|
}
|
||||||
|
|
||||||
|
await logWithOptions(`${logLabel}:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok', options);
|
||||||
|
return {
|
||||||
|
oauthUrl,
|
||||||
|
sub2apiSessionId: sessionId,
|
||||||
|
sub2apiOAuthState: oauthState,
|
||||||
|
sub2apiGroupId: group.id,
|
||||||
|
sub2apiGroupIds: groups.map((item) => item.id),
|
||||||
|
sub2apiDraftName: draftName,
|
||||||
|
sub2apiProxyId: proxyId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOpenAiCallback(state = {}, options = {}) {
|
||||||
|
const visibleStep = Number(options.visibleStep || state.visibleStep) || 10;
|
||||||
|
const callback = parseLocalhostCallback(state.localhostUrl || '', visibleStep);
|
||||||
|
const flowEmail = normalizeString(state.email);
|
||||||
|
const sessionId = normalizeString(state.sub2apiSessionId);
|
||||||
|
const expectedState = normalizeString(state.sub2apiOAuthState);
|
||||||
|
const logLabel = normalizeString(options.logLabel) || `步骤 ${visibleStep}`;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。');
|
||||||
|
}
|
||||||
|
if (expectedState && expectedState !== callback.state) {
|
||||||
|
throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origin, token } = await loginSub2Api(state, options);
|
||||||
|
const proxyPreference = resolveSub2ApiProxyPreference(state);
|
||||||
|
const preferredProxyId = normalizeProxyId(state.sub2apiProxyId);
|
||||||
|
const proxySelector = preferredProxyId || proxyPreference;
|
||||||
|
const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector, options) : null;
|
||||||
|
const proxyId = normalizeProxyId(proxy?.id);
|
||||||
|
const accountPriority = resolveSub2ApiAccountPriority(state);
|
||||||
|
const storedGroupIds = Array.isArray(state.sub2apiGroupIds) ? state.sub2apiGroupIds : [];
|
||||||
|
const groupIdsFromState = storedGroupIds
|
||||||
|
.map((id) => Number(id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
const groups = groupIdsFromState.length
|
||||||
|
? groupIdsFromState.map((id) => ({ id }))
|
||||||
|
: (state.sub2apiGroupId
|
||||||
|
? [{ id: state.sub2apiGroupId, name: state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME }]
|
||||||
|
: await getGroupsByNames(origin, token, state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME, options));
|
||||||
|
|
||||||
|
await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口交换 OpenAI 授权码...`, 'info', options);
|
||||||
|
if (proxy) {
|
||||||
|
await logWithOptions(`${logLabel}:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options);
|
||||||
|
} else {
|
||||||
|
await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeRequestBody = {
|
||||||
|
session_id: sessionId,
|
||||||
|
code: callback.code,
|
||||||
|
state: callback.state,
|
||||||
|
};
|
||||||
|
if (proxyId) {
|
||||||
|
exchangeRequestBody.proxy_id = proxyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
body: exchangeRequestBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentials = buildOpenAiCredentials(exchangeData);
|
||||||
|
const extra = buildOpenAiExtra(exchangeData);
|
||||||
|
const resolvedEmail = normalizeString(exchangeData?.email || credentials?.email);
|
||||||
|
const groupIds = groups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.filter((id) => Number.isFinite(id) && id > 0);
|
||||||
|
if (!groupIds.length) {
|
||||||
|
throw new Error('SUB2API 返回的目标分组 ID 无效。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountName = resolvedEmail
|
||||||
|
|| flowEmail
|
||||||
|
|| normalizeString(state.sub2apiDraftName)
|
||||||
|
|| buildDraftAccountName(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME);
|
||||||
|
const createPayload = {
|
||||||
|
name: accountName,
|
||||||
|
notes: '',
|
||||||
|
platform: 'openai',
|
||||||
|
type: 'oauth',
|
||||||
|
credentials,
|
||||||
|
concurrency: DEFAULT_CONCURRENCY,
|
||||||
|
priority: accountPriority,
|
||||||
|
rate_multiplier: DEFAULT_RATE_MULTIPLIER,
|
||||||
|
group_ids: groupIds,
|
||||||
|
auto_pause_on_expired: true,
|
||||||
|
};
|
||||||
|
if (proxyId) {
|
||||||
|
createPayload.proxy_id = proxyId;
|
||||||
|
}
|
||||||
|
if (extra) {
|
||||||
|
createPayload.extra = extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logWithOptions(`${logLabel}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`, 'info', options);
|
||||||
|
const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
timeoutMs: options.createTimeoutMs,
|
||||||
|
body: createPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
|
||||||
|
await logWithOptions(verifiedStatus, 'ok', options);
|
||||||
|
return {
|
||||||
|
localhostUrl: callback.url,
|
||||||
|
verifiedStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildDraftAccountName,
|
||||||
|
buildOpenAiCredentials,
|
||||||
|
buildOpenAiExtra,
|
||||||
|
buildProxyDisplayName,
|
||||||
|
extractStateFromAuthUrl,
|
||||||
|
generateOpenAiAuthUrl,
|
||||||
|
getGroupsByNames,
|
||||||
|
loginSub2Api,
|
||||||
|
normalizeProxyId,
|
||||||
|
normalizeRedirectUri,
|
||||||
|
normalizeSub2ApiGroupNames,
|
||||||
|
parseLocalhostCallback,
|
||||||
|
requestJson,
|
||||||
|
resolveSub2ApiAccountPriority,
|
||||||
|
resolveSub2ApiProxy,
|
||||||
|
submitOpenAiCallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSub2ApiApi,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -73,6 +73,8 @@ const PERSISTED_SETTING_DEFAULTS = {
|
|||||||
};
|
};
|
||||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||||
|
function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; }
|
||||||
|
function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); }
|
||||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||||
@@ -163,6 +165,8 @@ const PERSISTED_SETTING_DEFAULTS = {
|
|||||||
};
|
};
|
||||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||||
|
function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; }
|
||||||
|
function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); }
|
||||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ test('panel bridge module exposes a factory', () => {
|
|||||||
assert.equal(typeof api?.createPanelBridge, 'function');
|
assert.equal(typeof api?.createPanelBridge, 'function');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('panel bridge requests oauth url with step 7 log label payload', () => {
|
function loadPanelBridgeWithSub2Api() {
|
||||||
|
const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8');
|
||||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||||
assert.match(source, /logStep:\s*7/);
|
return new Function('self', `${sub2apiSource}\n${source}; return self.MultiPageBackgroundPanelBridge;`)({});
|
||||||
assert.doesNotMatch(source, /logStep:\s*6/);
|
}
|
||||||
|
|
||||||
|
function createSub2ApiResponse(payload, status = 200) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
text: async () => JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('panel bridge requests SUB2API oauth url via direct API', () => {
|
||||||
|
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||||
|
assert.match(source, /generateOpenAiAuthUrl/);
|
||||||
|
assert.doesNotMatch(source, /REQUEST_OAUTH_URL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('panel bridge can request codex2api oauth url via protocol', async () => {
|
test('panel bridge can request codex2api oauth url via protocol', async () => {
|
||||||
@@ -117,16 +131,63 @@ test('panel bridge can request cpa oauth url via management api', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('panel bridge forwards SUB2API account priority when requesting oauth url', async () => {
|
test('panel bridge can request SUB2API oauth url without opening the admin page', async () => {
|
||||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
const originalFetch = globalThis.fetch;
|
||||||
const sentMessages = [];
|
const fetchCalls = [];
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const body = options.body ? JSON.parse(options.body) : null;
|
||||||
|
fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body });
|
||||||
|
|
||||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
|
if (parsed.pathname === '/api/v1/auth/login') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: { access_token: 'admin-token' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/groups/all') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: [{ id: 5, name: 'codex', platform: 'openai' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/proxies/all') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: [{
|
||||||
|
id: 7,
|
||||||
|
name: 'shadowrocket',
|
||||||
|
protocol: 'socks5',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1080,
|
||||||
|
status: 'active',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
auth_url: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||||
|
session_id: 'session-123',
|
||||||
|
state: 'oauth-state',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabCreates = [];
|
||||||
|
const sentMessages = [];
|
||||||
|
const api = loadPanelBridgeWithSub2Api();
|
||||||
const bridge = api.createPanelBridge({
|
const bridge = api.createPanelBridge({
|
||||||
addLog: async () => {},
|
addLog: async () => {},
|
||||||
chrome: {
|
chrome: {
|
||||||
tabs: {
|
tabs: {
|
||||||
create: async () => ({ id: 72 }),
|
create: async (payload) => {
|
||||||
|
tabCreates.push(payload);
|
||||||
|
return { id: 72 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
closeConflictingTabsForSource: async () => {},
|
closeConflictingTabsForSource: async () => {},
|
||||||
@@ -137,11 +198,7 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url',
|
|||||||
rememberSourceLastUrl: async () => {},
|
rememberSourceLastUrl: async () => {},
|
||||||
sendToContentScript: async (sourceName, message, options) => {
|
sendToContentScript: async (sourceName, message, options) => {
|
||||||
sentMessages.push({ sourceName, message, options });
|
sentMessages.push({ sourceName, message, options });
|
||||||
return {
|
return {};
|
||||||
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
|
|
||||||
sub2apiSessionId: 'session-123',
|
|
||||||
sub2apiOAuthState: 'oauth-state',
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
sendToContentScriptResilient: async () => ({}),
|
sendToContentScriptResilient: async () => ({}),
|
||||||
waitForTabUrlFamily: async () => ({ id: 72 }),
|
waitForTabUrlFamily: async () => ({ id: 72 }),
|
||||||
@@ -149,16 +206,30 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url',
|
|||||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
|
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await bridge.requestOAuthUrlFromPanel({
|
try {
|
||||||
panelMode: 'sub2api',
|
const result = await bridge.requestOAuthUrlFromPanel({
|
||||||
sub2apiUrl: 'https://sub.example/admin/accounts',
|
panelMode: 'sub2api',
|
||||||
sub2apiEmail: 'admin@example.com',
|
sub2apiUrl: 'https://sub.example/admin/accounts',
|
||||||
sub2apiPassword: 'secret',
|
sub2apiEmail: 'admin@example.com',
|
||||||
sub2apiGroupName: 'codex',
|
sub2apiPassword: 'secret',
|
||||||
sub2apiAccountPriority: 3,
|
sub2apiGroupName: 'codex',
|
||||||
}, { logLabel: '步骤 7' });
|
sub2apiDefaultProxyName: 'shadowrocket',
|
||||||
|
}, { logLabel: '步骤 7' });
|
||||||
|
|
||||||
assert.equal(sentMessages.length, 1);
|
const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url');
|
||||||
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
|
assert.equal(tabCreates.length, 0);
|
||||||
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 3);
|
assert.equal(sentMessages.length, 0);
|
||||||
|
assert.equal(generateCall.body.proxy_id, 7);
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||||
|
sub2apiSessionId: 'session-123',
|
||||||
|
sub2apiOAuthState: 'oauth-state',
|
||||||
|
sub2apiGroupId: 5,
|
||||||
|
sub2apiGroupIds: [5],
|
||||||
|
sub2apiDraftName: result.sub2apiDraftName,
|
||||||
|
sub2apiProxyId: 7,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,72 +80,14 @@ test('platform verify module supports codex2api protocol callback exchange', asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('platform verify retries transient SUB2API oauth/token exchange errors before failing', async () => {
|
test('platform verify retries transient SUB2API oauth/token exchange errors before succeeding', async () => {
|
||||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||||
|
|
||||||
const logs = [];
|
const logs = [];
|
||||||
const attempts = [];
|
const attempts = [];
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
const executor = api.createStep10Executor({
|
let contentScriptCalled = false;
|
||||||
addLog: async (message, level = 'info') => {
|
|
||||||
logs.push({ message, level });
|
|
||||||
},
|
|
||||||
chrome: {
|
|
||||||
tabs: {
|
|
||||||
update: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
closeConflictingTabsForSource: async () => {},
|
|
||||||
completeStepFromBackground: async () => {},
|
|
||||||
ensureContentScriptReadyOnTab: async () => {},
|
|
||||||
getPanelMode: () => 'sub2api',
|
|
||||||
getTabId: async () => 12,
|
|
||||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
|
||||||
isTabAlive: async () => true,
|
|
||||||
normalizeCodex2ApiUrl: (value) => value,
|
|
||||||
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
|
|
||||||
rememberSourceLastUrl: async () => {},
|
|
||||||
reuseOrCreateTab: async () => 12,
|
|
||||||
sendToContentScript: async (_source, message) => {
|
|
||||||
attempts.push(message.type);
|
|
||||||
callCount += 1;
|
|
||||||
if (callCount === 1) {
|
|
||||||
return {
|
|
||||||
error: 'request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
sendToContentScriptResilient: async () => ({}),
|
|
||||||
shouldBypassStep9ForLocalCpa: () => false,
|
|
||||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executor.executeStep10({
|
|
||||||
panelMode: 'sub2api',
|
|
||||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
|
||||||
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
|
|
||||||
sub2apiEmail: 'flow@example.com',
|
|
||||||
sub2apiPassword: 'secret',
|
|
||||||
sub2apiSessionId: 'session-123',
|
|
||||||
sub2apiOAuthState: 'oauth-state',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
assert.deepStrictEqual(attempts, ['EXECUTE_STEP', 'EXECUTE_STEP']);
|
|
||||||
assert.equal(
|
|
||||||
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
|
|
||||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
|
||||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
|
||||||
|
|
||||||
const logs = [];
|
|
||||||
let callCount = 0;
|
|
||||||
const executor = api.createStep10Executor({
|
const executor = api.createStep10Executor({
|
||||||
addLog: async (message, level = 'info') => {
|
addLog: async (message, level = 'info') => {
|
||||||
logs.push({ message, level });
|
logs.push({ message, level });
|
||||||
@@ -167,14 +109,22 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
|
|||||||
rememberSourceLastUrl: async () => {},
|
rememberSourceLastUrl: async () => {},
|
||||||
reuseOrCreateTab: async () => 12,
|
reuseOrCreateTab: async () => 12,
|
||||||
sendToContentScript: async () => {
|
sendToContentScript: async () => {
|
||||||
callCount += 1;
|
contentScriptCalled = true;
|
||||||
if (callCount === 1) {
|
return {};
|
||||||
return {
|
|
||||||
error: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
},
|
},
|
||||||
|
createSub2ApiApi: () => ({
|
||||||
|
submitOpenAiCallback: async () => {
|
||||||
|
attempts.push('direct-api');
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
throw new Error('request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
|
verifiedStatus: 'SUB2API 已创建账号 #11',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
sendToContentScriptResilient: async () => ({}),
|
sendToContentScriptResilient: async () => ({}),
|
||||||
shouldBypassStep9ForLocalCpa: () => false,
|
shouldBypassStep9ForLocalCpa: () => false,
|
||||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||||
@@ -191,6 +141,74 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(callCount, 2);
|
assert.equal(callCount, 2);
|
||||||
|
assert.equal(contentScriptCalled, false);
|
||||||
|
assert.deepStrictEqual(attempts, ['direct-api', 'direct-api']);
|
||||||
|
assert.equal(
|
||||||
|
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
|
||||||
|
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||||
|
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
let callCount = 0;
|
||||||
|
let contentScriptCalled = false;
|
||||||
|
const executor = api.createStep10Executor({
|
||||||
|
addLog: async (message, level = 'info') => {
|
||||||
|
logs.push({ message, level });
|
||||||
|
},
|
||||||
|
chrome: {
|
||||||
|
tabs: {
|
||||||
|
update: async () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
closeConflictingTabsForSource: async () => {},
|
||||||
|
completeStepFromBackground: async () => {},
|
||||||
|
ensureContentScriptReadyOnTab: async () => {},
|
||||||
|
getPanelMode: () => 'sub2api',
|
||||||
|
getTabId: async () => 12,
|
||||||
|
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||||
|
isTabAlive: async () => true,
|
||||||
|
normalizeCodex2ApiUrl: (value) => value,
|
||||||
|
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
|
||||||
|
rememberSourceLastUrl: async () => {},
|
||||||
|
reuseOrCreateTab: async () => 12,
|
||||||
|
sendToContentScript: async () => {
|
||||||
|
contentScriptCalled = true;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
createSub2ApiApi: () => ({
|
||||||
|
submitOpenAiCallback: async () => {
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
throw new Error('token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
|
verifiedStatus: 'SUB2API 已创建账号 #11',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sendToContentScriptResilient: async () => ({}),
|
||||||
|
shouldBypassStep9ForLocalCpa: () => false,
|
||||||
|
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await executor.executeStep10({
|
||||||
|
panelMode: 'sub2api',
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
|
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
|
||||||
|
sub2apiEmail: 'flow@example.com',
|
||||||
|
sub2apiPassword: 'secret',
|
||||||
|
sub2apiSessionId: 'session-123',
|
||||||
|
sub2apiOAuthState: 'oauth-state',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(callCount, 2);
|
||||||
|
assert.equal(contentScriptCalled, false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ function createDeps(overrides = {}) {
|
|||||||
return { deps, logs, completed, uiCalls };
|
return { deps, logs, completed, uiCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadStep10WithSub2Api() {
|
||||||
|
const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8');
|
||||||
|
const step10Source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||||
|
return new Function('self', `${sub2apiSource}\n${step10Source}; return self.MultiPageBackgroundStep10;`)({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSub2ApiResponse(payload, status = 200) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
text: async () => JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('platform verify module submits CPA callback via management API first', async () => {
|
test('platform verify module submits CPA callback via management API first', async () => {
|
||||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -240,11 +254,109 @@ test('platform verify module rejects callback when cpa oauth state mismatches',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => {
|
test('platform verify module submits Plus visible step 13 to SUB2API via direct API', async () => {
|
||||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchCalls = [];
|
||||||
const sentMessages = [];
|
const sentMessages = [];
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const body = options.body ? JSON.parse(options.body) : null;
|
||||||
|
fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body });
|
||||||
|
|
||||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
if (parsed.pathname === '/api/v1/auth/login') {
|
||||||
|
return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } });
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/groups/all') {
|
||||||
|
return createSub2ApiResponse({ code: 0, data: [{ id: 5, name: 'codex', platform: 'openai' }] });
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
access_token: 'openai-access',
|
||||||
|
refresh_token: 'openai-refresh',
|
||||||
|
email: 'flow@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/accounts') {
|
||||||
|
return createSub2ApiResponse({ code: 0, data: { id: 11 } });
|
||||||
|
}
|
||||||
|
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = loadStep10WithSub2Api();
|
||||||
|
const { deps, completed } = createDeps({
|
||||||
|
getPanelMode: () => 'sub2api',
|
||||||
|
getTabId: async () => 91,
|
||||||
|
isTabAlive: async () => true,
|
||||||
|
sendToContentScript: async (sourceName, message, options) => {
|
||||||
|
sentMessages.push({ sourceName, message, options });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const executor = api.createStep10Executor(deps);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor.executeStep10({
|
||||||
|
panelMode: 'sub2api',
|
||||||
|
visibleStep: 13,
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
|
sub2apiUrl: 'https://sub.example/admin/accounts',
|
||||||
|
sub2apiEmail: 'admin@example.com',
|
||||||
|
sub2apiPassword: 'secret',
|
||||||
|
sub2apiGroupName: 'codex',
|
||||||
|
sub2apiSessionId: 'session-1',
|
||||||
|
sub2apiOAuthState: 'oauth-state',
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
|
||||||
|
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
|
||||||
|
assert.equal(sentMessages.length, 0);
|
||||||
|
assert.equal(exchangeCall.body.code, 'callback-code');
|
||||||
|
assert.equal(exchangeCall.body.state, 'oauth-state');
|
||||||
|
assert.deepStrictEqual(createCall.body.group_ids, [5]);
|
||||||
|
assert.deepStrictEqual(completed, [{
|
||||||
|
step: 13,
|
||||||
|
payload: {
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
|
verifiedStatus: 'SUB2API 已创建账号 #11',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform verify module forwards SUB2API account priority to direct create API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchCalls = [];
|
||||||
|
const sentMessages = [];
|
||||||
|
globalThis.fetch = async (url, options = {}) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const body = options.body ? JSON.parse(options.body) : null;
|
||||||
|
fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body });
|
||||||
|
|
||||||
|
if (parsed.pathname === '/api/v1/auth/login') {
|
||||||
|
return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } });
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
|
||||||
|
return createSub2ApiResponse({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
access_token: 'openai-access',
|
||||||
|
refresh_token: 'openai-refresh',
|
||||||
|
email: 'flow@example.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.pathname === '/api/v1/admin/accounts') {
|
||||||
|
return createSub2ApiResponse({ code: 0, data: { id: 11 } });
|
||||||
|
}
|
||||||
|
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = loadStep10WithSub2Api();
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
getPanelMode: () => 'sub2api',
|
getPanelMode: () => 'sub2api',
|
||||||
getTabId: async () => 91,
|
getTabId: async () => 91,
|
||||||
@@ -256,50 +368,23 @@ test('platform verify module sends Plus visible step 13 to SUB2API panel', async
|
|||||||
});
|
});
|
||||||
const executor = api.createStep10Executor(deps);
|
const executor = api.createStep10Executor(deps);
|
||||||
|
|
||||||
await executor.executeStep10({
|
try {
|
||||||
panelMode: 'sub2api',
|
await executor.executeStep10({
|
||||||
visibleStep: 13,
|
panelMode: 'sub2api',
|
||||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||||
sub2apiUrl: 'https://sub.example/admin/accounts',
|
sub2apiUrl: 'https://sub.example/admin/accounts',
|
||||||
sub2apiEmail: 'admin@example.com',
|
sub2apiEmail: 'admin@example.com',
|
||||||
sub2apiPassword: 'secret',
|
sub2apiPassword: 'secret',
|
||||||
sub2apiSessionId: 'session-1',
|
sub2apiSessionId: 'session-1',
|
||||||
sub2apiOAuthState: 'oauth-state',
|
sub2apiOAuthState: 'oauth-state',
|
||||||
});
|
sub2apiGroupId: 5,
|
||||||
|
sub2apiAccountPriority: 2,
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(sentMessages.length, 1);
|
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
|
||||||
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
|
assert.equal(sentMessages.length, 0);
|
||||||
assert.equal(sentMessages[0].message.step, 13);
|
assert.equal(createCall.body.priority, 2);
|
||||||
});
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
test('platform verify module forwards SUB2API account priority to panel', async () => {
|
}
|
||||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
|
||||||
const sentMessages = [];
|
|
||||||
|
|
||||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
|
||||||
const { deps } = createDeps({
|
|
||||||
getPanelMode: () => 'sub2api',
|
|
||||||
getTabId: async () => 91,
|
|
||||||
isTabAlive: async () => true,
|
|
||||||
sendToContentScript: async (sourceName, message, options) => {
|
|
||||||
sentMessages.push({ sourceName, message, options });
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const executor = api.createStep10Executor(deps);
|
|
||||||
|
|
||||||
await executor.executeStep10({
|
|
||||||
panelMode: 'sub2api',
|
|
||||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
|
||||||
sub2apiUrl: 'https://sub.example/admin/accounts',
|
|
||||||
sub2apiEmail: 'admin@example.com',
|
|
||||||
sub2apiPassword: 'secret',
|
|
||||||
sub2apiSessionId: 'session-1',
|
|
||||||
sub2apiOAuthState: 'oauth-state',
|
|
||||||
sub2apiAccountPriority: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(sentMessages.length, 1);
|
|
||||||
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
|
|
||||||
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 2);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user