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;
@@ -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