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
@@ -0,0 +1,504 @@
import type {
ProductAttributeSchema,
ProductCatalogCacheMetadata,
ProductCatalogRefreshRun,
ProductEditorValidation,
ProductPublishPreflight,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import {
enqueuePlatformCatalogRun,
type PlatformCatalogRunRow,
} from '../platform-catalog/platform-catalog-run.js';
import {
productCatalogCacheMetadata as metadata,
productCatalogResourceStatus as statusOf,
productCatalogStateWithRun as withRun,
type ProductCatalogResourceState as ResourceState,
} from './product-editor.catalog-cache.js';
import type { ProfileRow } from './product-editor.models.js';
type PublishPreflightAccess = {
requireProfileForPlatform: (
transaction: DatabaseTransaction,
profileId: string,
platformId: string,
) => Promise<ProfileRow>;
requireActiveCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<unknown>;
attributeSchemaForCategory: (
transaction: DatabaseTransaction,
platformId: string,
categoryId: string,
) => Promise<ProductAttributeSchema[]>;
validation: (
workspaceId: string,
profileId: string,
platformId: string,
variantIds: string[],
) => Promise<ProductEditorValidation>;
};
type PreparedCatalog = {
schema: ProductCatalogCacheMetadata;
dictionaries: ProductPublishPreflight['catalog']['dictionaries'];
runs: PlatformCatalogRunRow[];
blockingRunIds: string[];
hasBlockingResources: boolean;
};
type UsedDictionary = {
attribute_id: string;
platform_attribute_id: string;
name: string;
};
/**
* 首次发布预检只刷新当前商品实际依赖的平台目录资源。
* API 只创建平台任务并观察 Worker 结果,绝不在请求进程直接调用 Ozon。
*/
export class ProductEditorPublishPreflightOperations {
public constructor(
private readonly database: Database,
private readonly access: PublishPreflightAccess,
) {}
async preflight(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
): Promise<{ status: number; body: { data: ProductPublishPreflight } }> {
const deadline = Date.now() + 5_000;
const observedRuns = new Map<string, PlatformCatalogRunRow>();
let prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
true,
);
rememberRuns(observedRuns, prepared.runs);
// 第一轮通常等待 schema;成功后第二轮才知道本商品实际引用哪些字典。
for (
let pass = 0;
pass < 2 && prepared.blockingRunIds.length;
pass += 1
) {
const succeeded = await this.waitForRuns(
prepared.blockingRunIds,
deadline,
);
prepared = await this.prepare(
workspaceId,
userId,
profileId,
platformId,
variantIds,
succeeded && Date.now() < deadline,
);
rememberRuns(observedRuns, prepared.runs);
if (!succeeded) break;
}
const validation = await this.access.validation(
workspaceId,
profileId,
platformId,
variantIds,
);
const currentRuns = await this.currentRuns([...observedRuns.keys()]);
const refreshing = prepared.hasBlockingResources;
const result: ProductPublishPreflight = {
status: refreshing
? 'refreshing'
: validation.ready
? 'ready'
: 'blocked',
validation,
catalog: {
schema: prepared.schema,
dictionaries: prepared.dictionaries,
},
refresh_runs: currentRuns.map(toRefreshRun),
};
return {
status: refreshing ? 202 : 200,
body: { data: result },
};
}
private async prepare(
workspaceId: string,
userId: string,
profileId: string,
platformId: string,
variantIds: string[],
enqueue: boolean,
): Promise<PreparedCatalog> {
return this.database.workspaceTransaction(
workspaceId,
async (transaction) => {
const profile = await this.access.requireProfileForPlatform(
transaction,
profileId,
platformId,
);
if (!profile.platform_product_category_id)
throw new ApiError(
422,
'CATEGORY_REQUIRED',
'首次上架前请先选择产品类目',
);
await this.access.requireActiveCategory(
transaction,
platformId,
profile.platform_product_category_id,
);
await this.requirePublishableVariants(
transaction,
profile,
variantIds,
);
let sourceRevision: number | null = null;
const runs: PlatformCatalogRunRow[] = [];
const blockingRunIds: string[] = [];
const enqueueResource = async (input: {
scope: 'attributes' | 'dictionary';
scopeKey: string;
categoryId: string;
attributeId?: string;
priority: 'high' | 'low';
}) => {
if (sourceRevision === null) {
const source = await transaction.query<{
revision: number;
}>(
`SELECT revision FROM atlas.platform_catalog_sources
WHERE platform_id = $1 FOR UPDATE`,
[platformId],
);
if (!source.rows[0])
throw new ApiError(
422,
'CATALOG_SOURCE_REQUIRED',
'平台目录同步来源尚未配置',
);
sourceRevision = source.rows[0].revision;
}
const run = await enqueuePlatformCatalogRun(transaction, {
platformId,
scope: input.scope,
scopeKey: input.scopeKey,
categoryId: input.categoryId,
attributeId: input.attributeId,
sourceRevision,
trigger: 'publish',
priority: input.priority,
userId,
});
runs.push(run);
return run;
};
const categoryId = profile.platform_product_category_id;
let schemaState = await this.resourceState(
transaction,
platformId,
'attributes',
`attributes:${categoryId}`,
);
const schemaStatus = statusOf(schemaState);
if (enqueue && schemaStatus !== 'fresh') {
const run = await enqueueResource({
scope: 'attributes',
scopeKey: `attributes:${categoryId}`,
categoryId,
priority: schemaStatus === 'stale' ? 'low' : 'high',
});
schemaState = withRun(schemaState, run);
if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
)
blockingRunIds.push(run.id);
} else if (
schemaStatus === 'expired' ||
schemaStatus === 'missing'
) {
if (schemaState.refresh_run_id)
blockingRunIds.push(schemaState.refresh_run_id);
}
const schemaUsable =
schemaStatus === 'fresh' || schemaStatus === 'stale';
const dictionaries: ProductPublishPreflight['catalog']['dictionaries'] =
[];
if (schemaUsable) {
const schema = await this.access.attributeSchemaForCategory(
transaction,
platformId,
categoryId,
);
const used = await this.usedDictionaries(
transaction,
profileId,
variantIds,
schema,
);
for (const dictionary of used) {
let state = await this.resourceState(
transaction,
platformId,
'dictionary',
`dictionary:${dictionary.attribute_id}`,
);
const status = statusOf(state);
if (enqueue && status !== 'fresh') {
const run = await enqueueResource({
scope: 'dictionary',
scopeKey: `dictionary:${dictionary.attribute_id}`,
categoryId,
attributeId: dictionary.attribute_id,
priority: status === 'stale' ? 'low' : 'high',
});
state = withRun(state, run);
if (status === 'expired' || status === 'missing')
blockingRunIds.push(run.id);
} else if (
status === 'expired' ||
status === 'missing'
) {
if (state.refresh_run_id)
blockingRunIds.push(state.refresh_run_id);
}
dictionaries.push({
...dictionary,
cache: metadata(state, status),
});
}
}
return {
schema: metadata(schemaState, schemaStatus),
dictionaries,
runs,
blockingRunIds: [...new Set(blockingRunIds)],
hasBlockingResources:
!schemaUsable ||
dictionaries.some(
(item) =>
item.cache.status === 'expired' ||
item.cache.status === 'missing',
),
};
},
);
}
/** 发布入口只能选择当前 profile 下尚未首次导入的活动 SKU。 */
private async requirePublishableVariants(
transaction: DatabaseTransaction,
profile: ProfileRow,
variantIds: string[],
) {
const result = await transaction.query<{ id: string; status: string }>(
`SELECT variant.id, listing.status
FROM atlas.product_variants variant
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = variant.id
AND listing.profile_id = $1
WHERE variant.product_id = $2 AND variant.deleted_at IS NULL
AND variant.id = ANY($3::uuid[])`,
[profile.id, profile.product_id, variantIds],
);
if (result.rows.length !== new Set(variantIds).size)
throw new ApiError(
422,
'PUBLISH_VARIANTS_INVALID',
'待发布 SKU 不属于当前商品,或已经被删除',
);
if (result.rows.some((item) => item.status === 'submitting'))
throw new ApiError(
409,
'SKU_IMPORT_IN_PROGRESS',
'所选 SKU 正在首次上架,请等待当前任务完成',
);
if (result.rows.some((item) => item.status === 'imported'))
throw new ApiError(
422,
'SKU_ALREADY_IMPORTED',
'所选 SKU 已经首次导入,不能再次上架',
);
if (
result.rows.some(
(item) => !['not_listed', 'failed'].includes(item.status),
)
)
throw new ApiError(
422,
'SKU_NOT_PUBLISHABLE',
'所选 SKU 当前状态不允许首次上架',
);
}
/** 从已确认草稿中提取本次 SKU 集合真正使用的字典属性。 */
private async usedDictionaries(
transaction: DatabaseTransaction,
profileId: string,
variantIds: string[],
schema: ProductAttributeSchema[],
): Promise<UsedDictionary[]> {
const definitions = new Map(
schema
.filter(
(item) =>
item.value_type === 'dictionary' &&
item.dictionary_id &&
!item.editor_field,
)
.map((item) => [
`${item.platform_attribute_id}\u0000${item.complex_id}`,
item,
]),
);
const attributes = await transaction.query<{
platform_attribute_id: string;
complex_id: string;
values: unknown;
}>(
`SELECT platform_attribute_id, complex_id, values
FROM atlas.product_attributes
WHERE profile_id = $1 AND confirmation_status = 'confirmed'
AND (scope = 'common' OR product_variant_id = ANY($2::uuid[]))`,
[profileId, variantIds],
);
const used = new Map<string, UsedDictionary>();
for (const attribute of attributes.rows) {
if (!hasStringValue(attribute.values)) continue;
const definition = definitions.get(
`${attribute.platform_attribute_id}\u0000${attribute.complex_id}`,
);
if (!definition || used.has(definition.attribute_id)) continue;
used.set(definition.attribute_id, {
attribute_id: definition.attribute_id,
platform_attribute_id: definition.platform_attribute_id,
name: definition.name,
});
}
return [...used.values()];
}
private async resourceState(
transaction: DatabaseTransaction,
platformId: string,
scope: 'attributes' | 'dictionary',
scopeKey: string,
): Promise<ResourceState> {
const result = await transaction.query<ResourceState>(
`SELECT state.last_success_at,
coalesce(state.is_complete, false) AS is_complete,
CASE WHEN $2 = 'attributes' THEN policy.attribute_fresh_days
ELSE policy.dictionary_fresh_days END AS fresh_days,
CASE WHEN $2 = 'attributes' THEN policy.attribute_expire_days
ELSE policy.dictionary_expire_days END AS expire_days,
run.id AS refresh_run_id, run.status AS refresh_status
FROM atlas.platform_catalog_refresh_policies policy
LEFT JOIN atlas.platform_catalog_resource_states state
ON state.platform_id = policy.platform_id AND state.scope_key = $3
LEFT JOIN LATERAL (
SELECT id, status
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1 AND scope_key = $3
AND status IN ('queued', 'running')
ORDER BY created_at DESC LIMIT 1
) run ON true
WHERE policy.platform_id = $1`,
[platformId, scope, scopeKey],
);
if (!result.rows[0])
throw new ApiError(
422,
'CATALOG_POLICY_REQUIRED',
'平台目录刷新策略尚未初始化',
);
return result.rows[0];
}
/** 多个高优先级资源共用同一个五秒窗口,避免字典数量线性放大请求时长。 */
private async waitForRuns(runIds: string[], deadline: number) {
while (Date.now() < deadline) {
const result = await this.database.query<{
id: string;
status: string;
}>(
`SELECT id, status FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])`,
[runIds],
);
const statusById = new Map(
result.rows.map((item) => [item.id, item.status]),
);
const statuses = runIds.map(
(id) => statusById.get(id) ?? 'cancelled',
);
if (statuses.every((status) => status === 'succeeded')) return true;
if (
statuses.some((status) =>
['failed', 'cancelled'].includes(status),
)
)
return false;
await new Promise<void>((resolve) => setTimeout(resolve, 250));
}
return false;
}
/** 等待结束后重新读取任务状态,避免响应继续展示排队时的旧快照。 */
private async currentRuns(runIds: string[]) {
if (!runIds.length) return [];
const result = await this.database.query<PlatformCatalogRunRow>(
`SELECT id, platform_id, scope, scope_key, status, trigger_kind,
priority, created_at
FROM atlas.platform_catalog_sync_runs
WHERE id = ANY($1::uuid[])
ORDER BY created_at, id`,
[runIds],
);
return result.rows;
}
}
function rememberRuns(
target: Map<string, PlatformCatalogRunRow>,
runs: PlatformCatalogRunRow[],
) {
for (const run of runs) target.set(run.id, run);
}
function toRefreshRun(run: PlatformCatalogRunRow): ProductCatalogRefreshRun {
return {
id: run.id,
platform_id: run.platform_id,
scope: run.scope,
status: run.status,
trigger_kind: run.trigger_kind,
priority: run.priority,
created_at: new Date(run.created_at).toISOString(),
};
}
function hasStringValue(value: unknown) {
return (
Array.isArray(value) &&
value.some((item) => typeof item === 'string' && item.trim())
);
}