feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
+1
View File
@@ -5,3 +5,4 @@ export * from './watermarks';
export * from './media';
export * from './product-editor';
export * from './product-ai';
export * from './online-products';
+99 -1
View File
@@ -27,9 +27,10 @@ export const completeMediaUploadIntentRequestSchema = z
.strict();
export const createMediaAccessUrlRequestSchema = z
.object({
purpose: z.literal('preview'),
purpose: z.enum(['preview', 'download']),
})
.strict();
export type CreateMediaAccessUrlRequest = z.infer<typeof createMediaAccessUrlRequestSchema>;
export const importProductImageRequestSchema = z.object({
url: z.url().max(2048).refine((value) => {
try {
@@ -42,6 +43,50 @@ export const importProductImageRequestSchema = z.object({
}, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'),
}).strict();
export type ImportProductImageRequest = z.infer<typeof importProductImageRequestSchema>;
export const createAspectRatioDerivativeRequestSchema = z.object({
preset: z.literal('3:4'),
fit: z.literal('contain'),
product_id: z.uuid().optional(),
}).strict();
export type CreateAspectRatioDerivativeRequest = z.infer<typeof createAspectRatioDerivativeRequestSchema>;
export const createWatermarkDerivativeRequestSchema = z.object({
watermark_template_id: z.uuid(),
product_id: z.uuid().optional(),
}).strict();
export type CreateWatermarkDerivativeRequest = z.infer<typeof createWatermarkDerivativeRequestSchema>;
export const createMediaAiEditRequestSchema = z.object({
product_id: z.uuid(),
reference_asset_ids: z.array(z.uuid()).max(2).default([]),
prompt: z.string().trim().max(2000).default(''),
mode: z.enum(['edit', 'outpaint']).default('edit'),
ratio: z.enum(['3:4', '1:1', '4:5']).default('3:4'),
translate_text: z.boolean().default(false),
target_language: z.enum(['ru', 'ar', 'en']).default('ru'),
background: z.enum(['auto', 'white', 'solid', 'scene', 'reference']).default('auto'),
background_description: z.string().trim().max(500).default(''),
style_set: z.number().int().min(1).max(5).default(2),
style_prompt: z.string().trim().max(2000).default(''),
dimensions: z.object({
length: z.number().positive().max(100000).optional(),
width: z.number().positive().max(100000).optional(),
height: z.number().positive().max(100000).optional(),
unit: z.enum(['cm', 'mm']).default('cm'),
}).strict().optional(),
generate_size_chart: z.boolean().default(false),
}).strict().superRefine((input, ctx) => {
if (new Set(input.reference_asset_ids).size !== input.reference_asset_ids.length)
ctx.addIssue({ code: 'custom', message: '辅助参考图不能重复', path: ['reference_asset_ids'] });
if (input.mode === 'outpaint' && (input.ratio !== '3:4' || input.reference_asset_ids.length || input.generate_size_chart))
ctx.addIssue({ code: 'custom', message: '仅扩图只支持单张主图与 3:4 比例', path: ['mode'] });
if (input.mode === 'edit' && input.background === 'reference' && !input.reference_asset_ids.length)
ctx.addIssue({ code: 'custom', message: '参考背景需要一张辅助图', path: ['reference_asset_ids'] });
if (input.generate_size_chart && !input.dimensions
|| input.generate_size_chart && input.dimensions
&& ![input.dimensions.length, input.dimensions.width, input.dimensions.height].some((value) => value && value > 0))
ctx.addIssue({ code: 'custom', message: '尺寸标注图需要至少填写一个尺寸', path: ['dimensions'] });
});
export type CreateMediaAiEditRequest = z.input<typeof createMediaAiEditRequestSchema>;
export type MediaAiEditSettings = z.output<typeof createMediaAiEditRequestSchema>;
export type CreateMediaUploadIntentRequest = z.infer<
typeof createMediaUploadIntentRequestSchema
@@ -82,6 +127,59 @@ export type ProductUploadImage = MediaAsset & {
preview_url_expires_at: string;
};
export type ProductImportedImage = ProductUploadImage & { source_url: string };
export type AspectRatioDerivative = {
id: string;
status: 'queued' | 'running' | 'succeeded' | 'failed';
source_asset_id: string;
output_asset: ProductUploadImage | null;
error_code: string | null;
};
export type WatermarkDerivative = AspectRatioDerivative & {
watermark_template_id: string;
watermark_template_version: number;
};
/** 商品图片处理记录只返回可重新选用的成功结果。 */
export type ProductImageDerivativeRecord = {
id: string;
kind: 'aspect_ratio_3_4' | 'watermark';
source_asset_id: string;
created_at: string;
output_asset: ProductUploadImage;
};
export type MediaAiEdit = {
id: string;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
source_asset_id: string;
output_asset: ProductUploadImage | null;
error_code: string | null;
failure_source: 'pcore' | 'atlas' | null;
failure_stage: MediaAiEditFailureStage | null;
failure_detail: string | null;
};
export type MediaAiEditFailureStage = 'task_setup' | 'input_preparation' | 'provider_submission'
| 'provider_polling' | 'result_download' | 'result_processing' | 'result_storage';
/** 商品页右下角任务列表的统一图片任务模型;套图任务后续使用同一结构。 */
export type ProductImageTask = {
id: string;
kind: 'single_ai_edit' | 'ai_suite';
status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled';
phase: 'waiting_provider' | null;
total: number;
completed: number;
output_asset_ids: string[];
error_code: string | null;
failure_source: 'pcore' | 'atlas' | null;
failure_stage: MediaAiEditFailureStage | null;
failure_detail: string | null;
provider_error_raw: string | null;
created_at: string;
completed_at: string | null;
};
export const listProductImageTasksQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
export type ListProductImageTasksQuery = z.infer<typeof listProductImageTasksQuerySchema>;
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
export type MediaAccessUrl = {
+104
View File
@@ -0,0 +1,104 @@
import { z } from 'zod';
export const listOnlineProductsQuerySchema = z
.object({
store_id: z.uuid().optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().trim().max(200).optional(),
visibility: z
.enum(['all', 'visible', 'invisible', 'unknown'])
.default('all'),
source: z.enum(['atlas_published', 'external_sync']).optional(),
moderation_status: z.string().trim().max(100).optional(),
include_archived: z
.union([
z.boolean(),
z
.enum(['true', 'false'])
.transform((value) => value === 'true'),
])
.default(false),
})
.strict();
export const onlineProductSyncRequestSchema = z
.object({
reason: z.literal('manual_refresh').optional(),
})
.strict();
export const listOnlineProductSyncRunsQuerySchema = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
.strict();
export type ListOnlineProductsQuery = z.infer<
typeof listOnlineProductsQuerySchema
>;
export type ListOnlineProductSyncRunsQuery = z.infer<
typeof listOnlineProductSyncRunsQuerySchema
>;
export type OnlineProductSource = 'atlas_published' | 'external_sync';
export type OnlineProductSyncStatus =
'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type OnlineProduct = {
id: string;
store_id: string;
platform_id: string;
remote_product_id: string;
offer_id: string;
remote_sku: string | null;
name: string | null;
currency_code: string | null;
selling_price: string | null;
list_price: string | null;
visible: boolean | null;
remote_state: string | null;
moderation_status: string | null;
validation_status: string | null;
stock_summary: { total: number; has_data: boolean };
image: {
url: string | null;
width_px: number | null;
height_px: number | null;
};
errors: Array<{ code?: string; message: string }>;
visibility_details: Record<string, unknown>;
source: OnlineProductSource;
atlas_origin: {
collection_item_id: string;
product_id: string;
variant_id: string;
} | null;
last_synced_at: string;
missing_since: string | null;
archived_at: string | null;
};
export type OnlineProductSyncRun = {
id: string;
store_id: string;
status: OnlineProductSyncStatus;
trigger_source: 'manual' | 'scheduled' | 'recovery';
cursor_started: string | null;
cursor_finished: string | null;
listed_count: number;
upserted_count: number;
failed_count: number;
created_at: string;
started_at: string | null;
completed_at: string | null;
error: { code: string; message: string } | null;
};
export type OnlineProductsMeta = {
next_cursor: string | null;
has_more: boolean;
store: { id: string; name: string };
last_successful_sync_at: string | null;
last_sync_status: OnlineProductSyncStatus | null;
};
+1 -1
View File
@@ -540,7 +540,7 @@ export const saveProductEditorDraftRequestSchema = z
.max(20),
purchase_cost_cny: z
.string()
.regex(/^\d+(\.\d{1,4})?$/)
.regex(/^\d+(\.\d{1,2})?$/)
.nullable(),
title: nullableText(1_000).optional(),
tags: z
+36 -3
View File
@@ -7,6 +7,13 @@ const opacity = z
const scaleRatio = z
.string()
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
const imageSizePx = z.number().int().min(WATERMARK_IMAGE_SIZE_MIN_PX).max(WATERMARK_IMAGE_SIZE_MAX_PX);
const borderRadiusPx = z.number().int().min(0).max(WATERMARK_BORDER_RADIUS_MAX_PX);
const positionCoordinate = z.number().finite().min(0).max(1);
export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>;
export const watermarkPositionSchema = z.enum([
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
@@ -18,13 +25,19 @@ const textConfig = z.object({
font_code: z.string().trim().min(1).max(120),
font_size_px: z.number().int().min(12).max(60),
opacity,
position: watermarkPositionSchema,
position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(),
}).strict();
const imageConfig = z.object({
image_asset_id: uuid,
scale_ratio: scaleRatio,
image_size_px: imageSizePx.optional(),
border_radius_px: borderRadiusPx.optional(),
scale_ratio: scaleRatio.optional(),
opacity,
position: watermarkPositionSchema,
position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(),
}).strict();
export const createWatermarkTemplateRequestSchema = z.object({
@@ -38,6 +51,15 @@ export const createWatermarkTemplateRequestSchema = z.object({
if (value.kind === 'image' && !('image_asset_id' in value.config)) {
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' });
}
if (value.kind === 'image' && 'image_asset_id' in value.config
&& value.config.image_size_px === undefined && value.config.scale_ratio === undefined) {
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要提供像素尺寸' });
}
const hasX = value.config.position_x !== undefined;
const hasY = value.config.position_y !== undefined;
if (hasX !== hasY || (!hasX && !value.config.position)) {
context.addIssue({ code: 'custom', path: ['config'], message: '需要同时提供水平和垂直坐标,或提供旧版九宫格位置' });
}
});
const partialConfig = z.object({
@@ -47,6 +69,10 @@ const partialConfig = z.object({
font_size_px: z.number().int().min(12).max(60).optional(),
opacity: opacity.optional(),
scale_ratio: scaleRatio.optional(),
image_size_px: imageSizePx.optional(),
border_radius_px: borderRadiusPx.optional(),
position_x: positionCoordinate.optional(),
position_y: positionCoordinate.optional(),
position: watermarkPositionSchema.optional(),
}).strict();
@@ -94,6 +120,12 @@ export type WatermarkTemplateConfig = {
image_asset?: WatermarkTemplateImageAsset;
opacity?: string;
scale_ratio?: string;
image_size_px?: number;
border_radius_px?: number;
/** 0 为左/上边缘,1 为右/下边缘,按水印可移动范围归一化。 */
position_x: number;
position_y: number;
/** 兼容旧版九宫格客户端的近似位置;精确渲染以坐标为准。 */
position: WatermarkPosition;
};
@@ -102,6 +134,7 @@ export type WatermarkTemplate = {
id: string;
name: string;
kind: 'text' | 'image';
is_default: boolean;
current_version: number;
config: WatermarkTemplateConfig;
created_at: string;