fix(edit): 类目问题修改
This commit is contained in:
@@ -251,6 +251,43 @@ export class ProductEditorService {
|
||||
ORDER BY is_default DESC, name, id`,
|
||||
[platformId],
|
||||
);
|
||||
const media = await transaction.query<{
|
||||
listing_id: string;
|
||||
asset_id: string;
|
||||
content_type: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||
byte_size: string;
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
source_url: string | null;
|
||||
}>(
|
||||
`SELECT m.listing_id, m.asset_id, a.content_type, a.byte_size::text,
|
||||
a.width_px, a.height_px, min(t.source_url) AS source_url
|
||||
FROM atlas.product_variant_listing_media m
|
||||
JOIN atlas.media_assets a ON a.id = m.asset_id
|
||||
LEFT JOIN atlas.collection_asset_transfers t
|
||||
ON t.asset_id = m.asset_id AND t.product_id = $2
|
||||
WHERE m.profile_id = $1
|
||||
GROUP BY m.id, m.listing_id, m.asset_id, a.content_type,
|
||||
a.byte_size, a.width_px, a.height_px
|
||||
ORDER BY m.listing_id, m.sort_order, m.id`,
|
||||
[profile.id, productId],
|
||||
);
|
||||
const mediaByListing = new Map<
|
||||
string,
|
||||
ProductEditorSnapshot['variants'][number]['media']
|
||||
>();
|
||||
for (const item of media.rows) {
|
||||
const items = mediaByListing.get(item.listing_id) ?? [];
|
||||
items.push({
|
||||
asset_id: item.asset_id,
|
||||
content_type: item.content_type,
|
||||
byte_size: Number(item.byte_size),
|
||||
width_px: item.width_px,
|
||||
height_px: item.height_px,
|
||||
source_url: item.source_url,
|
||||
});
|
||||
mediaByListing.set(item.listing_id, items);
|
||||
}
|
||||
const listingCounts = await transaction.query<{
|
||||
total: string;
|
||||
imported: string;
|
||||
@@ -336,6 +373,7 @@ export class ProductEditorService {
|
||||
list_price: variant.list_price,
|
||||
currency_code: variant.currency_code,
|
||||
},
|
||||
media: mediaByListing.get(variant.listing_id) ?? [],
|
||||
}));
|
||||
return {
|
||||
collection_item_id: product.collection_item_id,
|
||||
@@ -845,10 +883,18 @@ export class ProductEditorService {
|
||||
id: string;
|
||||
}> = [];
|
||||
const retainedIds: string[] = [];
|
||||
const variantMedia: Array<{
|
||||
variantId: string;
|
||||
assetIds: string[];
|
||||
}> = [];
|
||||
for (let index = 0; index < input.variants.length; index += 1) {
|
||||
const variant = input.variants[index];
|
||||
if (variant.id) {
|
||||
retainedIds.push(variant.id);
|
||||
variantMedia.push({
|
||||
variantId: variant.id,
|
||||
assetIds: variant.media_asset_ids,
|
||||
});
|
||||
await transaction.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
|
||||
@@ -959,6 +1005,10 @@ export class ProductEditorService {
|
||||
);
|
||||
const id = inserted.rows[0].id;
|
||||
retainedIds.push(id);
|
||||
variantMedia.push({
|
||||
variantId: id,
|
||||
assetIds: variant.media_asset_ids,
|
||||
});
|
||||
createdVariants.push({
|
||||
client_key: variant.client_key!,
|
||||
id,
|
||||
@@ -1004,6 +1054,11 @@ export class ProductEditorService {
|
||||
attributes: input.attributes,
|
||||
retainedVariantIds: retainedIds,
|
||||
});
|
||||
await this.persistListingMedia(transaction, {
|
||||
product,
|
||||
profile,
|
||||
variants: variantMedia,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
@@ -1045,6 +1100,163 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist final image order only after every supplied asset has passed the
|
||||
* product/SKU provenance check. A completed transfer is the bridge from
|
||||
* an untrusted source URL to an Atlas-owned media asset; callers cannot
|
||||
* attach arbitrary workspace assets to a listing.
|
||||
*/
|
||||
private async persistListingMedia(
|
||||
transaction: DatabaseTransaction,
|
||||
input: {
|
||||
product: ProductRow;
|
||||
profile: ProfileRow;
|
||||
variants: Array<{ variantId: string; assetIds: string[] }>;
|
||||
},
|
||||
) {
|
||||
const variantIds = input.variants.map((variant) => variant.variantId);
|
||||
const listings = await transaction.query<{
|
||||
id: string;
|
||||
product_variant_id: string;
|
||||
}>(
|
||||
`SELECT id, product_variant_id
|
||||
FROM atlas.product_variant_platform_listings
|
||||
WHERE profile_id = $1 AND product_variant_id = ANY($2::uuid[])`,
|
||||
[input.profile.id, variantIds],
|
||||
);
|
||||
if (listings.rows.length !== input.variants.length)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'LISTING_CONTEXT_CHANGED',
|
||||
'SKU 发布记录已变化,请重新加载编辑器',
|
||||
);
|
||||
const listingByVariant = new Map(
|
||||
listings.rows.map((listing) => [listing.product_variant_id, listing]),
|
||||
);
|
||||
const assetIds = [
|
||||
...new Set(input.variants.flatMap((variant) => variant.assetIds)),
|
||||
];
|
||||
const assets = assetIds.length
|
||||
? await transaction.query<{
|
||||
id: string;
|
||||
content_type: string;
|
||||
byte_size: string;
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
}>(
|
||||
`SELECT id, content_type, byte_size::text, width_px, height_px
|
||||
FROM atlas.media_assets WHERE id = ANY($1::uuid[])`,
|
||||
[assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const assetById = new Map(assets.rows.map((asset) => [asset.id, asset]));
|
||||
const existing = await transaction.query<{
|
||||
product_variant_id: string;
|
||||
asset_id: string;
|
||||
}>(
|
||||
`SELECT l.product_variant_id, m.asset_id
|
||||
FROM atlas.product_variant_listing_media m
|
||||
JOIN atlas.product_variant_platform_listings l ON l.id = m.listing_id
|
||||
WHERE m.profile_id = $1 AND m.product_variant_id = ANY($2::uuid[])`,
|
||||
[input.profile.id, variantIds],
|
||||
);
|
||||
const existingByVariant = new Map<string, Set<string>>();
|
||||
for (const item of existing.rows) {
|
||||
const ids = existingByVariant.get(item.product_variant_id) ?? new Set();
|
||||
ids.add(item.asset_id);
|
||||
existingByVariant.set(item.product_variant_id, ids);
|
||||
}
|
||||
const candidates = assetIds.length
|
||||
? await transaction.query<{
|
||||
asset_id: string;
|
||||
source_variant_id: string | null;
|
||||
}>(
|
||||
`SELECT DISTINCT t.asset_id, t.source_variant_id
|
||||
FROM atlas.collection_asset_transfers t
|
||||
WHERE t.collection_item_id = $1 AND t.product_id = $2
|
||||
AND t.source_snapshot_revision = $3
|
||||
AND t.status = 'succeeded'
|
||||
AND t.asset_id = ANY($4::uuid[])`,
|
||||
[
|
||||
input.product.collection_item_id,
|
||||
input.product.id,
|
||||
input.product.source_snapshot_revision,
|
||||
assetIds,
|
||||
],
|
||||
)
|
||||
: { rows: [] };
|
||||
const candidateVariants = new Map<string, Set<string | null>>();
|
||||
for (const candidate of candidates.rows) {
|
||||
const variants = candidateVariants.get(candidate.asset_id) ?? new Set();
|
||||
variants.add(candidate.source_variant_id);
|
||||
candidateVariants.set(candidate.asset_id, variants);
|
||||
}
|
||||
for (const variant of input.variants) {
|
||||
for (const [index, assetId] of variant.assetIds.entries()) {
|
||||
const asset = assetById.get(assetId);
|
||||
const dimensionsKnown =
|
||||
asset?.width_px !== null && asset?.height_px !== null;
|
||||
if (
|
||||
!asset ||
|
||||
!['image/jpeg', 'image/png', 'image/webp'].includes(
|
||||
asset.content_type,
|
||||
) ||
|
||||
Number(asset.byte_size) > 20 * 1024 * 1024 ||
|
||||
!dimensionsKnown ||
|
||||
asset.width_px! > 10_000 ||
|
||||
asset.height_px! > 10_000
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MEDIA_INVALID',
|
||||
'SKU 图片必须是已校验的 JPG、PNG 或 WebP,且不超过 20MB / 10000px',
|
||||
);
|
||||
if (
|
||||
index === 0 &&
|
||||
Math.min(asset.width_px!, asset.height_px!) < 700
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MAIN_IMAGE_TOO_SMALL',
|
||||
'短边小于 700px 的图片不能作为 SKU 主图',
|
||||
);
|
||||
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||
continue;
|
||||
const allowedFor = candidateVariants.get(assetId);
|
||||
if (!allowedFor?.has(null) && !allowedFor?.has(variant.variantId))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||
'只能选择当前采集快照中该 SKU 或商品通用候选已转存的图片',
|
||||
);
|
||||
}
|
||||
}
|
||||
const listingIds = listings.rows.map((listing) => listing.id);
|
||||
await transaction.query(
|
||||
`DELETE FROM atlas.product_variant_listing_media
|
||||
WHERE listing_id = ANY($1::uuid[])`,
|
||||
[listingIds],
|
||||
);
|
||||
for (const variant of input.variants) {
|
||||
const listing = listingByVariant.get(variant.variantId)!;
|
||||
for (const [sortOrder, assetId] of variant.assetIds.entries()) {
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_variant_listing_media
|
||||
(workspace_id, product_id, profile_id, listing_id, product_variant_id, asset_id, sort_order)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
input.product.id,
|
||||
input.profile.id,
|
||||
listing.id,
|
||||
variant.variantId,
|
||||
assetId,
|
||||
sortOrder,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistDynamicAttributes(
|
||||
transaction: DatabaseTransaction,
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user