/** 401 是认证失败;403 表示密钥权限或账户组合不被允许。 */ export class OzonCredentialError extends Error { public constructor(public readonly status: 401 | 403) { super(`Ozon credential request failed (${status})`); } } /** 认证以外的确定性 4xx,不能误报为凭据无效。 */ export class OzonRequestError extends Error { public constructor(public readonly status: number) { super(`Ozon verification request was rejected (${status})`); } } export class OzonRetryableError extends Error {} /** 429 明确未受理写请求,可按平台要求延迟重试。 */ export class OzonRateLimitError extends OzonRetryableError { constructor(public readonly retryAfterSeconds: number) { super('Ozon request rate limited'); } } export type OzonCategoryNode = { /** 类型叶子会省略这两个字段,继承最近的父类目。 */ description_category_id?: number | string; category_name?: string | null; type_id?: number | string | null; type_name?: string | null; disabled?: boolean; children?: OzonCategoryNode[]; }; export type OzonCategoryAttribute = { id: number | string; name: string; type?: string | null; is_collection?: boolean; is_required?: boolean; is_aspect?: boolean; max_value_count?: number | null; dictionary_id?: number | string | null; complex_id?: number | string | null; [key: string]: unknown; }; export type OzonDictionaryValue = { id: number | string; value: string; [key: string]: unknown; }; /** * Uses the category-tree read endpoint as the credential probe. It has no write side effect and, * unlike warehouse-list, is verified against the current Ozon account integration. */ export async function verifyOzonCredentials( clientId: string, apiKey: string, ): Promise { await requestOzon(clientId, apiKey, '/v1/description-category/tree', { language: 'ZH_HANS', }); } /** 平台目录同步只在 Worker 中使用,密钥仅在此函数调用期间留在内存。 */ export async function fetchOzonCategoryTree(clientId: string, apiKey: string) { const body = await requestOzon(clientId, apiKey, '/v1/description-category/tree', { language: 'ZH_HANS', }); return readResultArray(body, '类目树'); } export async function fetchOzonCategoryAttributes( clientId: string, apiKey: string, categoryId: string, typeId: string, ) { const body = await requestOzon(clientId, apiKey, '/v1/description-category/attribute', { description_category_id: numberOrText(categoryId), type_id: numberOrText(typeId), language: 'ZH_HANS', }); return readResultArray(body, '类目属性'); } export async function fetchOzonDictionaryValues( clientId: string, apiKey: string, input: { attributeId: string; categoryId: string; typeId: string; query?: string; }, ) { const query = input.query?.trim() ?? ''; const request = { attribute_id: numberOrText(input.attributeId), description_category_id: numberOrText(input.categoryId), type_id: numberOrText(input.typeId), language: 'ZH_HANS', }; if (query) { const body = await requestOzon( clientId, apiKey, '/v1/description-category/attribute/values/search', { ...request, limit: 100, value: query }, ); return readResultArray(body, '属性字典'); } const values: OzonDictionaryValue[] = []; const seen = new Set(); let lastValueId: number | string = 0; // 大字典仅预取首批 1000 项;后续值由关键词搜索补充,重复游标不得造成无限循环。 for (let pageIndex = 0; pageIndex < 20 && values.length < 1_000; pageIndex += 1) { const body = await requestOzon( clientId, apiKey, '/v1/description-category/attribute/values', { ...request, limit: 1_000 - values.length, last_value_id: lastValueId }, ); const page = readResultArray(body, '属性字典'); let added = 0; for (const value of page) { const id = String(value.id ?? ''); if (!id || seen.has(id)) continue; seen.add(id); values.push(value); added += 1; } const nextId = page.at(-1)?.id; if ( (body as { has_next?: unknown }).has_next !== true || !page.length || !added || nextId == null || String(nextId) === String(lastValueId) ) break; lastValueId = typeof nextId === 'string' ? numberOrText(nextId) : nextId; } return values; } /** 平台级字典按页获取,明确返回完整性;大字典不会伪装成已全量同步。 */ export async function fetchOzonDictionaryBatch( clientId: string, apiKey: string, input: { attributeId: string; categoryId: string; typeId: string }, ): Promise<{ values: OzonDictionaryValue[]; isComplete: boolean }> { const values: OzonDictionaryValue[] = []; const seen = new Set(); let lastValueId: number | string = 0; for (let pageIndex = 0; pageIndex < 5; pageIndex += 1) { const body = await requestOzon( clientId, apiKey, '/v1/description-category/attribute/values', { attribute_id: numberOrText(input.attributeId), description_category_id: numberOrText(input.categoryId), type_id: numberOrText(input.typeId), language: 'ZH_HANS', limit: 2000, last_value_id: lastValueId, }, ); const page = readResultArray(body, '属性字典'); for (const value of page) { const id = String(value.id ?? '').trim(); if (id && !seen.has(id)) { seen.add(id); values.push(value); } } const hasNext = (body as { has_next?: unknown }).has_next === true; if (!hasNext) return { values, isComplete: true }; const nextId = page.at(-1)?.id; if (!page.length || nextId == null || String(nextId) === String(lastValueId)) { throw new OzonRetryableError('Ozon 字典分页游标没有前进'); } lastValueId = nextId; } return { values, isComplete: false }; } export async function requestOzon( clientId: string, apiKey: string, path: string, body: Record, ): Promise { let response: Response; try { response = await fetch(`https://api-seller.ozon.ru${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Client-Id': clientId, 'Api-Key': apiKey, }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); } catch { throw new OzonRetryableError('Ozon request could not be completed'); } if (response.ok) return response.json() as Promise; if (response.status === 401 || response.status === 403) { throw new OzonCredentialError(response.status); } if (response.status === 429) { const header = response.headers.get('Item-Retry-After') ?? response.headers.get('Retry-After'); const parsed = Number(header); const date = header ? (Date.parse(header)-Date.now())/1000 : NaN; throw new OzonRateLimitError(Number.isFinite(parsed) && parsed > 0 ? Math.min(86400,Math.ceil(parsed)) : Number.isFinite(date) && date > 0 ? Math.min(86400,Math.ceil(date)) : 60); } if (response.status >= 500) { throw new OzonRetryableError(`Ozon temporarily unavailable (${response.status})`); } throw new OzonRequestError(response.status); } function readResultArray(body: unknown, resource: string): T[] { if ( !body || typeof body !== 'object' || !Array.isArray((body as { result?: unknown }).result) ) { throw new OzonRetryableError(`Ozon ${resource} 响应格式异常`); } return (body as { result: T[] }).result; } function numberOrText(value: string) { return /^\d+$/.test(value) ? Number(value) : value; }