fix(edit): 类目问题修改

This commit is contained in:
R524809
2026-09-11 10:36:25 +08:00
parent 05a00d46b9
commit 2c4e94ab3d
11 changed files with 869 additions and 79 deletions
@@ -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: {