feat(category): 类目树、类目属性、属性字典维护系统构建完成
This commit is contained in:
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user