329 lines
16 KiB
TypeScript
329 lines
16 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import type { Database } from '@atlas/database';
|
|
import { ApiError } from '../common/api-error.js';
|
|
import { DATABASE } from '../database/database.module.js';
|
|
|
|
@Injectable()
|
|
export class PlatformCatalogAdminReads {
|
|
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
|
|
|
public async platforms() {
|
|
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;
|
|
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,
|
|
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) => ({
|
|
id: row.id,
|
|
code: row.code,
|
|
name: row.name,
|
|
status: row.status,
|
|
source: {
|
|
configured: row.client_id !== null,
|
|
client_id_hint: row.client_id ? `••••${row.client_id.slice(-4)}` : null,
|
|
updated_at: row.source_updated_at,
|
|
},
|
|
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,
|
|
},
|
|
}));
|
|
}
|
|
|
|
public async categories(platformId: string) {
|
|
await this.requirePlatform(platformId);
|
|
const result = await this.database.query(
|
|
`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(
|
|
`SELECT attribute.id, attribute.platform_attribute_id, attribute.complex_id,
|
|
attribute.name, attribute.description, attribute.group_id, attribute.group_name,
|
|
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`,
|
|
[platformId, categoryId],
|
|
);
|
|
return result.rows;
|
|
}
|
|
|
|
public async values(
|
|
platformId: string,
|
|
attributeId: string,
|
|
keyword: string | undefined,
|
|
page: number,
|
|
) {
|
|
const attribute = await this.database.query<{ dictionary_id: string | null }>(
|
|
`SELECT dictionary_id FROM atlas.platform_product_category_attributes
|
|
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
|
[attributeId, platformId],
|
|
);
|
|
if (!attribute.rows[0]) throw new ApiError(404, 'ATTRIBUTE_NOT_FOUND', '属性不存在');
|
|
if (!attribute.rows[0].dictionary_id) {
|
|
throw new ApiError(422, 'ATTRIBUTE_DICTIONARY_UNAVAILABLE', '该属性没有字典候选值');
|
|
}
|
|
const q = keyword?.trim() ?? '';
|
|
const [items, total, lastRun] = await Promise.all([
|
|
this.database.query(
|
|
`SELECT id, external_value_id, value, info, picture
|
|
FROM atlas.platform_catalog_dictionary_values
|
|
WHERE attribute_id = $1 AND is_active
|
|
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)
|
|
ORDER BY value, external_value_id LIMIT 100 OFFSET $3`,
|
|
[attributeId, q, (page - 1) * 100],
|
|
),
|
|
this.database.query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM atlas.platform_catalog_dictionary_values
|
|
WHERE attribute_id = $1 AND is_active
|
|
AND ($2 = '' OR value ILIKE '%' || $2 || '%' OR external_value_id = $2)`,
|
|
[attributeId, q],
|
|
),
|
|
this.database.query<{ is_complete: boolean }>(
|
|
`SELECT is_complete FROM atlas.platform_catalog_resource_states
|
|
WHERE platform_id = $1 AND scope_key = $2`,
|
|
[platformId, `dictionary:${attributeId}`],
|
|
),
|
|
]);
|
|
return {
|
|
data: items.rows,
|
|
meta: {
|
|
page,
|
|
page_size: 100,
|
|
total: Number(total.rows[0]?.count ?? 0),
|
|
is_complete: lastRun.rows[0]?.is_complete ?? false,
|
|
},
|
|
};
|
|
}
|
|
|
|
public async runs(platformId: string, limit: number) {
|
|
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, 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`,
|
|
[platformId, limit],
|
|
);
|
|
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',
|
|
[platformId],
|
|
);
|
|
if (!result.rows[0]) throw new ApiError(404, 'PLATFORM_NOT_FOUND', '平台不存在');
|
|
return result.rows[0];
|
|
}
|
|
|
|
public async requireCategory(platformId: string, categoryId: string) {
|
|
const result = await this.database.query(
|
|
`SELECT 1 FROM atlas.platform_product_categories WHERE id = $1 AND platform_id = $2`,
|
|
[categoryId, platformId],
|
|
);
|
|
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`;
|