feat(category): 类目树、类目属性、属性字典维护系统构建完成

This commit is contained in:
R524809
2026-09-20 15:11:32 +08:00
parent 388429dbd0
commit 880e441bc6
48 changed files with 5127 additions and 1159 deletions
@@ -11,20 +11,20 @@ export class PlatformCatalogAdminReads {
const rows = await this.database.query<{
id: string; code: string; name: string; status: string;
client_id: string | null; source_updated_at: Date | null;
category_count: string; last_tree_sync_at: Date | null; last_error: string | null;
category_count: string; last_tree_sync_at: Date | null;
next_tree_sync_at: Date | null; last_error: string | null;
}>(
`SELECT p.id, p.code, p.name, p.status,
source.client_id, source.updated_at AS source_updated_at,
(SELECT count(*) FROM atlas.platform_product_categories category
WHERE category.platform_id = p.id AND category.is_active)::text AS category_count,
(SELECT max(completed_at) FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = p.id AND run.scope = 'tree' AND run.status = 'succeeded') AS last_tree_sync_at,
(SELECT CASE WHEN run.status = 'failed' THEN run.error_message ELSE NULL END
FROM atlas.platform_catalog_sync_runs run
WHERE run.platform_id = p.id AND run.scope = 'tree'
ORDER BY run.created_at DESC LIMIT 1) AS last_error
tree_state.last_success_at AS last_tree_sync_at,
tree_state.last_success_at + interval '7 days' AS next_tree_sync_at,
tree_state.last_error_message AS last_error
FROM atlas.platforms p
LEFT JOIN atlas.platform_catalog_sources source ON source.platform_id = p.id
LEFT JOIN atlas.platform_catalog_resource_states tree_state
ON tree_state.platform_id = p.id AND tree_state.scope_key = 'tree'
ORDER BY CASE WHEN upper(p.code) = 'OZON' THEN 0 ELSE 1 END, p.name`,
);
return rows.rows.map((row) => ({
@@ -40,6 +40,7 @@ export class PlatformCatalogAdminReads {
summary: {
category_count: Number(row.category_count),
last_tree_sync_at: row.last_tree_sync_at,
next_tree_sync_at: row.next_tree_sync_at,
last_error: row.last_error,
},
}));
@@ -48,15 +49,111 @@ export class PlatformCatalogAdminReads {
public async categories(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT id, parent_id, external_category_id, external_type_id, name, path,
is_active, synced_at
FROM atlas.platform_product_categories
WHERE platform_id = $1 ORDER BY path, id LIMIT 20000`,
`SELECT category.id, category.parent_id, category.external_category_id,
category.external_type_id, category.name, category.path,
category.is_active, category.synced_at,
state.last_success_at AS attribute_last_success_at,
state.last_error_message AS attribute_last_error,
state.last_error_at AS attribute_last_error_at,
CASE
WHEN category.external_type_id IS NULL OR category.external_type_id = '0'
THEN NULL
WHEN state.last_success_at IS NULL THEN 'missing'
WHEN state.last_success_at > now() -
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
THEN 'fresh'
WHEN state.last_success_at >= now() -
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS attribute_cache_status
FROM atlas.platform_product_categories category
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = category.platform_id
AND state.scope_key = 'attributes:' || category.id
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = category.platform_id
WHERE category.platform_id = $1 ORDER BY path, id LIMIT 20000`,
[platformId],
);
return result.rows;
}
public async refreshPolicy(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query<{
attribute_fresh_days: number; attribute_expire_days: number;
dictionary_fresh_days: number; dictionary_expire_days: number;
updated_at: Date | null;
}>(
`SELECT COALESCE(policy.attribute_fresh_days, 14)::int AS attribute_fresh_days,
COALESCE(policy.attribute_expire_days, 21)::int AS attribute_expire_days,
COALESCE(policy.dictionary_fresh_days, 14)::int AS dictionary_fresh_days,
COALESCE(policy.dictionary_expire_days, 21)::int AS dictionary_expire_days,
policy.updated_at
FROM atlas.platforms platform
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = platform.id
WHERE platform.id = $1`,
[platformId],
);
return result.rows[0];
}
public async attributeSyncSummary(platformId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query<{
total: string; fresh: string; stale: string; expired: string; missing: string;
active_batch_id: string | null; evaluated_at: Date;
}>(
`WITH policy AS (
SELECT COALESCE(refresh.attribute_fresh_days, 14)::int AS fresh_days,
COALESCE(refresh.attribute_expire_days, 21)::int AS expire_days
FROM atlas.platforms platform
LEFT JOIN atlas.platform_catalog_refresh_policies refresh
ON refresh.platform_id = platform.id
WHERE platform.id = $1
), resources AS (
SELECT state.last_success_at, policy.fresh_days, policy.expire_days
FROM atlas.platform_product_categories category
CROSS JOIN policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = category.platform_id
AND state.scope_key = 'attributes:' || category.id
WHERE category.platform_id = $1 AND category.is_active
AND category.external_type_id IS NOT NULL AND category.external_type_id <> '0'
)
SELECT count(*)::text AS total,
count(*) FILTER (
WHERE last_success_at > now() - make_interval(days => fresh_days)
)::text AS fresh,
count(*) FILTER (
WHERE last_success_at <= now() - make_interval(days => fresh_days)
AND last_success_at >= now() - make_interval(days => expire_days)
)::text AS stale,
count(*) FILTER (
WHERE last_success_at < now() - make_interval(days => expire_days)
)::text AS expired,
count(*) FILTER (WHERE last_success_at IS NULL)::text AS missing,
(SELECT id FROM atlas.platform_catalog_attribute_batches batch
WHERE batch.platform_id = $1 AND batch.status IN ('queued', 'running')
LIMIT 1) AS active_batch_id,
now() AS evaluated_at
FROM resources`,
[platformId],
);
const row = result.rows[0];
return {
total: Number(row?.total ?? 0),
fresh: Number(row?.fresh ?? 0),
stale: Number(row?.stale ?? 0),
expired: Number(row?.expired ?? 0),
missing: Number(row?.missing ?? 0),
active_batch_id: row?.active_batch_id ?? null,
evaluated_at: row?.evaluated_at ?? new Date(),
};
}
public async attributes(platformId: string, categoryId: string) {
await this.requireCategory(platformId, categoryId);
const result = await this.database.query(
@@ -65,9 +162,45 @@ export class PlatformCatalogAdminReads {
attribute.value_type, attribute.is_required, attribute.is_aspect,
attribute.is_collection, attribute.max_value_count, attribute.dictionary_id,
attribute.category_dependent, attribute.is_active, attribute.synced_at,
schema_state.last_success_at,
CASE
WHEN schema_state.last_success_at IS NULL THEN 'missing'
WHEN schema_state.last_success_at > now() -
make_interval(days => COALESCE(policy.attribute_fresh_days, 14))
THEN 'fresh'
WHEN schema_state.last_success_at >= now() -
make_interval(days => COALESCE(policy.attribute_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS cache_status,
schema_state.last_error_message,
schema_state.last_error_at,
dictionary_state.last_success_at AS dictionary_last_success_at,
CASE
WHEN attribute.dictionary_id IS NULL THEN NULL
WHEN dictionary_state.last_success_at IS NULL THEN 'missing'
WHEN dictionary_state.last_success_at > now() -
make_interval(days => COALESCE(policy.dictionary_fresh_days, 14))
THEN 'fresh'
WHEN dictionary_state.last_success_at >= now() -
make_interval(days => COALESCE(policy.dictionary_expire_days, 21))
THEN 'stale'
ELSE 'expired'
END AS dictionary_cache_status,
dictionary_state.is_complete AS dictionary_is_complete,
dictionary_state.last_error_message AS dictionary_last_error_message,
dictionary_state.last_error_at AS dictionary_last_error_at,
(SELECT count(*)::int FROM atlas.platform_catalog_dictionary_values value
WHERE value.attribute_id = attribute.id AND value.is_active) AS dictionary_value_count
FROM atlas.platform_product_category_attributes attribute
LEFT JOIN atlas.platform_catalog_resource_states schema_state
ON schema_state.platform_id = attribute.platform_id
AND schema_state.scope_key = 'attributes:' || attribute.platform_product_category_id
LEFT JOIN atlas.platform_catalog_resource_states dictionary_state
ON dictionary_state.platform_id = attribute.platform_id
AND dictionary_state.scope_key = 'dictionary:' || attribute.id
LEFT JOIN atlas.platform_catalog_refresh_policies policy
ON policy.platform_id = attribute.platform_id
WHERE attribute.platform_id = $1 AND attribute.platform_product_category_id = $2
ORDER BY attribute.is_required DESC, attribute.group_name NULLS LAST,
attribute.name, attribute.platform_attribute_id`,
@@ -108,10 +241,8 @@ export class PlatformCatalogAdminReads {
[attributeId, q],
),
this.database.query<{ is_complete: boolean }>(
`SELECT COALESCE((result->>'is_complete')::boolean, false) AS is_complete
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $2 AND status = 'succeeded'
ORDER BY completed_at DESC LIMIT 1`,
`SELECT is_complete FROM atlas.platform_catalog_resource_states
WHERE platform_id = $1 AND scope_key = $2`,
[platformId, `dictionary:${attributeId}`],
),
]);
@@ -130,7 +261,7 @@ export class PlatformCatalogAdminReads {
await this.requirePlatform(platformId);
const result = await this.database.query(
`SELECT id, platform_id, scope, scope_key, category_id, attribute_id,
trigger_kind AS trigger, status, result, error_code, error_message,
trigger_kind AS trigger, priority, status, result, error_code, error_message,
attempt_count, created_at, started_at, completed_at
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 ORDER BY created_at DESC LIMIT $2`,
@@ -139,6 +270,32 @@ export class PlatformCatalogAdminReads {
return result.rows;
}
public async attributeBatches(platformId: string, limit: number) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`${batchProgressSelect}
WHERE batch.platform_id = $1
GROUP BY batch.id
ORDER BY batch.created_at DESC LIMIT $2`,
[platformId, limit],
);
return result.rows;
}
public async attributeBatch(platformId: string, batchId: string) {
await this.requirePlatform(platformId);
const result = await this.database.query(
`${batchProgressSelect}
WHERE batch.platform_id = $1 AND batch.id = $2
GROUP BY batch.id`,
[platformId, batchId],
);
if (!result.rows[0]) {
throw new ApiError(404, 'CATALOG_ATTRIBUTE_BATCH_NOT_FOUND', '类型属性同步批次不存在');
}
return result.rows[0];
}
public async requirePlatform(platformId: string) {
const result = await this.database.query<{ code: string }>(
'SELECT code FROM atlas.platforms WHERE id = $1',
@@ -156,3 +313,16 @@ export class PlatformCatalogAdminReads {
if (!result.rows[0]) throw new ApiError(404, 'CATEGORY_NOT_FOUND', '类目不属于当前平台');
}
}
const batchProgressSelect = `
SELECT batch.id, batch.platform_id, batch.mode, batch.status, batch.total_count,
count(*) FILTER (WHERE item.run_id IS NULL OR run.status = 'queued')::int
AS pending_count,
count(*) FILTER (WHERE run.status = 'running')::int AS running_count,
count(*) FILTER (WHERE run.status = 'succeeded')::int AS succeeded_count,
count(*) FILTER (WHERE run.status = 'failed')::int AS failed_count,
count(*) FILTER (WHERE run.status = 'cancelled')::int AS cancelled_count,
batch.created_at, batch.started_at, batch.completed_at
FROM atlas.platform_catalog_attribute_batches batch
JOIN atlas.platform_catalog_attribute_batch_items item ON item.batch_id = batch.id
LEFT JOIN atlas.platform_catalog_sync_runs run ON run.id = item.run_id`;