Expand seller store functionality

This commit is contained in:
R524809
2026-09-07 17:51:11 +08:00
parent 4c0f3a4a79
commit ceb644a3f9
35 changed files with 32883 additions and 364 deletions
+64 -20
View File
@@ -2,13 +2,11 @@ import type { CollectedProductPayload } from '@atlas/contracts';
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
const clientId = (import.meta.env.ATLAS_EXTENSION_CLIENT_ID as string | undefined) ?? 'atlas-collector';
type Tokens = { access_token: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number; token_type: 'Bearer' };
type Session = { authenticated: true; workspace: { id: string; available: boolean }; can_collect: boolean };
const storageKey = 'atlas.extension.tokens.v1';
const storageKey = 'atlas.extension.web-token.v1';
async function loadTokens() { return (await browser.storage.local.get(storageKey))[storageKey] as Tokens | undefined; }
async function saveTokens(tokens: Tokens) { await browser.storage.local.set({ [storageKey]: tokens }); }
async function loadToken() { return (await browser.storage.local.get(storageKey))[storageKey] as string | undefined; }
async function saveToken(token: string) { await browser.storage.local.set({ [storageKey]: token }); }
async function clearTokens() { await browser.storage.local.remove(storageKey); }
async function request(path: string, options: RequestInit = {}, token?: string) {
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
@@ -16,24 +14,70 @@ async function request(path: string, options: RequestInit = {}, token?: string)
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
}
async function activeAccessToken() {
let tokens = await loadTokens(); if (!tokens) return null;
try { await request('/extension/session', {}, tokens.access_token); return tokens.access_token; }
catch (error) {
if ((error as { status?: number }).status !== 401) throw error;
try { const refreshed = await request('/extension/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: tokens.refresh_token }) }) as { data: Tokens }; tokens = refreshed.data; await saveTokens(tokens); return tokens.access_token; }
catch { await clearTokens(); return null; }
}
const cached = await loadToken();
const usableCached = await validateAccessToken(cached);
if (usableCached) return usableCached;
// 用户可能先在 Atlas Web 登录、再打开来源商品页。此时插件还没有自己的
// 缓存,仍应直接从已打开的 Atlas 标签页同步,而不是错误地展示“登录”。
const synchronized = await syncAccessTokenFromWebTabs();
return validateAccessToken(synchronized);
}
export async function getSession(): Promise<Session | null> { const token = await activeAccessToken(); if (!token) return null; return (await request('/extension/session', {}, token) as { data: Session }).data; }
export async function login() {
const verifier = crypto.getRandomValues(new Uint8Array(64)); const verifierText = base64url(verifier); const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifierText))));
const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const redirectUri = browser.identity.getRedirectURL('callback');
const authorize = new URL(`${apiOrigin}/api/extension/authorize`); authorize.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
const callback = await browser.identity.launchWebAuthFlow({ url: authorize.toString(), interactive: true });
if (!callback) throw new Error('未完成 Atlas ERP 登录'); const result = new URL(callback); if (result.searchParams.get('state') !== state || !result.searchParams.get('code')) throw new Error('扩展授权状态校验失败');
const exchanged = await request('/extension/auth/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: clientId, code: result.searchParams.get('code'), redirect_uri: redirectUri, code_verifier: verifierText }) }) as { data: Tokens };
await saveTokens(exchanged.data); return getSession();
const currentSession = await getSession();
if (currentSession) return currentSession;
const tab = await browser.tabs.create({ url: webOrigin, active: true });
if (tab.id === undefined) throw new Error('无法打开 Atlas ERP 登录页');
const deadline = Date.now() + 3 * 60 * 1_000;
while (Date.now() < deadline) {
const token = await readWebAccessToken(tab.id);
if (token) {
await saveToken(token);
const session = await getSession();
if (session) return session;
await clearTokens();
}
await delay(2_000);
}
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
}
export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; }
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
function base64url(bytes: Uint8Array) { let value = ''; for (const byte of bytes) value += String.fromCharCode(byte); return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
async function readWebAccessToken(tabId: number) {
try {
const result = await browser.scripting.executeScript({ target: { tabId }, func: () => localStorage.getItem('atlas.extension.web-access') });
const raw = result[0]?.result;
if (typeof raw !== 'string') return null;
const parsed = JSON.parse(raw) as { accessToken?: unknown };
return typeof parsed.accessToken === 'string' && parsed.accessToken.length > 0 ? parsed.accessToken : null;
} catch { return null; }
}
async function syncAccessTokenFromWebTabs() {
const tabs = await browser.tabs.query({ url: `${webOrigin}/*` });
// tabs.query 返回的顺序未承诺。倒序优先尝试最近的标签页(Chrome 通常最后返回它)。
for (const tab of tabs.reverse()) {
if (tab.id === undefined) continue;
const token = await readWebAccessToken(tab.id);
if (token) {
await saveToken(token);
return token;
}
}
return null;
}
async function validateAccessToken(token: string | undefined | null) {
if (!token) return null;
try {
await request('/extension/session', {}, token);
return token;
} catch (error) {
if ((error as { status?: number }).status === 401) {
await clearTokens();
return null;
}
throw error;
}
}
function delay(milliseconds: number) { return new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); }