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
+69 -4
View File
@@ -56,6 +56,8 @@ export type ProductEditorCategoryOption = {
};
export type ProductAttributeSchema = {
/** Atlas 平台属性定义 UUID,用于精确限定同一类型下的字典作用域。 */
attribute_id: string;
platform_attribute_id: string;
complex_id: string;
name: string;
@@ -75,11 +77,46 @@ export type ProductAttributeSchema = {
| null;
};
export type ProductCatalogCacheStatus =
'fresh' | 'stale' | 'expired' | 'missing';
export type ProductCatalogCacheMetadata = {
status: ProductCatalogCacheStatus;
last_success_at: string | null;
fresh_until: string | null;
expires_at: string | null;
is_complete: boolean;
refresh_run_id: string | null;
refresh_status: string | null;
retry_after_ms: number | null;
};
export type ProductCatalogRefreshRun = {
id: string;
platform_id: string;
scope: string;
status: string;
trigger_kind: string;
priority: 'high' | 'low';
created_at: string;
};
export type ProductAttributeSchemaResult = {
items: ProductAttributeSchema[];
cache: ProductCatalogCacheMetadata;
};
export type ProductAttributeDictionaryValue = {
value_id: string;
value: string;
};
export type ProductAttributeDictionaryResult = {
items: ProductAttributeDictionaryValue[];
next_cursor: string | null;
cache: ProductCatalogCacheMetadata;
};
export type ProductEditorValidationIssue = {
code: string;
field: string;
@@ -102,10 +139,22 @@ export type ProductEditorValidation = {
}>;
};
/**
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
* publishing DTOs intentionally stay out of this first editor slice.
*/
export type ProductPublishPreflight = {
status: 'ready' | 'blocked' | 'refreshing';
validation: ProductEditorValidation;
catalog: {
schema: ProductCatalogCacheMetadata;
dictionaries: Array<{
attribute_id: string;
platform_attribute_id: string;
name: string;
cache: ProductCatalogCacheMetadata;
}>;
};
refresh_runs: ProductCatalogRefreshRun[];
};
/** 商品编辑入口与完整读模型;真正的发布 job DTO 仍在后续纵向任务中定义。 */
export type ProductEditorContext = {
collection_item_id: string;
product_id: string;
@@ -369,6 +418,20 @@ export const productAiSourceMaterialOverridesSchema =
.partial()
.strict();
export const productPublishPreflightRequestSchema = z
.object({
variant_ids: z
.array(uuid)
.min(1)
.max(100)
.refine((ids) => new Set(ids).size === ids.length, 'SKU 不能重复'),
})
.strict();
export type ProductPublishPreflightRequest = z.infer<
typeof productPublishPreflightRequestSchema
>;
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
export const saveProductEditorDraftRequestSchema = z
.object({
@@ -399,6 +462,8 @@ export const saveProductEditorDraftRequestSchema = z
title: nullableText(1_000),
description: nullableText(100_000),
tags: z.array(z.string().trim().min(1).max(100)).max(30),
platform_product_category_id: uuid.nullable().optional(),
/** 旧客户端过渡字段;服务端会按外部 ID 映射到平台类目。 */
store_product_category_id: uuid.nullable().optional(),
category_source: z
.enum(['source_capture', 'ai_suggestion', 'manual'])
@@ -0,0 +1,135 @@
SET search_path TO atlas, public;
-- 平台目录刷新阈值独立于门店和工作空间;修改策略不主动创建同步任务。
CREATE TABLE platform_catalog_refresh_policies (
platform_id UUID PRIMARY KEY REFERENCES platforms(id) ON DELETE CASCADE,
attribute_fresh_days INTEGER NOT NULL DEFAULT 14 CHECK (attribute_fresh_days BETWEEN 1 AND 365),
attribute_expire_days INTEGER NOT NULL DEFAULT 21 CHECK (attribute_expire_days BETWEEN 1 AND 365),
dictionary_fresh_days INTEGER NOT NULL DEFAULT 14 CHECK (dictionary_fresh_days BETWEEN 1 AND 365),
dictionary_expire_days INTEGER NOT NULL DEFAULT 21 CHECK (dictionary_expire_days BETWEEN 1 AND 365),
updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (attribute_expire_days > attribute_fresh_days),
CHECK (dictionary_expire_days > dictionary_fresh_days)
);
INSERT INTO platform_catalog_refresh_policies (platform_id)
SELECT id FROM platforms
ON CONFLICT (platform_id) DO NOTHING;
-- 成功检查时间属于资源状态,不能从数据行 updated_at 推断。
CREATE TABLE platform_catalog_resource_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
scope TEXT NOT NULL CHECK (scope IN ('tree', 'attributes', 'dictionary', 'dictionary_search')),
scope_key TEXT NOT NULL,
category_id UUID,
attribute_id UUID,
search_key TEXT,
last_success_at TIMESTAMPTZ,
last_success_run_id UUID REFERENCES platform_catalog_sync_runs(id) ON DELETE SET NULL,
is_complete BOOLEAN NOT NULL DEFAULT false,
last_error_code TEXT,
last_error_message TEXT,
last_error_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (platform_id, scope_key),
FOREIGN KEY (platform_id, category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE,
FOREIGN KEY (attribute_id, platform_id, category_id)
REFERENCES platform_product_category_attributes(id, platform_id, platform_product_category_id)
ON DELETE CASCADE,
CHECK (
(scope = 'tree' AND category_id IS NULL AND attribute_id IS NULL AND search_key IS NULL)
OR (scope = 'attributes' AND category_id IS NOT NULL AND attribute_id IS NULL AND search_key IS NULL)
OR (scope = 'dictionary' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NULL)
OR (scope = 'dictionary_search' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NOT NULL)
)
);
CREATE INDEX ix_platform_catalog_resource_states_status
ON platform_catalog_resource_states (platform_id, scope, last_success_at);
-- 保留旧 scheduled 触发值用于历史任务;新任务细分每周、批次和交互来源。
ALTER TABLE platform_catalog_sync_runs
DROP CONSTRAINT platform_catalog_sync_runs_trigger_kind_check;
ALTER TABLE platform_catalog_sync_runs
ADD CONSTRAINT platform_catalog_sync_runs_trigger_kind_check
CHECK (trigger_kind IN (
'manual', 'scheduled', 'weekly', 'source_configured',
'admin_batch', 'user_access', 'publish'
));
ALTER TABLE platform_catalog_sync_runs
ADD COLUMN priority TEXT NOT NULL DEFAULT 'low'
CHECK (priority IN ('high', 'low')),
ADD COLUMN promoted_at TIMESTAMPTZ;
-- 管理员批次固定启动瞬间的类型集合,后续树变化不改变既有批次范围。
CREATE TABLE platform_catalog_attribute_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('missing_expired', 'missing_stale_expired', 'all')),
status operation_status NOT NULL DEFAULT 'queued',
source_revision INTEGER NOT NULL CHECK (source_revision > 0),
attribute_fresh_days INTEGER NOT NULL CHECK (attribute_fresh_days BETWEEN 1 AND 365),
attribute_expire_days INTEGER NOT NULL CHECK (attribute_expire_days BETWEEN 1 AND 365),
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
idempotency_key UUID NOT NULL,
total_count INTEGER NOT NULL CHECK (total_count > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (platform_id, id),
UNIQUE (requested_by_user_id, idempotency_key),
CHECK (attribute_expire_days > attribute_fresh_days)
);
CREATE UNIQUE INDEX uq_platform_catalog_active_attribute_batch
ON platform_catalog_attribute_batches (platform_id)
WHERE status IN ('queued', 'running');
CREATE INDEX ix_platform_catalog_attribute_batches_recent
ON platform_catalog_attribute_batches (platform_id, created_at DESC);
CREATE TABLE platform_catalog_attribute_batch_items (
batch_id UUID NOT NULL,
platform_id UUID NOT NULL,
category_id UUID NOT NULL,
run_id UUID REFERENCES platform_catalog_sync_runs(id) ON DELETE RESTRICT,
linked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (batch_id, category_id),
FOREIGN KEY (platform_id, batch_id)
REFERENCES platform_catalog_attribute_batches(platform_id, id) ON DELETE CASCADE,
FOREIGN KEY (platform_id, category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
CHECK ((run_id IS NULL) = (linked_at IS NULL))
);
CREATE INDEX ix_platform_catalog_attribute_batch_items_pending
ON platform_catalog_attribute_batch_items (batch_id, created_at, category_id)
WHERE run_id IS NULL;
CREATE INDEX ix_platform_catalog_attribute_batch_items_run
ON platform_catalog_attribute_batch_items (run_id)
WHERE run_id IS NOT NULL;
-- 现有成功任务建立初始状态,避免部署后把已缓存资源误判为 missing。
WITH latest AS (
SELECT DISTINCT ON (run.platform_id, run.scope_key)
run.platform_id, run.scope, run.scope_key, run.category_id, run.attribute_id,
run.id AS run_id, run.completed_at, run.result
FROM platform_catalog_sync_runs run
WHERE run.status = 'succeeded' AND run.completed_at IS NOT NULL
ORDER BY run.platform_id, run.scope_key, run.completed_at DESC, run.id DESC
)
INSERT INTO platform_catalog_resource_states
(platform_id, scope, scope_key, category_id, attribute_id,
last_success_at, last_success_run_id, is_complete)
SELECT platform_id, scope, scope_key, category_id, attribute_id,
completed_at, run_id,
CASE WHEN scope IN ('tree', 'attributes') THEN true
ELSE COALESCE((result->>'is_complete')::boolean, false) END
FROM latest
ON CONFLICT (platform_id, scope_key) DO UPDATE
SET last_success_at = EXCLUDED.last_success_at,
last_success_run_id = EXCLUDED.last_success_run_id,
is_complete = EXCLUDED.is_complete,
updated_at = now();
@@ -0,0 +1,14 @@
SET search_path TO atlas, public;
-- 阈值调整会改变缓存可用性判断,保留不可变的前后值用于系统级审计。
CREATE TABLE platform_catalog_refresh_policy_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE,
changed_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
previous_policy JSONB NOT NULL,
next_policy JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_platform_catalog_refresh_policy_revisions_recent
ON platform_catalog_refresh_policy_revisions (platform_id, created_at DESC);
@@ -0,0 +1,46 @@
SET search_path TO atlas, public;
-- 商品编辑器恢复使用平台级类目引用;门店级列暂时保留用于旧客户端兼容和回滚。
UPDATE product_platform_profiles profile
SET platform_product_category_id = platform_category.id,
platform_type_id = platform_category.external_type_id,
updated_at = now()
FROM store_product_categories store_category
JOIN platform_product_categories platform_category
ON platform_category.platform_id = store_category.platform_id
AND platform_category.external_category_id = store_category.external_category_id
AND platform_category.external_type_id IS NOT DISTINCT FROM store_category.external_type_id
WHERE profile.store_product_category_id = store_category.id
AND profile.platform_id = platform_category.platform_id
AND profile.platform_product_category_id IS NULL;
UPDATE product_unmapped_attributes unmapped
SET original_platform_category_id = platform_category.id
FROM store_product_categories store_category
JOIN platform_product_categories platform_category
ON platform_category.platform_id = store_category.platform_id
AND platform_category.external_category_id = store_category.external_category_id
AND platform_category.external_type_id IS NOT DISTINCT FROM store_category.external_type_id
WHERE unmapped.original_store_product_category_id = store_category.id
AND unmapped.platform_id = platform_category.platform_id
AND unmapped.original_platform_category_id IS NULL;
-- 部分大字典的关键词补齐是独立缓存作用域,不能刷新整个字典的新鲜时间。
ALTER TABLE platform_catalog_sync_runs
ADD COLUMN search_key TEXT;
ALTER TABLE platform_catalog_sync_runs
DROP CONSTRAINT platform_catalog_sync_runs_scope_check,
DROP CONSTRAINT platform_catalog_sync_runs_check;
ALTER TABLE platform_catalog_sync_runs
ADD CONSTRAINT platform_catalog_sync_runs_scope_check
CHECK (scope IN ('tree', 'attributes', 'dictionary', 'dictionary_search')),
ADD CONSTRAINT platform_catalog_sync_runs_target_check CHECK (
(scope = 'tree' AND category_id IS NULL AND attribute_id IS NULL AND search_key IS NULL)
OR (scope = 'attributes' AND category_id IS NOT NULL AND attribute_id IS NULL AND search_key IS NULL)
OR (scope = 'dictionary' AND category_id IS NOT NULL AND attribute_id IS NOT NULL AND search_key IS NULL)
OR (scope = 'dictionary_search' AND category_id IS NOT NULL AND attribute_id IS NOT NULL
AND length(trim(search_key)) >= 2)
);