feat(ai-image): AI生图开发
This commit is contained in:
@@ -5,3 +5,4 @@ export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
export * from './online-products';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 派生任务独立保存源图和结果,避免编辑草稿确认前修改原图或其他 SKU 的选图。
|
||||
CREATE TABLE media_aspect_ratio_derivatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_asset_id UUID NOT NULL,
|
||||
output_asset_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
error_code TEXT,
|
||||
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, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
|
||||
CHECK (status <> 'failed' OR error_code IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX ix_media_aspect_ratio_derivatives_source
|
||||
ON media_aspect_ratio_derivatives (workspace_id, source_asset_id, created_at DESC);
|
||||
|
||||
ALTER TABLE media_aspect_ratio_derivatives ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_aspect_ratio_derivatives FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON media_aspect_ratio_derivatives
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,44 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 精确坐标按水印在预览区域内的可移动范围归一化;旧九宫格数据保留原位置。
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD COLUMN IF NOT EXISTS position_x NUMERIC(5,4),
|
||||
ADD COLUMN IF NOT EXISTS position_y NUMERIC(5,4);
|
||||
|
||||
-- 迁移角色受强制 RLS 限制,回填期间临时关闭并在同一事务内恢复。
|
||||
ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
UPDATE watermark_template_versions
|
||||
SET position_x = CASE
|
||||
WHEN position IN ('top_left', 'middle_left', 'bottom_left') THEN 0
|
||||
WHEN position IN ('top_center', 'center', 'bottom_center') THEN 0.5
|
||||
ELSE 1
|
||||
END,
|
||||
position_y = CASE
|
||||
WHEN position IN ('top_left', 'top_center', 'top_right') THEN 0
|
||||
WHEN position IN ('middle_left', 'center', 'middle_right') THEN 0.5
|
||||
ELSE 1
|
||||
END
|
||||
WHERE position_x IS NULL OR position_y IS NULL;
|
||||
|
||||
ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
ALTER TABLE watermark_template_versions
|
||||
ALTER COLUMN position_x SET NOT NULL,
|
||||
ALTER COLUMN position_y SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
|
||||
AND conname = 'watermark_template_versions_position_coordinates_check'
|
||||
) THEN
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD CONSTRAINT watermark_template_versions_position_coordinates_check
|
||||
CHECK (position_x BETWEEN 0 AND 1 AND position_y BETWEEN 0 AND 1);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,48 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD COLUMN IF NOT EXISTS image_size_px SMALLINT,
|
||||
ADD COLUMN IF NOT EXISTS border_radius_px SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- 旧图片模板的比例按原 240px 预览框中的显示宽度换算,保留可编辑的像素尺寸。
|
||||
ALTER TABLE watermark_template_versions NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE watermark_template_versions DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
UPDATE watermark_template_versions
|
||||
SET image_size_px = LEAST(200, GREATEST(20, ROUND(scale_ratio * 144)::integer))
|
||||
WHERE kind = 'image' AND image_size_px IS NULL;
|
||||
|
||||
ALTER TABLE watermark_template_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE watermark_template_versions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
|
||||
AND conname = 'watermark_template_versions_image_size_check'
|
||||
) THEN
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD CONSTRAINT watermark_template_versions_image_size_check
|
||||
CHECK (image_size_px IS NULL OR image_size_px BETWEEN 20 AND 200);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
|
||||
AND conname = 'watermark_template_versions_image_size_required_check'
|
||||
) THEN
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD CONSTRAINT watermark_template_versions_image_size_required_check
|
||||
CHECK (kind <> 'image' OR image_size_px IS NOT NULL);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid = 'atlas.watermark_template_versions'::regclass
|
||||
AND conname = 'watermark_template_versions_border_radius_check'
|
||||
) THEN
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD CONSTRAINT watermark_template_versions_border_radius_check
|
||||
CHECK (border_radius_px BETWEEN 0 AND 100);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,32 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 水印派生引用不可变模板版本;编辑草稿确认前不改写原图或 SKU 选图。
|
||||
CREATE TABLE media_watermark_derivatives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_asset_id UUID NOT NULL,
|
||||
watermark_template_id UUID NOT NULL,
|
||||
watermark_template_version INTEGER NOT NULL CHECK (watermark_template_version > 0),
|
||||
output_asset_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
error_code TEXT,
|
||||
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, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, watermark_template_id, watermark_template_version)
|
||||
REFERENCES watermark_template_versions(workspace_id, template_id, version) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
|
||||
CHECK (status <> 'failed' OR error_code IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX ix_media_watermark_derivatives_source
|
||||
ON media_watermark_derivatives (workspace_id, source_asset_id, created_at DESC);
|
||||
|
||||
ALTER TABLE media_watermark_derivatives ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_watermark_derivatives FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON media_watermark_derivatives
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,9 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 图片水印最小最长边从 20px 调整为 10px;已有模板数据保持不变。
|
||||
ALTER TABLE watermark_template_versions
|
||||
DROP CONSTRAINT IF EXISTS watermark_template_versions_image_size_check;
|
||||
|
||||
ALTER TABLE watermark_template_versions
|
||||
ADD CONSTRAINT watermark_template_versions_image_size_check
|
||||
CHECK (image_size_px IS NULL OR image_size_px BETWEEN 10 AND 200);
|
||||
@@ -0,0 +1,8 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
ALTER TABLE watermark_templates
|
||||
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_watermark_templates_workspace_default
|
||||
ON watermark_templates (workspace_id)
|
||||
WHERE is_default AND status = 'active' AND deleted_at IS NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 单张 AI 编辑保存素材 ID 和编辑参数;供应商任务 ID 只在工作空间内可见。
|
||||
CREATE TABLE media_ai_edits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_asset_id UUID NOT NULL,
|
||||
reference_asset_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
settings JSONB NOT NULL,
|
||||
provider_task_id TEXT,
|
||||
output_asset_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
||||
error_code TEXT,
|
||||
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, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK (cardinality(reference_asset_ids) <= 2),
|
||||
CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)),
|
||||
CHECK (status <> 'failed' OR error_code IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX ix_media_ai_edits_source ON media_ai_edits (workspace_id, source_asset_id, created_at DESC);
|
||||
|
||||
ALTER TABLE media_ai_edits ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE media_ai_edits FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON media_ai_edits
|
||||
USING (workspace_id = app_current_workspace_id())
|
||||
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,10 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- AI 生图记录按商品归档;旧任务若没有关联商品,不出现在商品素材库中。
|
||||
ALTER TABLE media_ai_edits
|
||||
ADD COLUMN product_id UUID,
|
||||
ADD CONSTRAINT media_ai_edits_product_fk
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT;
|
||||
CREATE INDEX ix_media_ai_edits_product
|
||||
ON media_ai_edits (workspace_id, product_id, completed_at DESC)
|
||||
WHERE status = 'succeeded';
|
||||
@@ -0,0 +1,89 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync');
|
||||
CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
|
||||
CREATE TABLE platform_product_sync_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
status platform_sync_status NOT NULL DEFAULT 'queued',
|
||||
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')),
|
||||
cursor_started TEXT,
|
||||
cursor_finished TEXT,
|
||||
listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0),
|
||||
upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0),
|
||||
failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0),
|
||||
error_payload JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC, id DESC);
|
||||
CREATE UNIQUE INDEX ux_platform_product_sync_runs_active ON platform_product_sync_runs (workspace_id, store_id) WHERE status IN ('queued', 'running');
|
||||
|
||||
CREATE TABLE platform_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
store_id UUID NOT NULL,
|
||||
platform_id UUID NOT NULL,
|
||||
remote_product_id TEXT NOT NULL,
|
||||
offer_id TEXT NOT NULL,
|
||||
remote_sku TEXT,
|
||||
name TEXT,
|
||||
currency_code CHAR(3) REFERENCES currencies(code),
|
||||
selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0),
|
||||
list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0),
|
||||
visible BOOLEAN,
|
||||
remote_state TEXT,
|
||||
moderation_status TEXT,
|
||||
validation_status TEXT,
|
||||
stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
image_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source platform_listing_source NOT NULL DEFAULT 'external_sync',
|
||||
atlas_variant_listing_id UUID,
|
||||
last_sync_run_id UUID,
|
||||
last_synced_at TIMESTAMPTZ NOT NULL,
|
||||
missing_since TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
raw_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, atlas_variant_listing_id) REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, remote_product_id),
|
||||
UNIQUE (workspace_id, store_id, platform_id, offer_id)
|
||||
);
|
||||
CREATE INDEX ix_platform_listings_store_list ON platform_listings (workspace_id, store_id, updated_at DESC, id DESC);
|
||||
CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC);
|
||||
CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE platform_listing_sync_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
platform_listing_id UUID NOT NULL,
|
||||
sync_run_id UUID NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (workspace_id, platform_listing_id, sync_run_id)
|
||||
);
|
||||
|
||||
-- 商品镜像、同步运行和响应快照均强制租户隔离,Worker 也必须在租户事务中访问。
|
||||
DO $$
|
||||
DECLARE table_name TEXT;
|
||||
BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY['platform_product_sync_runs', 'platform_listings', 'platform_listing_sync_snapshots'] LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,18 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 派生记录绑定商品,供选图弹窗找回未加入 SKU 草稿的处理结果。
|
||||
ALTER TABLE media_aspect_ratio_derivatives
|
||||
ADD COLUMN product_id UUID,
|
||||
ADD CONSTRAINT media_aspect_ratio_derivatives_product_fk
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT;
|
||||
CREATE INDEX ix_media_aspect_ratio_derivatives_product
|
||||
ON media_aspect_ratio_derivatives (workspace_id, product_id, completed_at DESC)
|
||||
WHERE status = 'succeeded';
|
||||
|
||||
ALTER TABLE media_watermark_derivatives
|
||||
ADD COLUMN product_id UUID,
|
||||
ADD CONSTRAINT media_watermark_derivatives_product_fk
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT;
|
||||
CREATE INDEX ix_media_watermark_derivatives_product
|
||||
ON media_watermark_derivatives (workspace_id, product_id, completed_at DESC)
|
||||
WHERE status = 'succeeded';
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user