fix(contribution): dedupe localhost callback submission

This commit is contained in:
QLHazycoder
2026-04-26 08:38:00 +00:00
parent 7b668fdcaf
commit 1e22e4f7cf
2 changed files with 210 additions and 33 deletions
+59 -33
View File
@@ -44,6 +44,7 @@
} = deps;
let listenersBound = false;
const inFlightCapturedCallbackTasks = new Map();
function normalizeString(value = '') {
return String(value || '').trim();
@@ -110,6 +111,29 @@
return FINAL_STATUSES.has(normalizeContributionStatus(status));
}
function runCapturedCallbackOnce(callbackUrl, executor) {
const normalizedUrl = normalizeString(callbackUrl);
if (!normalizedUrl) {
return Promise.resolve().then(executor);
}
const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl);
if (existingTask) {
return existingTask;
}
let task = null;
task = Promise.resolve()
.then(executor)
.finally(() => {
if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) {
inFlightCapturedCallbackTasks.delete(normalizedUrl);
}
});
inFlightCapturedCallbackTasks.set(normalizedUrl, task);
return task;
}
function normalizeContributionSource(value = '') {
const normalized = normalizeString(value).toLowerCase();
return normalized === CONTRIBUTION_SOURCE_SUB2API
@@ -513,42 +537,44 @@
}
async function handleCapturedCallback(rawUrl, metadata = {}) {
const currentState = await getState();
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
return currentState;
}
if (!isContributionCallbackUrl(rawUrl, currentState)) {
return currentState;
}
const normalizedUrl = normalizeString(rawUrl);
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (
normalizedUrl
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
) {
return currentState;
}
return runCapturedCallbackOnce(normalizedUrl, async () => {
const currentState = await getState();
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
return currentState;
}
if (!isContributionCallbackUrl(normalizedUrl, currentState)) {
return currentState;
}
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'captured',
contributionCallbackMessage: buildCallbackMessage('captured'),
});
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
if (
normalizedUrl
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
) {
return currentState;
}
if (typeof addLog === 'function') {
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
}
try {
return await submitContributionCallback(normalizedUrl, {
reason: metadata.source || 'navigation',
stateOverride: await getState(),
await applyRuntimeUpdates({
contributionCallbackUrl: normalizedUrl,
contributionCallbackStatus: 'captured',
contributionCallbackMessage: buildCallbackMessage('captured'),
});
} catch {
return getState();
}
if (typeof addLog === 'function') {
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
}
try {
return await submitContributionCallback(normalizedUrl, {
reason: metadata.source || 'navigation',
stateOverride: await getState(),
});
} catch {
return getState();
}
});
}
async function pollContributionStatus(options = {}) {
@@ -696,7 +722,7 @@
}
function onTabUpdated(tabId, changeInfo, tab) {
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
const candidateUrl = normalizeString(changeInfo?.url);
if (!candidateUrl) {
return;
}
+151
View File
@@ -510,6 +510,157 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
});
test('contribution oauth manager deduplicates concurrent callback captures for the same localhost url', async () => {
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
const globalScope = {};
const fetchCalls = [];
const broadcasts = [];
let submitCallCount = 0;
let resolveSubmitRequest = null;
let currentState = {
contributionMode: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionSessionId: 'session-001',
contributionAuthState: 'oauth-state-001',
contributionStatus: 'waiting',
contributionCallbackStatus: 'idle',
};
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
globalScope,
async (url, options = {}) => {
fetchCalls.push({ url, options });
if (String(url).endsWith('/submit-callback')) {
submitCallCount += 1;
await new Promise((resolve) => {
resolveSubmitRequest = resolve;
});
return createMockResponse(true, 200, {
ok: true,
session_id: 'session-001',
status: 'processing',
message: '回调地址已提交给 CPA,正在等待结果确认。',
});
}
if (String(url).includes('/status?')) {
return createMockResponse(true, 200, {
ok: true,
session_id: 'session-001',
status: 'processing',
message: '回调地址已提交给 CPA,正在等待结果确认。',
});
}
return createMockResponse(true, 200, { ok: true });
}
);
const manager = api.createContributionOAuthManager({
addLog: async () => {},
broadcastDataUpdate(updates) {
broadcasts.push(updates);
currentState = { ...currentState, ...updates };
},
chrome: {
tabs: {
onUpdated: { addListener() {} },
},
webNavigation: {
onCommitted: { addListener() {} },
onHistoryStateUpdated: { addListener() {} },
},
},
getState: async () => currentState,
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
});
const callbackUrl = 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001';
const firstTask = manager.handleCapturedCallback(callbackUrl, { source: 'tabs.onUpdated' });
const secondTask = manager.handleCapturedCallback(callbackUrl, { source: 'webNavigation.onCommitted' });
for (let round = 0; round < 10 && submitCallCount === 0; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.equal(submitCallCount, 1);
assert.equal(
broadcasts.filter((entry) => entry.contributionCallbackStatus === 'captured').length,
1
);
assert.equal(typeof resolveSubmitRequest, 'function');
resolveSubmitRequest();
const [firstState, secondState] = await Promise.all([firstTask, secondTask]);
assert.equal(firstState.contributionCallbackStatus, 'submitted');
assert.equal(secondState.contributionCallbackStatus, 'submitted');
assert.equal(fetchCalls.filter((call) => String(call.url).endsWith('/submit-callback')).length, 1);
});
test('contribution oauth manager ignores tabs.onUpdated events without a real url change', async () => {
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
const globalScope = {};
const fetchCalls = [];
const addedListeners = {};
let currentState = {
contributionMode: true,
contributionSource: 'sub2api',
contributionTargetGroupName: 'codex号池',
contributionSessionId: 'session-001',
contributionAuthState: 'oauth-state-001',
contributionStatus: 'waiting',
contributionCallbackStatus: 'idle',
};
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
globalScope,
async (url, options = {}) => {
fetchCalls.push({ url, options });
return createMockResponse(true, 200, { ok: true, status: 'processing' });
}
);
const manager = api.createContributionOAuthManager({
addLog: async () => {},
broadcastDataUpdate(updates) {
currentState = { ...currentState, ...updates };
},
chrome: {
tabs: {
onUpdated: {
addListener(listener) {
addedListeners.onTabUpdated = listener;
},
},
},
webNavigation: {
onCommitted: { addListener() {} },
onHistoryStateUpdated: { addListener() {} },
},
},
getState: async () => currentState,
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
});
manager.ensureCallbackListeners();
assert.equal(typeof addedListeners.onTabUpdated, 'function');
addedListeners.onTabUpdated(
88,
{ status: 'complete' },
{ url: 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001' }
);
await Promise.resolve();
await Promise.resolve();
assert.equal(fetchCalls.length, 0);
});
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
const globalScope = {};