feat(edit): 编辑页类目树逻辑开发

This commit is contained in:
2026-09-10 22:26:52 +08:00
parent 946af649dd
commit 05a00d46b9
13 changed files with 628 additions and 323 deletions
+79 -54
View File
@@ -38,11 +38,7 @@ type CategorySource = {
external_type_id: string | null;
};
/**
* Keeps platform metadata global while every credential and operation lookup
* remains inside the requesting workspace. Neither queue payloads nor cache
* rows contain the decrypted API key.
*/
/** 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);
@@ -63,11 +59,22 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
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.payload.platform_id, nodes);
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,
);
@@ -88,6 +95,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
);
result = await persistAttributes(
database,
job.workspaceId,
job.payload.store_id,
job.payload.platform_id,
job.payload.category_id ?? '',
attributes,
@@ -95,6 +104,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
} else {
const dictionary = await requireDictionaryAttribute(
database,
job.workspaceId,
job.payload.store_id,
job.payload.platform_id,
job.payload.category_id,
job.payload.dictionary_id,
@@ -111,7 +122,10 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
);
result = await persistDictionaryValues(
database,
job.workspaceId,
job.payload.store_id,
job.payload.platform_id,
job.payload.category_id ?? '',
job.payload.dictionary_id ?? '',
values,
);
@@ -174,9 +188,9 @@ async function loadSource(database: Database, job: CatalogSyncJob) {
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 (
'platform_category_tree_sync',
'platform_category_attribute_sync',
'platform_attribute_dictionary_sync'
'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
@@ -237,34 +251,38 @@ async function failOperation(
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.query<CategorySource>(
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<CategorySource>(
`SELECT external_category_id, external_type_id
FROM atlas.platform_product_categories
WHERE id = $1 AND platform_id = $2 AND is_active`,
[categoryId, platformId],
);
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.query<{ platform_attribute_id: string }>(
const result = await database.workspaceTransaction(workspaceId, (tx) => tx.query<{ platform_attribute_id: string }>(
`SELECT platform_attribute_id
FROM atlas.platform_product_category_attributes
WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3
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`,
[platformId, categoryId, dictionaryId],
);
[storeId, platformId, categoryId, dictionaryId],
));
if (!result.rows[0]) throw new Error('Catalog dictionary is no longer valid for this category');
return result.rows[0];
}
@@ -281,6 +299,8 @@ type FlatCategory = {
async function persistCategoryTree(
database: Database,
workspaceId: string,
storeId: string,
platformId: string,
roots: OzonCategoryNode[],
) {
@@ -295,25 +315,25 @@ async function persistCategoryTree(
).values(),
];
const syncTime = new Date();
await database.transaction(async (tx) => {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`WITH incoming AS (
SELECT * FROM jsonb_to_recordset($2::jsonb) AS value(
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.platform_product_categories
(platform_id, external_category_id, external_type_id, name, path,
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 $1, external_category_id, external_type_id, name, path,
is_active, source_payload, $3::timestamptz
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 (platform_id, external_category_id, external_type_id) DO UPDATE
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()`,
[platformId, JSON.stringify(records), syncTime],
[storeId, platformId, JSON.stringify(records), syncTime],
);
await tx.query(
`WITH incoming AS (
@@ -324,23 +344,23 @@ async function persistCategoryTree(
), resolved AS (
SELECT child.id AS child_id, parent.id AS parent_id
FROM incoming
JOIN atlas.platform_product_categories child
ON child.platform_id = $1
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.platform_product_categories parent
ON parent.platform_id = $1
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.platform_product_categories category
UPDATE atlas.store_product_categories category
SET parent_id = resolved.parent_id, updated_at = now()
FROM resolved WHERE category.id = resolved.child_id`,
[platformId, JSON.stringify(records)],
[storeId, JSON.stringify(records)],
);
await tx.query(
`UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now()
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
[platformId, syncTime],
`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 {
@@ -396,6 +416,8 @@ type CachedAttribute = {
async function persistAttributes(
database: Database,
workspaceId: string,
storeId: string,
platformId: string,
categoryId: string,
attributes: OzonCategoryAttribute[],
@@ -403,27 +425,27 @@ async function persistAttributes(
const records = attributes
.map(toCachedAttribute)
.filter((attribute): attribute is CachedAttribute => attribute !== null);
await database.transaction(async (tx) => {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`DELETE FROM atlas.platform_product_category_attributes
WHERE platform_id = $1 AND platform_product_category_id = $2`,
[platformId, categoryId],
`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.platform_product_category_attributes
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
`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 $1, $2, platform_attribute_id, complex_id, name, value_type,
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($3::jsonb) AS value(
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
)`,
[platformId, categoryId, JSON.stringify(records)],
[storeId, platformId, categoryId, JSON.stringify(records)],
);
});
return { attribute_count: records.length };
@@ -469,7 +491,10 @@ function mapAttributeType(value: string | null | undefined): CachedAttribute['va
async function persistDictionaryValues(
database: Database,
workspaceId: string,
storeId: string,
platformId: string,
categoryId: string,
dictionaryId: string,
values: OzonDictionaryValue[],
) {
@@ -483,17 +508,17 @@ async function persistDictionaryValues(
!!value.external_value_id && !!value.value,
);
if (!records.length) return { dictionary_value_count: 0 };
await database.query(
`INSERT INTO atlas.platform_attribute_dictionary_values
(platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
SELECT $1, $2, external_value_id, value, true, source_payload, now()
FROM jsonb_to_recordset($3::jsonb) AS value(
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 (platform_id, dictionary_id, external_value_id) DO UPDATE
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()`,
[platformId, dictionaryId, JSON.stringify(records)],
);
[storeId, platformId, categoryId, dictionaryId, JSON.stringify(records)],
));
return { dictionary_value_count: records.length };
}