feat(edit): 编辑上报细节优化

This commit is contained in:
2026-10-06 18:30:05 +08:00
parent e20cd7547f
commit 79b310efe7
92 changed files with 2764 additions and 368 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { PRODUCT_VARIANT_LIMIT } from './product-limits';
const uuid = z.uuid();
@@ -71,7 +72,7 @@ export const collectedProductPayloadSchema = z.object({
selections: z.array(z.object({ axisKey: z.string().min(1).max(300), valueKey: z.string().min(1).max(300), valueName: z.string().min(1).max(300) })).max(50),
sourcePrice: z.object({ amount: z.string().regex(/^\d+(\.\d{1,4})?$/), currencyCode: z.string().length(3).toUpperCase() }).optional(),
confidence: z.enum(['verified', 'partial']), evidenceRefs: z.array(z.string().min(1).max(300)).max(100),
}).strict()).max(100),
}).strict()).max(PRODUCT_VARIANT_LIMIT, `单个商品最多支持 ${PRODUCT_VARIANT_LIMIT} 个 SKU`),
assets: z.array(z.object({
url: z.url(), mediaType: z.enum(['image', 'video']).default('image'), posterUrl: z.url().optional(), role: z.enum(['source_main', 'source_sku', 'source_detail']), variantSourceKey: z.string().min(1).max(500).optional(),
relation: z.enum(['variant_direct', 'axis_value', 'product_candidate', 'inherited_product_default']), sortOrder: z.number().int().min(0).max(10_000).default(0), refererOrigin: z.url().optional(), evidenceRef: z.string().max(300).optional(),
+1
View File
@@ -1,4 +1,5 @@
export * from './collection';
export * from './product-limits';
export * from './auth';
export * from './stores';
export * from './watermarks';
+22 -2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import type { Operation } from './stores.js';
const checksum = z
.string()
@@ -6,6 +7,7 @@ const checksum = z
export const createMediaUploadIntentRequestSchema = z
.object({
purpose: z.enum(['watermark_image', 'product_image']),
product_id: z.uuid().optional(),
filename: z.string().trim().min(1).max(255),
content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']),
byte_size: z
@@ -19,7 +21,8 @@ export const createMediaUploadIntentRequestSchema = z
.refine(
(value) => value.purpose !== 'watermark_image' || value.byte_size <= 5 * 1024 * 1024,
{ path: ['byte_size'], message: '水印图片不能超过 5 MiB' },
);
).refine((value) => value.purpose === 'product_image' ? Boolean(value.product_id) : !value.product_id,
{ path: ['product_id'], message: '商品图片必须绑定商品,水印图片不能绑定商品' });
export const completeMediaUploadIntentRequestSchema = z
.object({
checksum_sha256: checksum,
@@ -32,6 +35,7 @@ export const createMediaAccessUrlRequestSchema = z
.strict();
export type CreateMediaAccessUrlRequest = z.infer<typeof createMediaAccessUrlRequestSchema>;
export const importProductImageRequestSchema = z.object({
product_id: z.uuid(),
url: z.url().max(2048).refine((value) => {
try {
const url = new URL(value);
@@ -130,7 +134,7 @@ export type MediaAsset = {
created_at: string;
};
/** 工作空间已上传的商品图片,预览地址过期后需重新读取。 */
/** 当前商品已上传的图片,预览地址过期后需重新读取。 */
export type ProductUploadImage = MediaAsset & {
preview_url: string;
preview_url_expires_at: string;
@@ -195,3 +199,19 @@ export type MediaAccessUrl = {
url: string;
expires_at: string;
};
export const deleteProductImageRequestSchema = z.object({
asset_id: z.uuid().optional(),
collection_image_id: z.uuid().optional(),
}).strict().refine((value) => Boolean(value.asset_id) !== Boolean(value.collection_image_id),
{ message: '必须指定一张素材或采集图片' });
export type DeleteProductImageRequest = z.infer<typeof deleteProductImageRequestSchema>;
export type ProductImageDeletion = {
operation: Operation;
id: string;
status: 'queued' | 'succeeded' | 'failed';
asset_id: string | null;
collection_image_id: string | null;
error_code: string | null;
};
+5 -1
View File
@@ -74,12 +74,16 @@ export type ProductAiTask = {
error_message: string | null;
};
export const PRODUCT_AI_HINT_MAX_LENGTH = 1000;
export const requestProductAiTaskSchema = z
.object({
hint: z.string().trim().max(1000).optional(),
hint: z.string().trim().max(PRODUCT_AI_HINT_MAX_LENGTH).optional(),
})
.strict();
export type RequestProductAiTask = z.infer<typeof requestProductAiTaskSchema>;
export const productCopyTranslationSchema = z
.object({
variant_id: z.uuid().nullable(),
+15 -1
View File
@@ -5,6 +5,7 @@ import {
type PricingOption,
} from './pricing';
import { z } from 'zod';
import { PRODUCT_VARIANT_LIMIT } from './product-limits';
import {
productCopyTranslationSchema,
type ProductCopyTranslation,
@@ -104,6 +105,18 @@ export type ProductAttributeSchema = {
export type ProductCatalogCacheStatus =
'fresh' | 'stale' | 'expired' | 'missing';
/** 商品颜色的多选例外由稳定属性 ID 识别,不依赖目录名称的语言或翻译。 */
export function isOzonProductColorAttribute(definition: Pick<ProductAttributeSchema, 'platform_attribute_id'>) {
return definition.platform_attribute_id === '10096';
}
/** 编辑、保存和发布共用数量规则,避免颜色草稿可保存但发布时被单值校验拒绝。 */
export function productAttributeValueRules(definition: Pick<ProductAttributeSchema, 'platform_attribute_id' | 'is_collection' | 'max_value_count'>) {
return isOzonProductColorAttribute(definition)
? { allowsMultipleValues: true, maxValueCount: 100 }
: { allowsMultipleValues: definition.is_collection, maxValueCount: definition.max_value_count };
}
export type ProductCatalogCacheMetadata = {
status: ProductCatalogCacheStatus;
last_success_at: string | null;
@@ -292,6 +305,7 @@ export type ProductEditorSnapshot = {
effective_package_spec: PackageSpec;
listing: {
id: string;
store_id: string;
offer_id: string | null;
status: string;
pricing_draft?: PricingParameters | null;
@@ -630,7 +644,7 @@ export const saveProductEditorDraftRequestSchema = z
}),
)
.min(1)
.max(100)
.max(PRODUCT_VARIANT_LIMIT, `单个商品最多支持 ${PRODUCT_VARIANT_LIMIT} 个 SKU`)
.refine(
(variants) => new Set(variants.map((v) => v.id ? `id:${v.id}` : `client:${v.client_key}`)).size === variants.length,
'SKU 标识不能重复',
+2
View File
@@ -0,0 +1,2 @@
/** 单次采集和商品完整草稿保存的 SKU 容量;首次发布批量限制独立校验。 */
export const PRODUCT_VARIANT_LIMIT = 1_000;
@@ -0,0 +1,20 @@
SET search_path TO atlas, public;
-- 历史记录无法可靠推断归属,保留空值但不再作为任何商品的候选图片。
ALTER TABLE media_upload_intents ADD COLUMN product_id UUID;
ALTER TABLE media_upload_intents ADD CONSTRAINT media_upload_intents_product_fk
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT;
ALTER TABLE media_upload_intents ADD CONSTRAINT media_upload_intents_product_purpose_check
CHECK (product_id IS NULL OR purpose = 'product_image');
CREATE INDEX ix_media_upload_intents_product_ready
ON media_upload_intents (workspace_id, product_id, completed_at DESC)
WHERE purpose = 'product_image' AND status = 'ready';
ALTER TABLE product_image_imports ADD COLUMN product_id UUID;
ALTER TABLE product_image_imports ADD CONSTRAINT product_image_imports_product_fk
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT;
ALTER TABLE product_image_imports DROP CONSTRAINT product_image_imports_workspace_id_source_url_key;
ALTER TABLE product_image_imports ADD CONSTRAINT product_image_imports_product_url_unique
UNIQUE (workspace_id, product_id, source_url);
CREATE INDEX ix_product_image_imports_product_recent
ON product_image_imports (workspace_id, product_id, updated_at DESC, id DESC);
@@ -0,0 +1,63 @@
SET search_path TO atlas, public;
-- 素材保留审计关系,删除后释放内容摘要,重新上传会创建独立素材和对象路径。
ALTER TABLE media_assets ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE media_assets ADD COLUMN deleted_checksum_sha256 TEXT;
ALTER TABLE media_assets ALTER COLUMN checksum_sha256 DROP NOT NULL;
ALTER TABLE media_assets ADD CONSTRAINT media_assets_deletion_shape
CHECK ((deleted_at IS NULL AND checksum_sha256 IS NOT NULL)
OR (deleted_at IS NOT NULL AND checksum_sha256 IS NULL));
ALTER TABLE collection_asset_transfers ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE TABLE product_image_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
asset_id UUID,
collection_image_id UUID,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'succeeded', 'failed')),
objects JSONB NOT NULL DEFAULT '[]',
error_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
FOREIGN KEY (collection_image_id) REFERENCES collection_asset_transfers(id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, asset_id),
UNIQUE (workspace_id, collection_image_id),
CHECK (asset_id IS NOT NULL OR collection_image_id IS NOT NULL)
);
ALTER TABLE product_image_deletions ENABLE ROW LEVEL SECURITY;
ALTER TABLE product_image_deletions FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON product_image_deletions
USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id());
ALTER TABLE collection_asset_transfers ADD CONSTRAINT collection_asset_transfers_product_identity UNIQUE (workspace_id, id, product_id);
ALTER TABLE product_image_deletions ADD CONSTRAINT product_image_deletions_collection_scope
FOREIGN KEY (workspace_id, collection_image_id, product_id) REFERENCES collection_asset_transfers(workspace_id, id, product_id);
-- 引用写入锁定素材,与删除事务的排他锁串行;历史引用保留,但不能重新选择已删除素材。
CREATE FUNCTION reject_deleted_media_reference() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
DECLARE column_name TEXT; asset UUID; removed TIMESTAMPTZ;
BEGIN
FOREACH column_name IN ARRAY TG_ARGV LOOP
FOR asset IN SELECT value::uuid FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(to_jsonb(NEW)->column_name) = 'array' THEN to_jsonb(NEW)->column_name
ELSE jsonb_build_array(to_jsonb(NEW)->column_name) END) value WHERE value IS NOT NULL LOOP
SELECT deleted_at INTO removed FROM media_assets WHERE id = asset FOR KEY SHARE;
IF removed IS NOT NULL THEN RAISE EXCEPTION 'media asset has been deleted' USING ERRCODE = '23514'; END IF;
END LOOP;
END LOOP;
RETURN NEW;
END $$;
DO $$ DECLARE rel RECORD; BEGIN
FOR rel IN SELECT c.conrelid::regclass AS tab, a.attname AS col
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f' AND c.confrelid = 'atlas.media_assets'::regclass
AND a.attname <> 'workspace_id' AND c.conrelid <> 'atlas.product_image_deletions'::regclass LOOP
EXECUTE format('CREATE TRIGGER %I BEFORE INSERT OR UPDATE OF %I ON %s FOR EACH ROW EXECUTE FUNCTION reject_deleted_media_reference(%L)',
'guard_deleted_' || rel.col, rel.col, rel.tab, rel.col);
END LOOP;
END $$;
CREATE TRIGGER guard_deleted_ai_references BEFORE INSERT OR UPDATE OF reference_asset_ids ON media_ai_edits
FOR EACH ROW EXECUTE FUNCTION reject_deleted_media_reference('reference_asset_ids');
@@ -0,0 +1,21 @@
SET search_path TO atlas, public;
-- 删除保护也落在数据库,禁止直接更新绕过 SKU、头像、水印和活跃任务的引用校验。
CREATE FUNCTION guard_media_asset_deletion() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
BEGIN
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
PERFORM 1 FROM media_assets WHERE id = OLD.id FOR UPDATE;
IF EXISTS (SELECT 1 FROM product_variant_listing_media WHERE asset_id = OLD.id)
OR EXISTS (SELECT 1 FROM watermark_template_versions WHERE image_asset_id = OLD.id)
OR EXISTS (SELECT 1 FROM users WHERE avatar_asset_id = OLD.id)
OR EXISTS (SELECT 1 FROM media_ai_edits WHERE status IN ('queued','running') AND (source_asset_id = OLD.id OR OLD.id = ANY(reference_asset_ids)))
OR EXISTS (SELECT 1 FROM media_aspect_ratio_derivatives WHERE status IN ('queued','running') AND source_asset_id = OLD.id)
OR EXISTS (SELECT 1 FROM media_watermark_derivatives WHERE status IN ('queued','running') AND source_asset_id = OLD.id)
OR EXISTS (SELECT 1 FROM product_image_suite_references r JOIN product_image_suites s ON s.id = r.suite_id WHERE r.asset_id = OLD.id AND s.status IN ('queued','running')) THEN
RAISE EXCEPTION 'media asset is in use' USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER guard_media_asset_deletion BEFORE UPDATE OF deleted_at ON media_assets
FOR EACH ROW EXECUTE FUNCTION guard_media_asset_deletion();