feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { z } from 'zod';
|
||||
import type { MediaAiEdit } from './media';
|
||||
import type { Operation } from './stores';
|
||||
|
||||
export const imageSuitePlanErrorMessages: Record<string, string> = {
|
||||
DEEPSEEK_NOT_CONFIGURED:
|
||||
'DeepSeek 未配置,请检查 Worker 的 API Key 和模型名称',
|
||||
DEEPSEEK_REJECTED: 'DeepSeek 拒绝请求,请检查 API Key、模型名称和账户状态',
|
||||
DEEPSEEK_RETRYABLE: 'DeepSeek 当前繁忙或限流,请稍后重新规划',
|
||||
DEEPSEEK_UNAVAILABLE: '暂时无法连接 DeepSeek,请稍后重新规划',
|
||||
DEEPSEEK_EMPTY_RESULT: 'DeepSeek 未返回方案内容,请稍后重新规划',
|
||||
DEEPSEEK_OUTPUT_TRUNCATED: 'DeepSeek 方案过长,请精简资料后重新规划',
|
||||
DEEPSEEK_INVALID_JSON: 'DeepSeek 返回格式异常,请重新规划',
|
||||
AI_SUITE_INVALID_PLAN:
|
||||
'AI 方案未通过张数、主题或资料校验,请调整资料后重新规划',
|
||||
AI_SUITE_PLAN_SCHEMA_INVALID: 'AI 方案字段不符合要求,请重新规划',
|
||||
AI_SUITE_PLAN_COUNT_MISMATCH:
|
||||
'AI 返回的图片张数与本次预算不一致,请重新规划',
|
||||
AI_SUITE_PLAN_MAIN_IMAGE_INVALID: 'AI 方案第一张不是白底主图,请重新规划',
|
||||
AI_SUITE_PLAN_DUPLICATE_THEME: 'AI 方案出现重复主题,请重新规划',
|
||||
AI_SUITE_PLAN_DIMENSIONS_UNVERIFIED:
|
||||
'AI 安排了尺寸图,但缺少带单位的实物尺寸',
|
||||
AI_SUITE_PLAN_SAVE_FAILED: 'AI 规划结果保存失败,请稍后重新规划',
|
||||
AI_SUITE_PLAN_FAILED: '智能规划失败,请重试或继续使用当前方案',
|
||||
};
|
||||
|
||||
export const imageSuiteCategories = [
|
||||
'white',
|
||||
'scene',
|
||||
'selling',
|
||||
'other',
|
||||
] as const;
|
||||
export const imageSuiteTypes = [
|
||||
'white_background',
|
||||
'lifestyle',
|
||||
'selling_point',
|
||||
'detail',
|
||||
'material',
|
||||
'feature',
|
||||
'dimensions',
|
||||
'comparison',
|
||||
'accessories',
|
||||
'usage',
|
||||
'custom',
|
||||
] as const;
|
||||
export const imageSuiteShotSchema = z
|
||||
.object({
|
||||
id: z.uuid(),
|
||||
category: z.enum(imageSuiteCategories),
|
||||
type: z.enum(imageSuiteTypes),
|
||||
theme: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().min(1).max(1000),
|
||||
evidence: z.string().trim().max(500).default(''),
|
||||
reference_asset_ids: z.array(z.uuid()).min(1).max(3).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((shot, ctx) => {
|
||||
const categories = {
|
||||
white_background: 'white',
|
||||
lifestyle: 'scene',
|
||||
selling_point: 'selling',
|
||||
detail: 'selling',
|
||||
material: 'selling',
|
||||
feature: 'selling',
|
||||
dimensions: 'other',
|
||||
comparison: 'other',
|
||||
accessories: 'other',
|
||||
usage: 'other',
|
||||
custom: 'other',
|
||||
};
|
||||
if (categories[shot.type] !== shot.category)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['type'],
|
||||
message: '图片类型与分类不一致',
|
||||
});
|
||||
if (
|
||||
shot.reference_asset_ids &&
|
||||
new Set(shot.reference_asset_ids).size !==
|
||||
shot.reference_asset_ids.length
|
||||
)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['reference_asset_ids'],
|
||||
message: '参考图片不能重复',
|
||||
});
|
||||
});
|
||||
export const imageSuiteInputSchema = z
|
||||
.object({
|
||||
variant_id: z.uuid(),
|
||||
reference_asset_ids: z.array(z.uuid()).min(1).max(8),
|
||||
product_name: z.string().trim().min(1).max(1000),
|
||||
color: z.string().trim().max(500).default(''),
|
||||
dimensions: z.string().trim().max(500).default(''),
|
||||
weight: z.string().trim().max(500).default(''),
|
||||
collected_info: z.string().trim().max(10000).default(''),
|
||||
selling_points: z.string().trim().max(5000).default(''),
|
||||
ratio: z.enum(['3:4', '1:1', '4:3']).default('3:4'),
|
||||
language: z.enum(['ru', 'zh', 'en', 'ar']).default('ru'),
|
||||
style_set: z.number().int().min(1).max(5).default(1),
|
||||
style_prompt: z.string().trim().max(2000).default(''),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(input) =>
|
||||
new Set(input.reference_asset_ids).size ===
|
||||
input.reference_asset_ids.length,
|
||||
{
|
||||
path: ['reference_asset_ids'],
|
||||
message: '底图不能重复',
|
||||
},
|
||||
);
|
||||
export const imageSuitePlanInputSchema = imageSuiteInputSchema.safeExtend({
|
||||
image_count: z.number().int().min(1).max(20).default(4),
|
||||
});
|
||||
export const createImageSuiteSchema = imageSuiteInputSchema
|
||||
.safeExtend({ shots: z.array(imageSuiteShotSchema).min(1).max(20) })
|
||||
.superRefine((input, ctx) => {
|
||||
if (
|
||||
new Set(input.shots.map((shot) => shot.id)).size !==
|
||||
input.shots.length
|
||||
)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shots'],
|
||||
message: '逐张方案 ID 不能重复',
|
||||
});
|
||||
for (const category of imageSuiteCategories)
|
||||
if (
|
||||
input.shots.filter((shot) => shot.category === category)
|
||||
.length > 5
|
||||
)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shots'],
|
||||
message: '每类最多生成 5 张',
|
||||
});
|
||||
input.shots.forEach((shot, index) => {
|
||||
if (
|
||||
shot.reference_asset_ids &&
|
||||
shot.reference_asset_ids[0] !== input.reference_asset_ids[0]
|
||||
)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shots', index, 'reference_asset_ids'],
|
||||
message: '每张图片必须使用已选主体图作为第一张参考图',
|
||||
});
|
||||
if (
|
||||
shot.reference_asset_ids?.some(
|
||||
(id) => !input.reference_asset_ids.includes(id),
|
||||
)
|
||||
)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shots', index, 'reference_asset_ids'],
|
||||
message: '逐张参考图必须来自已选底图',
|
||||
});
|
||||
if (shot.type === 'dimensions' && !/\d/.test(input.dimensions))
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['shots', index, 'type'],
|
||||
message: '请先填写有依据的实物尺寸',
|
||||
});
|
||||
});
|
||||
});
|
||||
export type ImageSuiteInput = z.infer<typeof imageSuiteInputSchema>;
|
||||
export type ImageSuitePlanInput = z.infer<typeof imageSuitePlanInputSchema>;
|
||||
export type ImageSuiteShot = z.infer<typeof imageSuiteShotSchema>;
|
||||
export type CreateImageSuiteRequest = z.infer<typeof createImageSuiteSchema>;
|
||||
export type ImageSuite = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
purpose: 'plan' | 'generate';
|
||||
status:
|
||||
'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled';
|
||||
input: ImageSuiteInput | ImageSuitePlanInput;
|
||||
shots: ImageSuiteShot[];
|
||||
items: Array<MediaAiEdit & { shot_id: string; edit_id: string }>;
|
||||
error_code: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
operation: Operation;
|
||||
};
|
||||
@@ -5,8 +5,10 @@ export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
export * from './ozon-hashtags';
|
||||
export * from './online-products';
|
||||
|
||||
export * from './pricing';
|
||||
export * from './pricing-admin';
|
||||
export * from './category-commissions';
|
||||
export * from './image-suite';
|
||||
|
||||
@@ -59,9 +59,9 @@ export const createMediaAiEditRequestSchema = z.object({
|
||||
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'),
|
||||
ratio: z.enum(['3:4', '1:1', '4:5', '4:3']).default('3:4'),
|
||||
translate_text: z.boolean().default(false),
|
||||
target_language: z.enum(['ru', 'ar', 'en']).default('ru'),
|
||||
target_language: z.enum(['ru', 'ar', 'en', 'zh']).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),
|
||||
@@ -73,6 +73,15 @@ export const createMediaAiEditRequestSchema = z.object({
|
||||
unit: z.enum(['cm', 'mm']).default('cm'),
|
||||
}).strict().optional(),
|
||||
generate_size_chart: z.boolean().default(false),
|
||||
suite_context: z.object({
|
||||
type: z.string().max(80),
|
||||
product_name: z.string().max(1000),
|
||||
color: z.string().max(500),
|
||||
dimensions: z.string().max(500),
|
||||
weight: z.string().max(500),
|
||||
collected_info: z.string().max(10000),
|
||||
selling_points: z.string().max(5000),
|
||||
}).strict().optional(),
|
||||
}).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'] });
|
||||
@@ -158,7 +167,7 @@ export type MediaAiEdit = {
|
||||
};
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const OZON_HASHTAG_LIMIT = 30;
|
||||
export const OZON_HASHTAG_MAX_LENGTH = 30;
|
||||
|
||||
/** 标签数组的每项只表示一个主题,空格分隔仅用于最终 Ozon 属性值。 */
|
||||
export function ozonHashtagProblem(tags: readonly string[]): string | null {
|
||||
if (tags.length > OZON_HASHTAG_LIMIT)
|
||||
return `主题标签最多 ${OZON_HASHTAG_LIMIT} 个`;
|
||||
for (const [index, tag] of tags.entries()) {
|
||||
const label = `第 ${index + 1} 个主题标签`;
|
||||
if (!tag.startsWith('#'))
|
||||
return `${label}必须以 # 开头,例如 #спортивный_стиль`;
|
||||
if (/\s/u.test(tag))
|
||||
return `${label}内不能有空格,多词请用下划线连接;不同标签用空格分隔`;
|
||||
if (tag.slice(1).includes('#'))
|
||||
return `${label}包含多个 #,请用空格分隔不同的标签`;
|
||||
if (!/^#[\p{L}\p{N}_]+$/u.test(tag))
|
||||
return `${label}只能包含开头的 #、字母、数字和下划线`;
|
||||
if ([...tag].length > OZON_HASHTAG_MAX_LENGTH)
|
||||
return `${label}最多 ${OZON_HASHTAG_MAX_LENGTH} 个字符(含 #)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 仅整理 AI 标签的常见格式偏差,不截断主题,也不用于改写卖家的草稿。 */
|
||||
export function normalizeAiOzonHashtag(value: string): string | null {
|
||||
const body = value
|
||||
.trim()
|
||||
.replace(/^#/, '')
|
||||
.replace(/[\s-]+/gu, '_');
|
||||
const tag = `#${body}`;
|
||||
return ozonHashtagProblem([tag]) ? null : tag;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 套图绑定一个 SKU;资料与逐张方案是本次任务的不可变快照。
|
||||
CREATE TABLE product_image_suites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
product_id UUID NOT NULL,
|
||||
variant_id UUID NOT NULL,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('plan', 'generate')),
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'partial', 'failed', 'cancelled')),
|
||||
input JSONB NOT NULL CHECK (jsonb_typeof(input) = 'object'),
|
||||
shots JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(shots) = 'array' AND jsonb_array_length(shots) <= 20),
|
||||
error_code TEXT,
|
||||
model_name TEXT,
|
||||
prompt_version TEXT NOT NULL DEFAULT 'image-suite-v1',
|
||||
token_usage INTEGER,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, variant_id, product_id) REFERENCES product_variants(workspace_id, id, product_id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, id, product_id),
|
||||
CHECK (purpose <> 'generate' OR jsonb_array_length(shots) >= 1),
|
||||
CHECK (jsonb_array_length(input -> 'reference_asset_ids') BETWEEN 1 AND 8)
|
||||
);
|
||||
CREATE INDEX ix_product_image_suites_recent ON product_image_suites(workspace_id, product_id, created_at DESC, id DESC);
|
||||
|
||||
-- 每个底图槽位强制引用当前工作空间正式素材,不能借用外部租户 ID。
|
||||
CREATE TABLE product_image_suite_references (
|
||||
workspace_id UUID NOT NULL,
|
||||
suite_id UUID NOT NULL,
|
||||
asset_id UUID NOT NULL,
|
||||
position INTEGER NOT NULL CHECK (position BETWEEN 0 AND 7),
|
||||
PRIMARY KEY (workspace_id, suite_id, asset_id),
|
||||
UNIQUE (workspace_id, suite_id, position),
|
||||
FOREIGN KEY (workspace_id, suite_id) REFERENCES product_image_suites(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
ALTER TABLE media_ai_edits ADD COLUMN suite_id UUID,
|
||||
ADD COLUMN suite_shot_id UUID,
|
||||
ADD COLUMN suite_position INTEGER,
|
||||
ADD CONSTRAINT media_ai_edits_suite_fk FOREIGN KEY (workspace_id, suite_id, product_id)
|
||||
REFERENCES product_image_suites(workspace_id, id, product_id) ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT media_ai_edits_suite_shape CHECK (
|
||||
(suite_id IS NULL AND suite_shot_id IS NULL AND suite_position IS NULL) OR
|
||||
(suite_id IS NOT NULL AND product_id IS NOT NULL AND suite_shot_id IS NOT NULL AND suite_position BETWEEN 0 AND 19)),
|
||||
ADD CONSTRAINT media_ai_edits_suite_shot_unique UNIQUE (workspace_id, suite_id, suite_shot_id),
|
||||
ADD CONSTRAINT media_ai_edits_suite_position_unique UNIQUE (workspace_id, suite_id, suite_position);
|
||||
|
||||
CREATE FUNCTION guard_image_suite_references() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.suite_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM unnest(ARRAY[NEW.source_asset_id] || NEW.reference_asset_ids) AS refs(id)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM product_image_suite_references r
|
||||
WHERE r.workspace_id = NEW.workspace_id AND r.suite_id = NEW.suite_id AND r.asset_id = refs.id)
|
||||
) THEN
|
||||
RAISE EXCEPTION '套图子任务底图必须来自当前任务底图库' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER media_ai_edits_suite_references BEFORE INSERT OR UPDATE OF source_asset_id, reference_asset_ids, suite_id
|
||||
ON media_ai_edits FOR EACH ROW EXECUTE FUNCTION guard_image_suite_references();
|
||||
|
||||
-- 汇总依赖数据库子任务事实;重投递不会增加成功计数,也不会覆盖取消的整套。
|
||||
CREATE FUNCTION refresh_image_suite_status() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
parent product_image_suites%ROWTYPE;
|
||||
count_all INTEGER;
|
||||
count_done INTEGER;
|
||||
count_active INTEGER;
|
||||
next_status TEXT;
|
||||
BEGIN
|
||||
IF NEW.suite_id IS NULL THEN RETURN NEW; END IF;
|
||||
SELECT * INTO parent FROM product_image_suites WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id FOR UPDATE;
|
||||
IF parent.status = 'cancelled' THEN RETURN NEW; END IF;
|
||||
SELECT count(*), count(*) FILTER (WHERE status = 'succeeded'), count(*) FILTER (WHERE status IN ('queued', 'running'))
|
||||
INTO count_all, count_done, count_active FROM media_ai_edits WHERE workspace_id = NEW.workspace_id AND suite_id = NEW.suite_id;
|
||||
next_status := CASE WHEN count_active > 0 THEN 'running' WHEN count_done = count_all THEN 'succeeded'
|
||||
WHEN count_done > 0 THEN 'partial' ELSE 'failed' END;
|
||||
UPDATE product_image_suites SET status = next_status,
|
||||
completed_at = CASE WHEN count_active = 0 THEN now() ELSE NULL END,
|
||||
error_code = CASE WHEN next_status IN ('partial', 'failed') THEN 'AI_SUITE_IMAGE_FAILED' ELSE NULL END
|
||||
WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id;
|
||||
UPDATE operations SET status = CASE WHEN next_status = 'partial' THEN 'failed' ELSE next_status END,
|
||||
started_at = coalesce(started_at, now()), completed_at = CASE WHEN count_active = 0 THEN now() ELSE NULL END,
|
||||
error_code = CASE WHEN next_status IN ('partial', 'failed') THEN 'AI_SUITE_IMAGE_FAILED' ELSE NULL END,
|
||||
error_message = CASE WHEN next_status IN ('partial', 'failed') THEN '部分图片未完成,可重试失败项' ELSE NULL END,
|
||||
result = jsonb_build_object('completed', count_done, 'total', count_all)
|
||||
WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER media_ai_edits_suite_progress AFTER UPDATE OF status ON media_ai_edits
|
||||
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status) EXECUTE FUNCTION refresh_image_suite_status();
|
||||
|
||||
ALTER TABLE product_image_suites ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_image_suites FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_image_suites USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
ALTER TABLE product_image_suite_references ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE product_image_suite_references FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON product_image_suite_references USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id());
|
||||
@@ -0,0 +1,35 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 重试拥有独立代次;旧轮询或迟到结果不得影响新的生图请求。
|
||||
ALTER TABLE media_ai_edits ADD COLUMN generation_attempt INTEGER NOT NULL DEFAULT 1 CHECK (generation_attempt >= 1);
|
||||
ALTER TABLE media_ai_edits DROP CONSTRAINT media_ai_edits_suite_shape;
|
||||
ALTER TABLE media_ai_edits ADD CONSTRAINT media_ai_edits_suite_shape CHECK (
|
||||
(suite_id IS NULL AND suite_shot_id IS NULL AND suite_position IS NULL) OR
|
||||
(suite_id IS NOT NULL AND product_id IS NOT NULL AND suite_shot_id IS NOT NULL AND suite_position IS NOT NULL AND suite_position BETWEEN 0 AND 19));
|
||||
ALTER TABLE product_image_suites ADD CONSTRAINT image_suite_input_references CHECK (
|
||||
coalesce(jsonb_typeof(input -> 'reference_asset_ids') = 'array' AND jsonb_array_length(input -> 'reference_asset_ids') BETWEEN 1 AND 8, false));
|
||||
|
||||
CREATE FUNCTION guard_image_suite_snapshot() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.input IS DISTINCT FROM OLD.input OR NEW.product_id IS DISTINCT FROM OLD.product_id
|
||||
OR NEW.variant_id IS DISTINCT FROM OLD.variant_id OR NEW.purpose IS DISTINCT FROM OLD.purpose THEN
|
||||
RAISE EXCEPTION '套图输入快照不可改写' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.shots IS DISTINCT FROM OLD.shots AND (OLD.purpose <> 'plan' OR OLD.status NOT IN ('queued', 'running')) THEN
|
||||
RAISE EXCEPTION '已固化的出图方案不可改写' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER product_image_suite_snapshot BEFORE UPDATE ON product_image_suites
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_image_suite_snapshot();
|
||||
|
||||
CREATE FUNCTION guard_image_suite_reference_slot() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM product_image_suites WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id
|
||||
AND input -> 'reference_asset_ids' ->> NEW.position = NEW.asset_id::text) THEN
|
||||
RAISE EXCEPTION '底图槽位与套图输入快照不一致' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER product_image_suite_reference_slot BEFORE INSERT OR UPDATE ON product_image_suite_references
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_image_suite_reference_slot();
|
||||
@@ -0,0 +1,7 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 触发器不能依赖 API 连接的默认 search_path;固定业务 schema,并排除可写 public schema。
|
||||
ALTER FUNCTION atlas.guard_image_suite_references() SET search_path = atlas, pg_temp;
|
||||
ALTER FUNCTION atlas.refresh_image_suite_status() SET search_path = atlas, pg_temp;
|
||||
ALTER FUNCTION atlas.guard_image_suite_snapshot() SET search_path = atlas, pg_temp;
|
||||
ALTER FUNCTION atlas.guard_image_suite_reference_slot() SET search_path = atlas, pg_temp;
|
||||
@@ -0,0 +1,30 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- operation 使用数据库枚举,父套图的 partial 状态映射为失败操作并保留已完成数量。
|
||||
CREATE OR REPLACE FUNCTION refresh_image_suite_status() RETURNS TRIGGER LANGUAGE plpgsql SET search_path = atlas, pg_temp AS $$
|
||||
DECLARE
|
||||
parent product_image_suites%ROWTYPE;
|
||||
count_all INTEGER;
|
||||
count_done INTEGER;
|
||||
count_active INTEGER;
|
||||
next_status TEXT;
|
||||
BEGIN
|
||||
IF NEW.suite_id IS NULL THEN RETURN NEW; END IF;
|
||||
SELECT * INTO parent FROM product_image_suites WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id FOR UPDATE;
|
||||
IF parent.status = 'cancelled' THEN RETURN NEW; END IF;
|
||||
SELECT count(*), count(*) FILTER (WHERE status = 'succeeded'), count(*) FILTER (WHERE status IN ('queued', 'running'))
|
||||
INTO count_all, count_done, count_active FROM media_ai_edits WHERE workspace_id = NEW.workspace_id AND suite_id = NEW.suite_id;
|
||||
next_status := CASE WHEN count_active > 0 THEN 'running' WHEN count_done = count_all THEN 'succeeded'
|
||||
WHEN count_done > 0 THEN 'partial' ELSE 'failed' END;
|
||||
UPDATE product_image_suites SET status = next_status,
|
||||
completed_at = CASE WHEN count_active = 0 THEN now() ELSE NULL END,
|
||||
error_code = CASE WHEN next_status IN ('partial', 'failed') THEN 'AI_SUITE_IMAGE_FAILED' ELSE NULL END
|
||||
WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id;
|
||||
UPDATE operations SET status = (CASE WHEN next_status = 'partial' THEN 'failed' ELSE next_status END)::operation_status,
|
||||
started_at = coalesce(started_at, now()), completed_at = CASE WHEN count_active = 0 THEN now() ELSE NULL END,
|
||||
error_code = CASE WHEN next_status IN ('partial', 'failed') THEN 'AI_SUITE_IMAGE_FAILED' ELSE NULL END,
|
||||
error_message = CASE WHEN next_status IN ('partial', 'failed') THEN '部分图片未完成,可重试失败项' ELSE NULL END,
|
||||
result = jsonb_build_object('completed', count_done, 'total', count_all)
|
||||
WHERE workspace_id = NEW.workspace_id AND id = NEW.suite_id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user