544 lines
21 KiB
TypeScript
544 lines
21 KiB
TypeScript
import type { Database } from '@atlas/database';
|
||
import type { OutboxJob } from './outbox-dispatcher.js';
|
||
import {
|
||
fetchOzonCategoryAttributes,
|
||
fetchOzonCategoryTree,
|
||
fetchOzonDictionaryValues,
|
||
OzonCredentialError,
|
||
OzonRequestError,
|
||
type OzonCategoryAttribute,
|
||
type OzonCategoryNode,
|
||
type OzonDictionaryValue,
|
||
} from './ozon-client.js';
|
||
import { decryptStoreSecret } from './store-secret.crypto.js';
|
||
import { workerConfig } from './config.js';
|
||
|
||
export type CatalogSyncScope = 'tree' | 'attributes' | 'dictionary';
|
||
type CatalogSyncJob = OutboxJob & {
|
||
payload: {
|
||
store_id: string;
|
||
secret_id: string;
|
||
operation_id: string;
|
||
platform_id: string;
|
||
scope: CatalogSyncScope;
|
||
category_id?: string;
|
||
dictionary_id?: string;
|
||
query?: string;
|
||
};
|
||
};
|
||
|
||
type SyncSource = {
|
||
platform_code: string;
|
||
platform_client_id: string;
|
||
secret_ciphertext: Buffer;
|
||
secret_nonce: Buffer;
|
||
};
|
||
type CategorySource = {
|
||
external_category_id: string;
|
||
external_type_id: string | null;
|
||
};
|
||
|
||
/** Cache is store-scoped because Ozon returns it under a seller credential. */
|
||
export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||
const source = await loadSource(database, job);
|
||
if (!source) return cancelStaleOperation(database, job);
|
||
if (source.platform_code.toUpperCase() !== 'OZON') {
|
||
return failOperation(
|
||
database,
|
||
job,
|
||
'PLATFORM_CATALOG_UNSUPPORTED',
|
||
'当前平台暂不支持类目缓存同步',
|
||
);
|
||
}
|
||
|
||
const apiKey = decryptStoreSecret(
|
||
source.secret_ciphertext,
|
||
source.secret_nonce,
|
||
workerConfig.storeSecretEncryptionKey,
|
||
);
|
||
try {
|
||
let result: Record<string, unknown>;
|
||
if (job.payload.scope === 'tree') {
|
||
const nodes = await fetchOzonCategoryTree(
|
||
source.platform_client_id,
|
||
apiKey,
|
||
);
|
||
result = await persistCategoryTree(
|
||
database,
|
||
job.workspaceId,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
nodes,
|
||
);
|
||
} else {
|
||
const category = await requireCategory(
|
||
database,
|
||
job.workspaceId,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
job.payload.category_id,
|
||
);
|
||
if (!category.external_type_id) {
|
||
return failOperation(
|
||
database,
|
||
job,
|
||
'CATEGORY_TYPE_UNAVAILABLE',
|
||
'该类目缺少 Ozon 商品类型,无法同步属性',
|
||
);
|
||
}
|
||
if (job.payload.scope === 'attributes') {
|
||
const attributes = await fetchOzonCategoryAttributes(
|
||
source.platform_client_id,
|
||
apiKey,
|
||
category.external_category_id,
|
||
category.external_type_id,
|
||
);
|
||
result = await persistAttributes(
|
||
database,
|
||
job.workspaceId,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
job.payload.category_id ?? '',
|
||
attributes,
|
||
);
|
||
} else {
|
||
const dictionary = await requireDictionaryAttribute(
|
||
database,
|
||
job.workspaceId,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
job.payload.category_id,
|
||
job.payload.dictionary_id,
|
||
);
|
||
const values = await fetchOzonDictionaryValues(
|
||
source.platform_client_id,
|
||
apiKey,
|
||
{
|
||
attributeId: dictionary.platform_attribute_id,
|
||
categoryId: category.external_category_id,
|
||
typeId: category.external_type_id,
|
||
query: job.payload.query,
|
||
},
|
||
);
|
||
result = await persistDictionaryValues(
|
||
database,
|
||
job.workspaceId,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
job.payload.category_id ?? '',
|
||
job.payload.dictionary_id ?? '',
|
||
values,
|
||
);
|
||
}
|
||
}
|
||
await succeedOperation(database, job, result);
|
||
} catch (error) {
|
||
if (error instanceof OzonCredentialError) {
|
||
await failOperation(
|
||
database,
|
||
job,
|
||
error.status === 403
|
||
? 'OZON_CATALOG_CREDENTIAL_FORBIDDEN'
|
||
: 'OZON_CATALOG_CREDENTIAL_UNAUTHORIZED',
|
||
error.status === 403
|
||
? 'Ozon 拒绝读取类目:请检查 API Key 权限'
|
||
: 'Ozon 未接受当前门店凭据',
|
||
);
|
||
return;
|
||
}
|
||
if (error instanceof OzonRequestError) {
|
||
await failOperation(
|
||
database,
|
||
job,
|
||
'OZON_CATALOG_REQUEST_REJECTED',
|
||
`Ozon 拒绝类目同步请求(HTTP ${error.status})`,
|
||
);
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function markCatalogSyncUnavailable(
|
||
database: Database,
|
||
job: CatalogSyncJob,
|
||
_error: Error,
|
||
) {
|
||
await failOperation(
|
||
database,
|
||
job,
|
||
'OZON_CATALOG_SYNC_UNAVAILABLE',
|
||
'暂时无法同步 Ozon 类目数据,请稍后重试',
|
||
);
|
||
}
|
||
|
||
async function loadSource(database: Database, job: CatalogSyncJob) {
|
||
return database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.operations SET status = 'running', started_at = coalesce(started_at, now()),
|
||
attempt_count = attempt_count + 1
|
||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||
[job.payload.operation_id],
|
||
);
|
||
const source = await tx.query<SyncSource>(
|
||
`SELECT platform.code AS platform_code, store.platform_client_id,
|
||
secret.secret_ciphertext, secret.secret_nonce
|
||
FROM atlas.operations operation
|
||
JOIN atlas.stores store ON store.id = $2 AND store.deleted_at IS NULL
|
||
JOIN atlas.platforms platform ON platform.id = store.platform_id AND platform.id = $3
|
||
JOIN atlas.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id
|
||
WHERE operation.id = $1 AND operation.kind IN (
|
||
'store_category_tree_sync',
|
||
'store_category_attribute_sync',
|
||
'store_attribute_dictionary_sync'
|
||
)
|
||
AND store.credential_status = 'valid'
|
||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||
LIMIT 1`,
|
||
[
|
||
job.payload.operation_id,
|
||
job.payload.store_id,
|
||
job.payload.platform_id,
|
||
job.payload.secret_id,
|
||
],
|
||
);
|
||
return source.rows[0] ?? null;
|
||
});
|
||
}
|
||
|
||
async function cancelStaleOperation(database: Database, job: CatalogSyncJob) {
|
||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||
tx.query(
|
||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now(),
|
||
error_code = 'CATALOG_SYNC_SOURCE_REPLACED',
|
||
error_message = '门店凭据或发布配置已变化,旧类目同步已取消'
|
||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||
[job.payload.operation_id],
|
||
),
|
||
);
|
||
}
|
||
|
||
async function succeedOperation(
|
||
database: Database,
|
||
job: CatalogSyncJob,
|
||
result: Record<string, unknown>,
|
||
) {
|
||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||
tx.query(
|
||
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now(),
|
||
result = $2::jsonb, error_code = NULL, error_message = NULL
|
||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||
[job.payload.operation_id, JSON.stringify({ scope: job.payload.scope, ...result })],
|
||
),
|
||
);
|
||
}
|
||
|
||
async function failOperation(
|
||
database: Database,
|
||
job: CatalogSyncJob,
|
||
code: string,
|
||
message: string,
|
||
) {
|
||
await database.workspaceTransaction(job.workspaceId, (tx) =>
|
||
tx.query(
|
||
`UPDATE atlas.operations SET status = 'failed', completed_at = now(),
|
||
error_code = $2, error_message = $3
|
||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||
[job.payload.operation_id, code, message],
|
||
),
|
||
);
|
||
}
|
||
|
||
async function requireCategory(
|
||
database: Database,
|
||
workspaceId: string,
|
||
storeId: string,
|
||
platformId: string,
|
||
categoryId: string | undefined,
|
||
) {
|
||
if (!categoryId) throw new Error('Catalog sync category is missing');
|
||
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<CategorySource>(
|
||
`SELECT external_category_id, external_type_id
|
||
FROM atlas.store_product_categories
|
||
WHERE id = $1 AND store_id = $2 AND platform_id = $3 AND is_active`,
|
||
[categoryId, storeId, platformId],
|
||
));
|
||
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
||
return result.rows[0];
|
||
}
|
||
|
||
async function requireDictionaryAttribute(
|
||
database: Database,
|
||
workspaceId: string,
|
||
storeId: string,
|
||
platformId: string,
|
||
categoryId: string | undefined,
|
||
dictionaryId: string | undefined,
|
||
) {
|
||
if (!categoryId || !dictionaryId) throw new Error('Catalog dictionary input is missing');
|
||
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<{ platform_attribute_id: string }>(
|
||
`SELECT platform_attribute_id
|
||
FROM atlas.store_product_category_attributes
|
||
WHERE store_id = $1 AND platform_id = $2 AND store_product_category_id = $3 AND dictionary_id = $4
|
||
LIMIT 1`,
|
||
[storeId, platformId, categoryId, dictionaryId],
|
||
));
|
||
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
|
||
return result.rows[0];
|
||
}
|
||
|
||
type FlatCategory = {
|
||
external_category_id: string;
|
||
external_type_id: string;
|
||
parent_key: string | null;
|
||
name: string;
|
||
path: string;
|
||
is_active: boolean;
|
||
source_payload: OzonCategoryNode;
|
||
};
|
||
|
||
async function persistCategoryTree(
|
||
database: Database,
|
||
workspaceId: string,
|
||
storeId: string,
|
||
platformId: string,
|
||
roots: OzonCategoryNode[],
|
||
) {
|
||
// Ozon 的树偶尔会在不同分支重复返回同一个类目;缓存主键是
|
||
// (category_id, type_id),因此保留最后一个完整路径而不让批量 upsert 冲突。
|
||
const records = [
|
||
...new Map(
|
||
flattenCategories(roots).map((record) => [
|
||
categoryKey(record.external_category_id, record.external_type_id),
|
||
record,
|
||
]),
|
||
).values(),
|
||
];
|
||
const syncTime = new Date();
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`WITH incoming AS (
|
||
SELECT * FROM jsonb_to_recordset($3::jsonb) AS value(
|
||
external_category_id text, external_type_id text, parent_key text,
|
||
name text, path text, is_active boolean, source_payload jsonb
|
||
)
|
||
)
|
||
INSERT INTO atlas.store_product_categories
|
||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||
is_active, source_payload, synced_at)
|
||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id, name, path,
|
||
is_active, source_payload, $4::timestamptz
|
||
FROM incoming
|
||
ON CONFLICT (store_id, external_category_id, external_type_id) DO UPDATE
|
||
SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active,
|
||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||
updated_at = now()`,
|
||
[storeId, platformId, JSON.stringify(records), syncTime],
|
||
);
|
||
await tx.query(
|
||
`WITH incoming AS (
|
||
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
|
||
external_category_id text, external_type_id text, parent_key text,
|
||
name text, path text, is_active boolean, source_payload jsonb
|
||
)
|
||
), resolved AS (
|
||
SELECT child.id AS child_id, parent.id AS parent_id
|
||
FROM incoming
|
||
JOIN atlas.store_product_categories child
|
||
ON child.store_id = $1
|
||
AND child.external_category_id = incoming.external_category_id
|
||
AND child.external_type_id = incoming.external_type_id
|
||
LEFT JOIN atlas.store_product_categories parent
|
||
ON parent.store_id = $1
|
||
AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key
|
||
)
|
||
UPDATE atlas.store_product_categories category
|
||
SET parent_id = resolved.parent_id, updated_at = now()
|
||
FROM resolved WHERE category.id = resolved.child_id`,
|
||
[storeId, JSON.stringify(records)],
|
||
);
|
||
await tx.query(
|
||
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||
[storeId, syncTime],
|
||
);
|
||
});
|
||
return {
|
||
category_count: records.length,
|
||
active_category_count: records.filter((record) => record.is_active).length,
|
||
};
|
||
}
|
||
|
||
export function flattenCategories(
|
||
nodes: OzonCategoryNode[],
|
||
path: string[] = [],
|
||
parentKey: string | null = null,
|
||
inheritedCategory?: { id: string; name: string },
|
||
): FlatCategory[] {
|
||
const flattened: FlatCategory[] = [];
|
||
for (const node of nodes) {
|
||
// Ozon's last level may contain only type_name/type_id. It is still a
|
||
// publishable option for the closest description category, so inherit
|
||
// its category ID while retaining the type's own display name and ID.
|
||
const explicitCategoryId = String(node.description_category_id ?? '').trim();
|
||
const explicitCategoryName = node.category_name?.trim() ?? '';
|
||
const categoryId = explicitCategoryId || inheritedCategory?.id || '';
|
||
const categoryName = explicitCategoryName || inheritedCategory?.name || '';
|
||
const typeId = String(node.type_id ?? 0);
|
||
const typeName = node.type_name?.trim() ?? '';
|
||
const name = explicitCategoryName || typeName || categoryName;
|
||
if (!categoryId || !name) continue;
|
||
const nextPath = [...path, name];
|
||
const key = categoryKey(categoryId, typeId);
|
||
flattened.push({
|
||
external_category_id: categoryId,
|
||
external_type_id: typeId,
|
||
parent_key: parentKey,
|
||
name,
|
||
path: nextPath.join(' › '),
|
||
is_active: node.disabled !== true && typeId !== '0',
|
||
source_payload: node,
|
||
});
|
||
const nextInheritedCategory = explicitCategoryId && explicitCategoryName
|
||
? { id: explicitCategoryId, name: explicitCategoryName }
|
||
: inheritedCategory;
|
||
flattened.push(
|
||
...flattenCategories(
|
||
node.children ?? [],
|
||
nextPath,
|
||
key,
|
||
nextInheritedCategory,
|
||
),
|
||
);
|
||
}
|
||
return flattened;
|
||
}
|
||
|
||
function categoryKey(categoryId: string, typeId: string) {
|
||
// Ozon category/type IDs are scalar identifiers; a printable separator is
|
||
// intentional because PostgreSQL text/jsonb forbids embedded NUL bytes.
|
||
return `${categoryId}:${typeId}`;
|
||
}
|
||
|
||
type CachedAttribute = {
|
||
platform_attribute_id: string;
|
||
complex_id: string;
|
||
name: string;
|
||
value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
|
||
is_required: boolean;
|
||
is_collection: boolean;
|
||
max_value_count: number;
|
||
dictionary_id: string | null;
|
||
is_aspect: boolean;
|
||
source_payload: OzonCategoryAttribute;
|
||
};
|
||
|
||
async function persistAttributes(
|
||
database: Database,
|
||
workspaceId: string,
|
||
storeId: string,
|
||
platformId: string,
|
||
categoryId: string,
|
||
attributes: OzonCategoryAttribute[],
|
||
) {
|
||
const records = attributes
|
||
.map(toCachedAttribute)
|
||
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`DELETE FROM atlas.store_product_category_attributes
|
||
WHERE store_id = $1 AND store_product_category_id = $2`,
|
||
[storeId, categoryId],
|
||
);
|
||
if (!records.length) return;
|
||
await tx.query(
|
||
`INSERT INTO atlas.store_product_category_attributes
|
||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, complex_id,
|
||
name, value_type, is_required, is_collection, max_value_count,
|
||
dictionary_id, is_aspect, source_payload, synced_at)
|
||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, platform_attribute_id, complex_id, name, value_type,
|
||
is_required, is_collection, max_value_count, dictionary_id,
|
||
is_aspect, source_payload, now()
|
||
FROM jsonb_to_recordset($4::jsonb) AS value(
|
||
platform_attribute_id text, complex_id text, name text, value_type text,
|
||
is_required boolean, is_collection boolean, max_value_count integer,
|
||
dictionary_id text, is_aspect boolean, source_payload jsonb
|
||
)`,
|
||
[storeId, platformId, categoryId, JSON.stringify(records)],
|
||
);
|
||
});
|
||
return { attribute_count: records.length };
|
||
}
|
||
|
||
function toCachedAttribute(value: OzonCategoryAttribute): CachedAttribute | null {
|
||
const id = String(value.id ?? '').trim();
|
||
const name = value.name?.trim();
|
||
if (!id || !name) return null;
|
||
const dictionaryId = String(value.dictionary_id ?? '').trim();
|
||
const hasDictionary = !!dictionaryId && dictionaryId !== '0';
|
||
const isCollection = value.is_collection === true;
|
||
const requestedMax = Number(value.max_value_count ?? (isCollection ? 100 : 1));
|
||
return {
|
||
platform_attribute_id: id,
|
||
complex_id: String(value.complex_id ?? 0),
|
||
name,
|
||
value_type: hasDictionary ? 'dictionary' : mapAttributeType(value.type),
|
||
is_required: value.is_required === true,
|
||
is_collection: isCollection,
|
||
max_value_count: isCollection
|
||
? Math.max(1, Math.min(100, Number.isFinite(requestedMax) ? requestedMax : 100))
|
||
: 1,
|
||
dictionary_id: hasDictionary ? dictionaryId : null,
|
||
is_aspect: value.is_aspect === true,
|
||
source_payload: value,
|
||
};
|
||
}
|
||
|
||
function mapAttributeType(value: string | null | undefined): CachedAttribute['value_type'] {
|
||
switch (value?.toLowerCase()) {
|
||
case 'integer':
|
||
return 'integer';
|
||
case 'decimal':
|
||
case 'number':
|
||
return 'decimal';
|
||
case 'boolean':
|
||
return 'boolean';
|
||
default:
|
||
return 'text';
|
||
}
|
||
}
|
||
|
||
async function persistDictionaryValues(
|
||
database: Database,
|
||
workspaceId: string,
|
||
storeId: string,
|
||
platformId: string,
|
||
categoryId: string,
|
||
dictionaryId: string,
|
||
values: OzonDictionaryValue[],
|
||
) {
|
||
const records = values
|
||
.map((value) => ({
|
||
external_value_id: String(value.id ?? '').trim(),
|
||
value: value.value?.trim(),
|
||
source_payload: value,
|
||
}))
|
||
.filter((value): value is { external_value_id: string; value: string; source_payload: OzonDictionaryValue } =>
|
||
!!value.external_value_id && !!value.value,
|
||
);
|
||
if (!records.length) return { dictionary_value_count: 0 };
|
||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, external_value_id, value, true, source_payload, now()
|
||
FROM jsonb_to_recordset($5::jsonb) AS value(
|
||
external_value_id text, value text, source_payload jsonb
|
||
)
|
||
ON CONFLICT (store_id, store_product_category_id, dictionary_id, external_value_id) DO UPDATE
|
||
SET value = EXCLUDED.value, is_active = true, source_payload = EXCLUDED.source_payload,
|
||
synced_at = EXCLUDED.synced_at, updated_at = now()`,
|
||
[storeId, platformId, categoryId, dictionaryId, JSON.stringify(records)],
|
||
));
|
||
return { dictionary_value_count: records.length };
|
||
}
|