226 lines
8.2 KiB
TypeScript
226 lines
8.2 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 {}
|
|
/** 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<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 };
|
|
}
|
|
|
|
export 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) {
|
|
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<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;
|
|
}
|