Files
atlas-seller-kit/apps/worker/src/ozon-client.ts
T

216 lines
7.5 KiB
TypeScript

/** 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 {}
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<void> {
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<OzonCategoryNode>(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<OzonCategoryAttribute>(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<OzonDictionaryValue>(body, '属性字典');
}
const values: OzonDictionaryValue[] = [];
const seen = new Set<string>();
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<OzonDictionaryValue>(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<string>();
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<OzonDictionaryValue>(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 };
}
async function requestOzon(
clientId: string,
apiKey: string,
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
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<unknown>;
if (response.status === 401 || response.status === 403) {
throw new OzonCredentialError(response.status);
}
if (response.status === 429 || response.status >= 500) {
throw new OzonRetryableError(`Ozon temporarily unavailable (${response.status})`);
}
throw new OzonRequestError(response.status);
}
function readResultArray<T>(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;
}