feat(ai-edit): 开发 AI 填报功能

This commit is contained in:
R524809
2026-09-23 17:18:09 +08:00
parent 95a2a37f1b
commit b3958166dd
35 changed files with 2832 additions and 278 deletions
+1
View File
@@ -4,3 +4,4 @@ export * from './stores';
export * from './watermarks';
export * from './media';
export * from './product-editor';
export * from './product-ai';
+116
View File
@@ -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: '对照字段类型不正确' });
}
});
+12
View File
@@ -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)
);