feat(ai-edit): 开发 AI 填报功能
This commit is contained in:
@@ -4,3 +4,4 @@ export * from './stores';
|
||||
export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ProductCopyField = 'title' | 'tags' | 'description';
|
||||
export type ProductCopyTranslation = {
|
||||
variant_id: string | null;
|
||||
field: ProductCopyField;
|
||||
russian_value: string | string[];
|
||||
chinese_value: string | string[];
|
||||
source_task_id: string | null;
|
||||
};
|
||||
|
||||
export type AiCopyOutput = {
|
||||
title?: { ru: string; zh: string };
|
||||
tags?: Array<{ ru: string; zh: string }>;
|
||||
description?: { ru: string; zh: string };
|
||||
};
|
||||
|
||||
export type ProductAiAutofillResult = {
|
||||
category?: { category_id: string; path: string };
|
||||
category_candidates?: Array<{ category_id: string; path: string }>;
|
||||
model?: string;
|
||||
offers?: Array<{ variant_id: string; offer_id: string }>;
|
||||
package_spec?: {
|
||||
length_mm: string;
|
||||
width_mm: string;
|
||||
height_mm: string;
|
||||
shipping_weight_g: string;
|
||||
evidence: string;
|
||||
};
|
||||
spu_copy?: AiCopyOutput;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ProductAiSkuCopyResult = {
|
||||
variant_id: string;
|
||||
copy: AiCopyOutput;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅兼容已完成的历史属性填报任务;应用不再创建或执行此类任务。
|
||||
*/
|
||||
export type ProductAiAttributeSuggestion = {
|
||||
scope: 'common' | 'variant';
|
||||
variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: string[];
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export type ProductAiAttributeFillResult = {
|
||||
attributes: ProductAiAttributeSuggestion[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ProductAiTask = {
|
||||
id: string;
|
||||
/** `attribute_fill` 仅用于读取历史任务记录。 */
|
||||
kind: 'autofill' | 'sku_copy' | 'attribute_fill';
|
||||
status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed';
|
||||
input_revision: {
|
||||
product: number;
|
||||
profile: number;
|
||||
source_snapshot: number;
|
||||
};
|
||||
variant_id: string | null;
|
||||
result:
|
||||
| ProductAiAutofillResult
|
||||
| ProductAiSkuCopyResult
|
||||
| ProductAiAttributeFillResult
|
||||
| null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
export const requestProductAiTaskSchema = z
|
||||
.object({
|
||||
hint: z.string().trim().max(1000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const productCopyTranslationSchema = z
|
||||
.object({
|
||||
variant_id: z.uuid().nullable(),
|
||||
field: z.enum(['title', 'tags', 'description']),
|
||||
russian_value: z.union([
|
||||
z.string().max(100_000),
|
||||
z.array(z.string().max(100)).max(30),
|
||||
]),
|
||||
chinese_value: z.union([
|
||||
z.string().max(100_000),
|
||||
z.array(z.string().max(100)).max(30),
|
||||
]),
|
||||
source_task_id: z.uuid().nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
typeof value.russian_value !== typeof value.chinese_value ||
|
||||
Array.isArray(value.russian_value) !==
|
||||
Array.isArray(value.chinese_value)
|
||||
) {
|
||||
context.addIssue({ code: 'custom', message: '中俄对照结构不一致' });
|
||||
}
|
||||
if (
|
||||
Array.isArray(value.russian_value) &&
|
||||
Array.isArray(value.chinese_value) &&
|
||||
value.russian_value.length !== value.chinese_value.length
|
||||
) {
|
||||
context.addIssue({ code: 'custom', message: '中俄标签数量不一致' });
|
||||
}
|
||||
if ((value.field === 'tags') !== Array.isArray(value.russian_value)) {
|
||||
context.addIssue({ code: 'custom', message: '对照字段类型不正确' });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productCopyTranslationSchema,
|
||||
type ProductCopyTranslation,
|
||||
} from './product-ai';
|
||||
|
||||
export type ProductAiSourceAttribute = {
|
||||
key: string;
|
||||
@@ -227,6 +231,8 @@ export type ProductEditorSnapshot = {
|
||||
display_name: string | null;
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
/** SKU 发布标题覆盖;空值继承当前平台 SPU 标题。 */
|
||||
title: string | null;
|
||||
/** 当前平台下该 SKU 对 SPU 主题标签的覆盖;空数组表示继承 SPU。 */
|
||||
tags: string[];
|
||||
/** 当前平台下该 SKU 对 SPU 产品描述的覆盖;null 表示继承 SPU。 */
|
||||
@@ -282,6 +288,7 @@ export type ProductEditorSnapshot = {
|
||||
source_material_state: ProductAiSourceMaterialState;
|
||||
captured_category_match: CapturedCategoryMatch;
|
||||
ai_input: ProductAiInput;
|
||||
copy_translations: ProductCopyTranslation[];
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
@@ -510,6 +517,10 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
})
|
||||
.strict(),
|
||||
attributes: z.array(productAttributeDraft).max(1_000).optional(),
|
||||
copy_translations: z
|
||||
.array(productCopyTranslationSchema)
|
||||
.max(1_000)
|
||||
.optional(),
|
||||
variants: z
|
||||
.array(
|
||||
z
|
||||
@@ -531,6 +542,7 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,4})?$/)
|
||||
.nullable(),
|
||||
title: nullableText(1_000).optional(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(100))
|
||||
.max(30),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- SKU 发布标题为空时继承当前平台 SPU 标题;内部展示名不是发布标题。
|
||||
ALTER TABLE product_variant_platform_listings ADD COLUMN title TEXT;
|
||||
ALTER TABLE product_variants ADD CONSTRAINT uq_product_variants_workspace_id_product
|
||||
UNIQUE (workspace_id, id, product_id);
|
||||
|
||||
-- 模型任务只保存服务端固化的资料快照;Outbox 仅携带任务 ID。
|
||||
CREATE TABLE product_ai_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
variant_id UUID,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('autofill', 'sku_copy')),
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued', 'running', 'succeeded', 'partial', 'failed')),
|
||||
input_snapshot JSONB NOT NULL,
|
||||
result JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
model_name TEXT,
|
||||
prompt_version TEXT NOT NULL DEFAULT 'v1',
|
||||
token_usage JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, profile_id, product_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_id, product_id)
|
||||
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE RESTRICT,
|
||||
CHECK ((kind = 'autofill' AND variant_id IS NULL)
|
||||
OR (kind = 'sku_copy' AND variant_id IS NOT NULL)),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_product_ai_tasks_profile_recent
|
||||
ON product_ai_tasks (workspace_id, profile_id, created_at DESC);
|
||||
|
||||
-- 中文只作编辑对照;发布适配器只读取俄文草稿。
|
||||
CREATE TABLE product_copy_translations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
product_id UUID NOT NULL,
|
||||
profile_id UUID NOT NULL,
|
||||
variant_id UUID,
|
||||
field TEXT NOT NULL CHECK (field IN ('title', 'tags', 'description')),
|
||||
russian_value JSONB NOT NULL,
|
||||
chinese_value JSONB NOT NULL,
|
||||
source_task_id UUID,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, profile_id, product_id)
|
||||
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, variant_id, product_id)
|
||||
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_task_id)
|
||||
REFERENCES product_ai_tasks(workspace_id, id) ON DELETE SET NULL (source_task_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_product_copy_translations_spu
|
||||
ON product_copy_translations (workspace_id, profile_id, field)
|
||||
WHERE variant_id IS NULL;
|
||||
CREATE UNIQUE INDEX ux_product_copy_translations_sku
|
||||
ON product_copy_translations (workspace_id, profile_id, variant_id, field)
|
||||
WHERE variant_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE product_ai_tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_ai_tasks FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_ai_tasks
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
ALTER TABLE product_copy_translations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_copy_translations FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_copy_translations
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,16 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 类目属性 AI 任务与 SPU/SKU 文案任务共用不可变输入快照和 Outbox,
|
||||
-- 但它只能在已确认的 SPU 类目下运行,因此不关联单个 SKU。
|
||||
ALTER TABLE product_ai_tasks
|
||||
DROP CONSTRAINT product_ai_tasks_kind_check,
|
||||
DROP CONSTRAINT product_ai_tasks_check;
|
||||
|
||||
ALTER TABLE product_ai_tasks
|
||||
ADD CONSTRAINT product_ai_tasks_kind_check
|
||||
CHECK (kind IN ('autofill', 'sku_copy', 'attribute_fill')),
|
||||
ADD CONSTRAINT product_ai_tasks_check
|
||||
CHECK (
|
||||
(kind = 'sku_copy' AND variant_id IS NOT NULL)
|
||||
OR (kind IN ('autofill', 'attribute_fill') AND variant_id IS NULL)
|
||||
);
|
||||
Reference in New Issue
Block a user