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());