fix(edit): 类目问题修改
This commit is contained in:
@@ -251,6 +251,43 @@ export class ProductEditorService {
|
|||||||
ORDER BY is_default DESC, name, id`,
|
ORDER BY is_default DESC, name, id`,
|
||||||
[platformId],
|
[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<{
|
const listingCounts = await transaction.query<{
|
||||||
total: string;
|
total: string;
|
||||||
imported: string;
|
imported: string;
|
||||||
@@ -336,6 +373,7 @@ export class ProductEditorService {
|
|||||||
list_price: variant.list_price,
|
list_price: variant.list_price,
|
||||||
currency_code: variant.currency_code,
|
currency_code: variant.currency_code,
|
||||||
},
|
},
|
||||||
|
media: mediaByListing.get(variant.listing_id) ?? [],
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
collection_item_id: product.collection_item_id,
|
collection_item_id: product.collection_item_id,
|
||||||
@@ -845,10 +883,18 @@ export class ProductEditorService {
|
|||||||
id: string;
|
id: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
const retainedIds: string[] = [];
|
const retainedIds: string[] = [];
|
||||||
|
const variantMedia: Array<{
|
||||||
|
variantId: string;
|
||||||
|
assetIds: string[];
|
||||||
|
}> = [];
|
||||||
for (let index = 0; index < input.variants.length; index += 1) {
|
for (let index = 0; index < input.variants.length; index += 1) {
|
||||||
const variant = input.variants[index];
|
const variant = input.variants[index];
|
||||||
if (variant.id) {
|
if (variant.id) {
|
||||||
retainedIds.push(variant.id);
|
retainedIds.push(variant.id);
|
||||||
|
variantMedia.push({
|
||||||
|
variantId: variant.id,
|
||||||
|
assetIds: variant.media_asset_ids,
|
||||||
|
});
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
`UPDATE atlas.product_variants
|
`UPDATE atlas.product_variants
|
||||||
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
|
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
|
||||||
@@ -959,6 +1005,10 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
const id = inserted.rows[0].id;
|
const id = inserted.rows[0].id;
|
||||||
retainedIds.push(id);
|
retainedIds.push(id);
|
||||||
|
variantMedia.push({
|
||||||
|
variantId: id,
|
||||||
|
assetIds: variant.media_asset_ids,
|
||||||
|
});
|
||||||
createdVariants.push({
|
createdVariants.push({
|
||||||
client_key: variant.client_key!,
|
client_key: variant.client_key!,
|
||||||
id,
|
id,
|
||||||
@@ -1004,6 +1054,11 @@ export class ProductEditorService {
|
|||||||
attributes: input.attributes,
|
attributes: input.attributes,
|
||||||
retainedVariantIds: retainedIds,
|
retainedVariantIds: retainedIds,
|
||||||
});
|
});
|
||||||
|
await this.persistListingMedia(transaction, {
|
||||||
|
product,
|
||||||
|
profile,
|
||||||
|
variants: variantMedia,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
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(
|
private async persistDynamicAttributes(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
await tx.query(
|
||||||
|
'DELETE FROM atlas.product_variant_listing_media WHERE workspace_id = $1',
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -157,6 +161,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||||
workspaceId,
|
workspaceId,
|
||||||
]);
|
]);
|
||||||
|
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
await database.query(
|
await database.query(
|
||||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||||
@@ -1058,7 +1065,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`UPDATE atlas.collection_items
|
`UPDATE atlas.collection_items
|
||||||
SET source_platform_id = $1,
|
SET source_kind = 'platform', source_platform_id = $1,
|
||||||
normalized_payload = $2::jsonb
|
normalized_payload = $2::jsonb
|
||||||
WHERE id = $3`,
|
WHERE id = $3`,
|
||||||
[
|
[
|
||||||
@@ -1193,4 +1200,97 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||||
|
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
const variant = before.body.data.variants[0];
|
||||||
|
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const created = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.media_assets
|
||||||
|
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||||
|
byte_size, checksum_sha256, width_px, height_px)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||||
|
'image/jpeg', 1024, $2, 1200, 900)
|
||||||
|
RETURNING id`,
|
||||||
|
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
|
||||||
|
);
|
||||||
|
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
|
||||||
|
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
||||||
|
[first.collectionItemId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.collection_asset_transfers
|
||||||
|
(workspace_id, collection_item_id, product_id, source_variant_id, role,
|
||||||
|
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||||
|
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
||||||
|
[
|
||||||
|
first.collectionItemId,
|
||||||
|
first.productId,
|
||||||
|
variant.id,
|
||||||
|
created.rows[0].id,
|
||||||
|
snapshot.rows[0].source_snapshot_revision,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return created.rows[0].id;
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
ai_source_material: before.body.data.source_material,
|
||||||
|
ai_source_overridden_fields:
|
||||||
|
before.body.data.source_material_state.overridden_fields,
|
||||||
|
common: {
|
||||||
|
...before.body.data.product.common,
|
||||||
|
default_physical_spec: before.body.data.product.default_physical_spec,
|
||||||
|
default_package_spec: before.body.data.product.default_package_spec,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
target_store_id: before.body.data.profile.target_store.id,
|
||||||
|
fulfillment: before.body.data.profile.fulfillment,
|
||||||
|
title: before.body.data.profile.title,
|
||||||
|
description: before.body.data.profile.description,
|
||||||
|
tags: before.body.data.profile.tags,
|
||||||
|
store_product_category_id: before.body.data.profile.category.id,
|
||||||
|
category_source: before.body.data.profile.category.source,
|
||||||
|
},
|
||||||
|
attributes: before.body.data.attributes.map((attribute: any) => ({
|
||||||
|
scope: attribute.scope,
|
||||||
|
variant_id: attribute.variant_id,
|
||||||
|
platform_attribute_id: attribute.platform_attribute_id,
|
||||||
|
complex_id: attribute.complex_id,
|
||||||
|
values: attribute.values,
|
||||||
|
language_code: attribute.language_code,
|
||||||
|
source: attribute.source,
|
||||||
|
confirmation_status: attribute.confirmation_status,
|
||||||
|
})),
|
||||||
|
variants: before.body.data.variants.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
client_key: null,
|
||||||
|
copy_from_variant_id: null,
|
||||||
|
display_name: item.display_name,
|
||||||
|
source_sku: item.source_sku,
|
||||||
|
specification_values: item.specification_values,
|
||||||
|
purchase_cost_cny: item.purchase_cost_cny,
|
||||||
|
physical_spec: item.physical_spec,
|
||||||
|
package_spec: item.package_spec,
|
||||||
|
offer_id: item.listing.offer_id,
|
||||||
|
media_asset_ids: item.id === variant.id ? [asset] : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers({
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
assert.equal(saved.response.status, 200);
|
||||||
|
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(after.body.data.variants[0].media.map((item: any) => item.asset_id), [asset]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import type { Database } from '@atlas/database';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||||
|
import { DATABASE } from '../src/database/database.module.js';
|
||||||
|
import { createApplication } from '../src/main.js';
|
||||||
|
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js';
|
||||||
|
import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
|
||||||
|
|
||||||
|
let app: NestExpressApplication;
|
||||||
|
let database: Database;
|
||||||
|
let baseUrl = '';
|
||||||
|
let workspaceId = '';
|
||||||
|
let userId = '';
|
||||||
|
let token = '';
|
||||||
|
let platformId = '';
|
||||||
|
let storeId = '';
|
||||||
|
let secretId = '';
|
||||||
|
|
||||||
|
async function request(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
return { response, body: (await response.json()) as any };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleWithStoreLock() {
|
||||||
|
return database.workspaceTransaction(workspaceId, async (transaction) => {
|
||||||
|
await transaction.query(
|
||||||
|
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
return scheduleStoreCategoryTreeSync(transaction, {
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
secretId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('门店类目树 Worker 调度集成测试', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
Object.assign(process.env, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
AUTH_REGISTER_IP_LIMIT: '100',
|
||||||
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||||
|
});
|
||||||
|
const created = await createApplication();
|
||||||
|
app = created.app;
|
||||||
|
database = app.get<Database>(DATABASE);
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||||
|
const registration = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `catalog-worker-${randomUUID()}@example.test`,
|
||||||
|
password: 'correct-horse-battery-staple-123',
|
||||||
|
password_confirmation: 'correct-horse-battery-staple-123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(registration.response.status, 201);
|
||||||
|
workspaceId = registration.body.data.workspace.id;
|
||||||
|
userId = registration.body.data.user.id;
|
||||||
|
token = registration.body.data.access_token;
|
||||||
|
const platforms = await request('/api/platforms', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
platformId = platforms.body.data[0].id;
|
||||||
|
const seeded = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const store = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.stores
|
||||||
|
(workspace_id, platform_id, name, platform_client_id, currency_code,
|
||||||
|
is_default, status, credential_status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, '类目 Worker 测试店',
|
||||||
|
$2, 'RUB', true, 'active', 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[platformId, `catalog-worker-${randomUUID()}`],
|
||||||
|
);
|
||||||
|
const secret = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.store_secrets
|
||||||
|
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||||
|
encryption_key_version, fingerprint, status)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||||
|
$2, $3, 'test', $4, 'valid')
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
store.rows[0].id,
|
||||||
|
Buffer.from('catalog-worker-api-key-must-not-leak'),
|
||||||
|
Buffer.from('catalog-worker-nonce'),
|
||||||
|
randomUUID(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { storeId: store.rows[0].id, secretId: secret.rows[0].id };
|
||||||
|
});
|
||||||
|
storeId = seeded.storeId;
|
||||||
|
secretId = seeded.secretId;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.store_product_categories WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]);
|
||||||
|
});
|
||||||
|
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||||
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||||
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空缓存的并发验证只创建一个无密钥的树同步任务', async () => {
|
||||||
|
const results = await Promise.all([scheduleWithStoreLock(), scheduleWithStoreLock()]);
|
||||||
|
assert.equal(results.filter((result) => result.scheduled).length, 1);
|
||||||
|
assert.ok(results.some((result) => !result.scheduled && result.reason === 'already_queued'));
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const events = await tx.query<{ event_payload: unknown }>(
|
||||||
|
`SELECT event_payload FROM atlas.outbox_events
|
||||||
|
WHERE aggregate_id = $1 AND event_type = 'store_category_tree_sync.requested'`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
assert.equal(events.rows.length, 1);
|
||||||
|
const payload = JSON.stringify(events.rows[0].event_payload);
|
||||||
|
assert.ok(!payload.includes('catalog-worker-api-key-must-not-leak'));
|
||||||
|
assert.ok(!payload.includes('catalog-worker-nonce'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已有已同步的门店缓存时跳过 Ozon 请求调度', async () => {
|
||||||
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await tx.query(
|
||||||
|
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now()
|
||||||
|
WHERE kind = 'store_category_tree_sync' AND resource_id = $1`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.store_product_categories
|
||||||
|
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||||
|
name, path, synced_at)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||||
|
'worker-cache', 'worker-type', '已缓存类目', '已缓存类目', now())`,
|
||||||
|
[storeId, platformId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const result = await scheduleWithStoreLock();
|
||||||
|
assert.deepEqual(result, { scheduled: false, reason: 'cache_available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为 Ozon 暂时失败配置五次指数退避', async () => {
|
||||||
|
const enqueued: Array<{ name: string; options: Record<string, unknown> }> = [];
|
||||||
|
const dispatcher = new OutboxDispatcher(
|
||||||
|
{
|
||||||
|
query: async () => ({ rows: [{ id: workspaceId }] }),
|
||||||
|
workspaceTransaction: async (_workspaceId: string, work: any) =>
|
||||||
|
work({
|
||||||
|
query: async (sql: string) =>
|
||||||
|
sql.includes('RETURNING event.id')
|
||||||
|
? {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
event_type: 'store_category_tree_sync.requested',
|
||||||
|
event_payload: {
|
||||||
|
store_id: storeId,
|
||||||
|
secret_id: secretId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { rows: [] },
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
add: async (name: string, _data: unknown, options: Record<string, unknown>) => {
|
||||||
|
enqueued.push({ name, options });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'worker-test',
|
||||||
|
);
|
||||||
|
await dispatcher.dispatchBatch();
|
||||||
|
assert.deepEqual(enqueued, [
|
||||||
|
{
|
||||||
|
name: 'store_category_tree_sync.requested',
|
||||||
|
options: {
|
||||||
|
jobId: enqueued[0]?.options.jobId,
|
||||||
|
attempts: 5,
|
||||||
|
backoff: { type: 'exponential', delay: 5_000 },
|
||||||
|
removeOnComplete: 1_000,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": "..",
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
.package-dimensions-controls {
|
.package-dimensions-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 22px 120px 22px 120px auto;
|
grid-template-columns: 130px 22px 130px 22px 130px auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@@ -163,6 +163,29 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
}
|
}
|
||||||
|
.editor-standard-cascader .ant-select-selector {
|
||||||
|
min-height: 40px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.editor-standard-cascader .ant-select-selection-placeholder,
|
||||||
|
.editor-standard-cascader .ant-select-selection-item {
|
||||||
|
line-height: 38px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
.editor-category-popup .ant-cascader-menu {
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
.editor-category-popup .ant-cascader-menu-item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.supply-link-control .ant-btn {
|
||||||
|
height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
.category-row {
|
.category-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -411,6 +434,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.sku-media-item {
|
.sku-media-item {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
aspect-ratio: 3 / 4;
|
aspect-ratio: 3 / 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -418,6 +442,15 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f5f3f7;
|
background: #f5f3f7;
|
||||||
}
|
}
|
||||||
|
.sku-media-item .ant-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.sku-media-item.selected {
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
}
|
||||||
.sku-media-item .ant-image,
|
.sku-media-item .ant-image,
|
||||||
.sku-media-item img {
|
.sku-media-item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
|||||||
physical_spec: variant.physical_spec,
|
physical_spec: variant.physical_spec,
|
||||||
package_spec: variant.package_spec,
|
package_spec: variant.package_spec,
|
||||||
offer_id: variant.listing.offer_id,
|
offer_id: variant.listing.offer_id,
|
||||||
|
media_asset_ids: variant.media.map((media) => media.asset_id),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ const wait = (milliseconds: number) =>
|
|||||||
type CategoryTreeOption = {
|
type CategoryTreeOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
isLeaf?: boolean;
|
||||||
children?: CategoryTreeOption[];
|
children?: CategoryTreeOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +122,6 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
|
|||||||
nodes.set(category.id, {
|
nodes.set(category.id, {
|
||||||
value: category.id,
|
value: category.id,
|
||||||
label: category.name,
|
label: category.name,
|
||||||
...(category.is_active ? {} : { disabled: true }),
|
|
||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -134,6 +134,13 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
|
|||||||
if (parent) parent.children!.push(node);
|
if (parent) parent.children!.push(node);
|
||||||
else roots.push(node);
|
else roots.push(node);
|
||||||
}
|
}
|
||||||
|
// Cascader navigation nodes must never be disabled: disabled folders cannot
|
||||||
|
// be opened. `changeOnSelect` remains false (the default), so the component
|
||||||
|
// only commits a value for a leaf node.
|
||||||
|
for (const category of options) {
|
||||||
|
const node = nodes.get(category.id)!;
|
||||||
|
node.isLeaf = !node.children?.length;
|
||||||
|
}
|
||||||
for (const category of options) {
|
for (const category of options) {
|
||||||
const path: string[] = [];
|
const path: string[] = [];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
@@ -320,7 +327,10 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function variantImageUrls(recordValue: CollectionItemInspection | null, variant: SnapshotVariant) {
|
function variantImageCandidates(
|
||||||
|
recordValue: CollectionItemInspection | null,
|
||||||
|
variant: SnapshotVariant,
|
||||||
|
) {
|
||||||
if (!recordValue) return [];
|
if (!recordValue) return [];
|
||||||
const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item]));
|
const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item]));
|
||||||
return recordValue.asset_transfers
|
return recordValue.asset_transfers
|
||||||
@@ -332,8 +342,10 @@ function variantImageUrls(recordValue: CollectionItemInspection | null, variant:
|
|||||||
variant.source_variant_key
|
variant.source_variant_key
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((transfer) => transfer.source_url)
|
.filter(
|
||||||
.filter((url, index, urls) => !!url && urls.indexOf(url) === index);
|
(transfer, index, transfers) =>
|
||||||
|
transfers.findIndex((item) => item.id === transfer.id) === index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackageFields({ name }: { name: NamePath }) {
|
function PackageFields({ name }: { name: NamePath }) {
|
||||||
@@ -433,6 +445,7 @@ const ProductEditPage = () => {
|
|||||||
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
|
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
|
||||||
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
|
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
|
||||||
const draftAttributes = Form.useWatch('attributes', form) ?? [];
|
const draftAttributes = Form.useWatch('attributes', form) ?? [];
|
||||||
|
const draftVariants = Form.useWatch('variants', form) ?? [];
|
||||||
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||||
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
||||||
const [sourceDraft, setSourceDraft] = useState<ProductAiSourceMaterial | null>(null);
|
const [sourceDraft, setSourceDraft] = useState<ProductAiSourceMaterial | null>(null);
|
||||||
@@ -450,6 +463,7 @@ const ProductEditPage = () => {
|
|||||||
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
|
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||||
const blocker = useBlocker(dirty);
|
const blocker = useBlocker(dirty);
|
||||||
@@ -811,6 +825,29 @@ const ProductEditPage = () => {
|
|||||||
'字典值已同步',
|
'字典值已同步',
|
||||||
).then(() => void loadDictionaryValues(dictionaryId, query));
|
).then(() => void loadDictionaryValues(dictionaryId, query));
|
||||||
};
|
};
|
||||||
|
const materializeCandidate = async (transferId: string) => {
|
||||||
|
if (!snapshot) return;
|
||||||
|
setMaterializingTransferId(transferId);
|
||||||
|
try {
|
||||||
|
const queued = await collectionApi.materializeAssets(snapshot.collection_item_id, {
|
||||||
|
transfer_ids: [transferId],
|
||||||
|
});
|
||||||
|
const completed = await waitForCatalogOperation(queued.operation.id);
|
||||||
|
const refreshed = await collectionApi.inspection(snapshot.collection_item_id);
|
||||||
|
setCollectionRecord(refreshed);
|
||||||
|
if (completed?.status === 'succeeded') {
|
||||||
|
message.success('素材已转存,可选择并随草稿保存');
|
||||||
|
} else if (completed?.status === 'failed') {
|
||||||
|
message.error(completed.error?.message ?? '素材转存失败,请稍后重试');
|
||||||
|
} else {
|
||||||
|
message.info('素材正在转存,稍后重新展开此 SKU 即可选择');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(apiErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setMaterializingTransferId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!currentPlatform) return <Alert type="warning" message="正在读取平台上下文" />;
|
if (!currentPlatform) return <Alert type="warning" message="正在读取平台上下文" />;
|
||||||
if (loading && !snapshot)
|
if (loading && !snapshot)
|
||||||
@@ -914,6 +951,9 @@ const ProductEditPage = () => {
|
|||||||
>
|
>
|
||||||
<Cascader
|
<Cascader
|
||||||
className="editor-standard-cascader"
|
className="editor-standard-cascader"
|
||||||
|
classNames={{
|
||||||
|
popup: { root: 'editor-category-popup' },
|
||||||
|
}}
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="选择当前店铺可发布的产品类目"
|
placeholder="选择当前店铺可发布的产品类目"
|
||||||
options={categories.options}
|
options={categories.options}
|
||||||
@@ -1298,6 +1338,7 @@ const ProductEditPage = () => {
|
|||||||
status: null,
|
status: null,
|
||||||
},
|
},
|
||||||
offer_id: null,
|
offer_id: null,
|
||||||
|
media_asset_ids: [],
|
||||||
} satisfies VariantValues);
|
} satisfies VariantValues);
|
||||||
return (
|
return (
|
||||||
<div className="sku-card-list">
|
<div className="sku-card-list">
|
||||||
@@ -1315,9 +1356,33 @@ const ProductEditPage = () => {
|
|||||||
field.name,
|
field.name,
|
||||||
]) as VariantValues;
|
]) as VariantValues;
|
||||||
const expanded = expandedVariantKeys.has(field.key);
|
const expanded = expandedVariantKeys.has(field.key);
|
||||||
const imageUrls = original
|
const imageCandidates = original
|
||||||
? variantImageUrls(collectionRecord, original)
|
? variantImageCandidates(collectionRecord, original)
|
||||||
: [];
|
: [];
|
||||||
|
const selectedAssetIds =
|
||||||
|
draftVariants[field.name]?.media_asset_ids ?? [];
|
||||||
|
const selectedImages = selectedAssetIds
|
||||||
|
.map(
|
||||||
|
(assetId) =>
|
||||||
|
imageCandidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.asset_id === assetId
|
||||||
|
)?.source_url ??
|
||||||
|
original?.media.find(
|
||||||
|
(media) => media.asset_id === assetId
|
||||||
|
)?.source_url
|
||||||
|
)
|
||||||
|
.filter((url): url is string => !!url);
|
||||||
|
const toggleImage = (assetId: string) => {
|
||||||
|
const next = selectedAssetIds.includes(assetId)
|
||||||
|
? selectedAssetIds.filter((id) => id !== assetId)
|
||||||
|
: [...selectedAssetIds, assetId];
|
||||||
|
form.setFieldValue(
|
||||||
|
['variants', field.name, 'media_asset_ids'],
|
||||||
|
next
|
||||||
|
);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
const copyVariant = () =>
|
const copyVariant = () =>
|
||||||
add({
|
add({
|
||||||
...variantValue,
|
...variantValue,
|
||||||
@@ -1328,6 +1393,7 @@ const ProductEditPage = () => {
|
|||||||
? `${variantValue.display_name} 副本`
|
? `${variantValue.display_name} 副本`
|
||||||
: 'SKU 副本',
|
: 'SKU 副本',
|
||||||
offer_id: null,
|
offer_id: null,
|
||||||
|
media_asset_ids: [],
|
||||||
});
|
});
|
||||||
const toggle = () =>
|
const toggle = () =>
|
||||||
setExpandedVariantKeys((previous) => {
|
setExpandedVariantKeys((previous) => {
|
||||||
@@ -1357,6 +1423,12 @@ const ProductEditPage = () => {
|
|||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'media_asset_ids']}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
<div className="sku-card-top">
|
<div className="sku-card-top">
|
||||||
<span className="sku-card-index">
|
<span className="sku-card-index">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
@@ -1420,10 +1492,10 @@ const ProductEditPage = () => {
|
|||||||
<div className="sku-card-field sku-image-field">
|
<div className="sku-card-field sku-image-field">
|
||||||
<span>主图</span>
|
<span>主图</span>
|
||||||
<div className="sku-main-thumbnail">
|
<div className="sku-main-thumbnail">
|
||||||
{imageUrls[0] ? (
|
{selectedImages[0] ? (
|
||||||
<Image
|
<Image
|
||||||
preview={false}
|
preview={false}
|
||||||
src={imageUrls[0]}
|
src={selectedImages[0]}
|
||||||
alt="SKU 主图"
|
alt="SKU 主图"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1500,38 +1572,67 @@ const ProductEditPage = () => {
|
|||||||
<Typography.Title level={5}>
|
<Typography.Title level={5}>
|
||||||
SKU 图片
|
SKU 图片
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
{imageUrls.length ? (
|
{imageCandidates.length ? (
|
||||||
<div className="sku-media-grid">
|
<div className="sku-media-grid">
|
||||||
<div className="sku-media-item main">
|
{imageCandidates.map((candidate) => {
|
||||||
<Image
|
const selectable =
|
||||||
src={imageUrls[0]}
|
candidate.status === 'succeeded' &&
|
||||||
alt="SKU 主图"
|
!!candidate.asset_id;
|
||||||
/>
|
const selected =
|
||||||
</div>
|
selectable &&
|
||||||
<div className="sku-media-secondary">
|
selectedAssetIds.includes(
|
||||||
{imageUrls
|
candidate.asset_id!
|
||||||
.slice(1, 5)
|
);
|
||||||
.map(
|
return (
|
||||||
(
|
<div
|
||||||
url,
|
className={`sku-media-item ${selected ? 'selected' : ''}`}
|
||||||
imageIndex
|
key={candidate.id}
|
||||||
) => (
|
>
|
||||||
<div
|
<Image
|
||||||
className="sku-media-item"
|
src={candidate.source_url}
|
||||||
key={
|
alt="采集候选图片"
|
||||||
url
|
/>
|
||||||
|
{selectable ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={selected ? 'primary' : 'default'}
|
||||||
|
onClick={() =>
|
||||||
|
toggleImage(
|
||||||
|
candidate.asset_id!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Image
|
{selected ? '已选择' : '选择'}
|
||||||
src={
|
</Button>
|
||||||
url
|
) : (
|
||||||
}
|
<Button
|
||||||
alt={`SKU 图片 ${imageIndex + 2}`}
|
size="small"
|
||||||
/>
|
loading={
|
||||||
</div>
|
materializingTransferId ===
|
||||||
)
|
candidate.id
|
||||||
)}
|
}
|
||||||
</div>
|
disabled={
|
||||||
|
candidate.status === 'queued' ||
|
||||||
|
candidate.status === 'running' ||
|
||||||
|
candidate.status === 'failed'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void materializeCandidate(
|
||||||
|
candidate.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{candidate.status ===
|
||||||
|
'pending_selection'
|
||||||
|
? '转存后选择'
|
||||||
|
: candidate.status === 'failed'
|
||||||
|
? '转存失败'
|
||||||
|
: '转存中'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="sku-media-empty">
|
<div className="sku-media-empty">
|
||||||
@@ -1679,7 +1780,7 @@ const ProductEditPage = () => {
|
|||||||
/>
|
/>
|
||||||
<div className="editor-form-grid">
|
<div className="editor-form-grid">
|
||||||
<Form.Item label="货源链接" className="span-two">
|
<Form.Item label="货源链接" className="span-two">
|
||||||
<Space.Compact block>
|
<Space.Compact block className="supply-link-control">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['common', 'purchase_url']}
|
name={['common', 'purchase_url']}
|
||||||
noStyle
|
noStyle
|
||||||
|
|||||||
+8
-34
@@ -18,6 +18,7 @@ import {
|
|||||||
syncOzonCatalog,
|
syncOzonCatalog,
|
||||||
type CatalogSyncScope,
|
type CatalogSyncScope,
|
||||||
} from './ozon-catalog-sync.js';
|
} from './ozon-catalog-sync.js';
|
||||||
|
import { scheduleStoreCategoryTreeSync } from './store-category-tree-scheduler.js';
|
||||||
|
|
||||||
const connection = new Redis(workerConfig.redisUrl, {
|
const connection = new Redis(workerConfig.redisUrl, {
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
@@ -161,8 +162,8 @@ async function completeVerification(
|
|||||||
rejectedStatus?: 401 | 403,
|
rejectedStatus?: 401 | 403,
|
||||||
) {
|
) {
|
||||||
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
await database.workspaceTransaction(job.workspaceId, async (tx) => {
|
||||||
const current = await tx.query<{ id: string }>(
|
const current = await tx.query<{ id: string; platform_id: string }>(
|
||||||
`SELECT s.id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
`SELECT s.id, s.platform_id FROM atlas.stores s JOIN atlas.store_secrets secret ON secret.id = $2
|
||||||
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
WHERE s.id = $1 AND s.deleted_at IS NULL AND secret.store_id = s.id
|
||||||
AND secret.revoked_at IS NULL FOR UPDATE`,
|
AND secret.revoked_at IS NULL FOR UPDATE`,
|
||||||
[job.payload.store_id, job.payload.secret_id ?? ''],
|
[job.payload.store_id, job.payload.secret_id ?? ''],
|
||||||
@@ -216,38 +217,11 @@ async function completeVerification(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
const catalogOperation = await tx.query<{
|
await scheduleStoreCategoryTreeSync(tx, {
|
||||||
id: string;
|
storeId: job.payload.store_id,
|
||||||
}>(
|
platformId: current.rows[0].platform_id,
|
||||||
`INSERT INTO atlas.operations
|
secretId: job.payload.secret_id ?? '',
|
||||||
(workspace_id, kind, resource_type, resource_id)
|
});
|
||||||
VALUES (current_setting('app.workspace_id')::uuid,
|
|
||||||
'store_category_tree_sync', 'store', $1)
|
|
||||||
RETURNING id`,
|
|
||||||
[job.payload.store_id],
|
|
||||||
);
|
|
||||||
const operationId = catalogOperation.rows[0]?.id;
|
|
||||||
if (!operationId) throw new Error('Unable to create store catalog sync operation');
|
|
||||||
await tx.query(
|
|
||||||
`INSERT INTO atlas.outbox_events
|
|
||||||
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
|
||||||
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1,
|
|
||||||
'store_category_tree_sync.requested', $2::jsonb, $3)`,
|
|
||||||
[
|
|
||||||
job.payload.store_id,
|
|
||||||
JSON.stringify({
|
|
||||||
store_id: job.payload.store_id,
|
|
||||||
secret_id: job.payload.secret_id,
|
|
||||||
operation_id: operationId,
|
|
||||||
platform_id: (await tx.query<{ platform_id: string }>(
|
|
||||||
`SELECT platform_id FROM atlas.stores WHERE id = $1`,
|
|
||||||
[job.payload.store_id],
|
|
||||||
)).rows[0]?.platform_id,
|
|
||||||
scope: 'tree',
|
|
||||||
}),
|
|
||||||
operationId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { DatabaseTransaction } from '@atlas/database';
|
||||||
|
|
||||||
|
type ScheduleInput = {
|
||||||
|
storeId: string;
|
||||||
|
platformId: string;
|
||||||
|
secretId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreCategoryTreeScheduleResult =
|
||||||
|
| { scheduled: true; operationId: string }
|
||||||
|
| { scheduled: false; reason: 'cache_available' | 'already_queued' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule exactly one usable tree refresh for a store.
|
||||||
|
*
|
||||||
|
* The caller must hold the store row with `FOR UPDATE`; credential verification
|
||||||
|
* already does this, which turns the lookup + insert below into a per-store
|
||||||
|
* critical section without introducing a process-local lock.
|
||||||
|
*/
|
||||||
|
export async function scheduleStoreCategoryTreeSync(
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
input: ScheduleInput,
|
||||||
|
): Promise<StoreCategoryTreeScheduleResult> {
|
||||||
|
const cache = await transaction.query<{ present: boolean }>(
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM atlas.store_product_categories
|
||||||
|
WHERE store_id = $1 AND synced_at IS NOT NULL
|
||||||
|
) AS present`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
if (cache.rows[0]?.present)
|
||||||
|
return { scheduled: false, reason: 'cache_available' };
|
||||||
|
|
||||||
|
const queued = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
secret_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT operation.id, event.event_payload ->> 'secret_id' AS secret_id
|
||||||
|
FROM atlas.operations operation
|
||||||
|
LEFT JOIN atlas.outbox_events event
|
||||||
|
ON event.event_type = 'store_category_tree_sync.requested'
|
||||||
|
AND event.event_payload ->> 'operation_id' = operation.id::text
|
||||||
|
WHERE operation.kind = 'store_category_tree_sync'
|
||||||
|
AND operation.resource_type = 'store'
|
||||||
|
AND operation.resource_id = $1
|
||||||
|
AND operation.status IN ('queued', 'running')
|
||||||
|
ORDER BY operation.created_at DESC
|
||||||
|
FOR UPDATE OF operation`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
if (queued.rows.some((operation) => operation.secret_id === input.secretId))
|
||||||
|
return { scheduled: false, reason: 'already_queued' };
|
||||||
|
|
||||||
|
// A task tied to a replaced secret can no longer supply this store's cache.
|
||||||
|
// Mark it cancelled before adding the current credential's task; a worker
|
||||||
|
// that has already picked it up independently re-checks the secret and
|
||||||
|
// leaves this terminal state intact.
|
||||||
|
if (queued.rows.length) {
|
||||||
|
await transaction.query(
|
||||||
|
`UPDATE atlas.operations
|
||||||
|
SET status = 'cancelled', completed_at = now(),
|
||||||
|
error_code = 'CATALOG_SYNC_SOURCE_REPLACED',
|
||||||
|
error_message = '门店凭据已更新,旧类目同步已取消'
|
||||||
|
WHERE id = ANY($1::uuid[]) AND status IN ('queued', 'running')`,
|
||||||
|
[queued.rows.map((operation) => operation.id)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await transaction.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.operations
|
||||||
|
(workspace_id, kind, resource_type, resource_id)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid,
|
||||||
|
'store_category_tree_sync', 'store', $1)
|
||||||
|
RETURNING id`,
|
||||||
|
[input.storeId],
|
||||||
|
);
|
||||||
|
const operationId = created.rows[0]?.id;
|
||||||
|
if (!operationId)
|
||||||
|
throw new Error('Unable to create store catalog sync operation');
|
||||||
|
await transaction.query(
|
||||||
|
`INSERT INTO atlas.outbox_events
|
||||||
|
(workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||||
|
VALUES (current_setting('app.workspace_id')::uuid, 'store', $1,
|
||||||
|
'store_category_tree_sync.requested', $2::jsonb, $3)`,
|
||||||
|
[
|
||||||
|
input.storeId,
|
||||||
|
JSON.stringify({
|
||||||
|
store_id: input.storeId,
|
||||||
|
secret_id: input.secretId,
|
||||||
|
operation_id: operationId,
|
||||||
|
platform_id: input.platformId,
|
||||||
|
scope: 'tree',
|
||||||
|
}),
|
||||||
|
operationId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { scheduled: true, operationId };
|
||||||
|
}
|
||||||
+21
-3
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
当前已经完成的是“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环,以及 M3.0–M3.4 商品领域基础、编辑器核心、AI 来源素材字段级合并、动态属性和缓存同步基础。数据库迁移、工作空间隔离、同商品归属外键、幂等键和 product/profile 双乐观锁已经落地。
|
||||||
|
|
||||||
当前没有完成的是“可直接用于发布”的闭环:候选素材选择/物化、定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
当前没有完成的是“可直接用于发布”的闭环:候选素材已经可以选择、转存并保存为 SKU 的平台素材,但定价确认、AI 任务与候选结果、Ozon 首次发布、在线商品同步仍未开发。真实站点回归也尚未由本项目完成;用户已确认此前提出的首次回归项基本满足采集箱和商品编辑需求,但仍建议在最终发布前保留一轮样本验收。
|
||||||
|
|
||||||
当前最高优先级是完成真实样本回归,然后进入候选素材选择/物化这一条发布前纵向能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
当前最高优先级是以真实来源样本回归素材转存与选择,然后进入定价确认能力。类目树改造已落地:Worker 使用显式指定的 Ozon 缓存源预热共享树,编辑器直接读取 Cascader 树,并会在空类目时按 Ozon 来源层级回填;详见 [1.28](#128-开发进度2026-09-10类目树预热与来源回填)。
|
||||||
|
|
||||||
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
接手前请先阅读 `git status --short`;当前 M3 商品编辑和 M3.4/M3.5 缓存改动均已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。
|
||||||
|
|
||||||
@@ -255,7 +255,25 @@
|
|||||||
3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。
|
3. 启动本地 PostgreSQL 与 Redis,执行 `022_store_catalog_cache.sql`,运行 API 集成测试并检查 RLS、外键和历史 profile 兼容性。
|
||||||
4. 使用真实已验证 Ozon 门店和样本,在 `http://localhost:8100` 回归:类目树回显、切换店铺、保存 UUID、来源回填、不覆盖手选类目以及属性/字典按需同步。
|
4. 使用真实已验证 Ozon 门店和样本,在 `http://localhost:8100` 回归:类目树回显、切换店铺、保存 UUID、来源回填、不覆盖手选类目以及属性/字典按需同步。
|
||||||
|
|
||||||
当前阻塞:本机 PostgreSQL 与 Redis 未运行,`apps/api/test/product-editor.e2e.test.ts` 连接报 `ECONNREFUSED`,因此 API 集成测试和迁移尚未实际验证。
|
### 1.30 开发进度(2026-09-11):M3.5 类目缓存可靠性收口
|
||||||
|
|
||||||
|
- 已使用专用 migrator 实际执行 `022_store_catalog_cache.sql`;三张门店类目缓存表均已创建,并实际检查了强制 RLS、`product_platform_profiles` 的门店类目外键及旧 `platform_product_category_id` 列的兼容保留。
|
||||||
|
- 门店凭据验证完成后,树同步调度现在以已锁定的门店行为临界区:已有 `synced_at` 缓存直接跳过;同一凭据的并发验证或重复事件只保留一条 queued/running 同步;凭据替换会取消旧任务并为新密钥安排新任务。队列载荷始终仅含门店、密钥记录、operation 与平台 ID,不含 API Key 明文。
|
||||||
|
- 新增 `apps/api/test/worker-catalog-scheduler.e2e.test.ts`,在真实 PostgreSQL 上覆盖空缓存的并发互斥、缓存跳过、无明文密钥 outbox 载荷,以及 BullMQ 的 5 次指数退避配置。现有 `product-editor.e2e.test.ts` 同时修复了 Ozon 来源测试数据必须将 `source_kind` 设为 `platform` 的约束问题。
|
||||||
|
- 已执行 contracts、API(含测试)、Worker TypeScript 检查,`product-editor.e2e.test.ts` 与 Worker 调度测试共 10 项通过,Web production build 通过。
|
||||||
|
- 真实浏览器回归:重新验证一个已有 Ozon 门店后,类目树同步 operation 成功,写入 568 个门店级类目;编辑器 Cascader 能加载完整树。再次验证同一门店后,类目树 operation 仍只有 1 条,确认有效缓存不会触发重复同步。已采集样本的公开来源层级与 Seller API 树不完全一致时,不会错误自动落类目,仍保留人工选择或 AI 推荐入口;这符合“仅精确路径匹配才自动回填”的保护规则。
|
||||||
|
|
||||||
|
M3.5 与 M3.6 均已收口。真实来源回归中若要提升 Ozon 自动类目命中率,需先确认是否可使用稳定的 Ozon 类目 ID 映射,不能用名称模糊匹配替代。
|
||||||
|
|
||||||
|
### 1.31 开发进度(2026-09-11):M3.6 候选素材选择与物化
|
||||||
|
|
||||||
|
- 新增迁移 `023_product_listing_media.sql`:最终图片归属 `SKU × 平台 listing`,而不是来源商品或门店全局;复合外键强制同一工作空间、商品、profile 与 SKU 的归属。每个 listing 最多 15 张,素材和排序均不可重复,表启用强制 RLS。
|
||||||
|
- 编辑器快照返回每个 SKU 已选的 Atlas 图片素材;草稿请求的 `media_asset_ids` 是最终顺序。服务端只接受当前采集快照中已经成功转存、且关系为该 SKU 或商品通用的候选。旧的已选资产在复采后仍可保留,不会因来源快照更新而丢失。
|
||||||
|
- 保存时二次校验 JPG/PNG/WebP、20MB、10000px、主图短边至少 700px、最多 15 张及去重。候选 `source_url` 只用于页面预览,发布事实永远是 `media_asset_id`,调用方无法引用任意工作空间素材。
|
||||||
|
- Web 在展开 SKU 后按来源关系显示候选图:`pending_selection` 显示“转存后选择”,已转存候选显示“选择 / 已选择”,选中后主图立即跟随第一个素材。复制 SKU 默认不复制来源绑定图片,避免把原 SKU 的专属候选误带到新 SKU。
|
||||||
|
- 已实际执行 `023`;`product-editor.e2e.test.ts` 8 项通过(含候选→正式素材顺序保存和读回);contracts、API、Web TypeScript 检查与 Web production build 通过。ego-browser 验证了真实编辑器的 SKU 展开、候选显示、选择状态和主图即时回显。浏览器手工草稿因未填类目、品牌、型号和包装规格被既有表单校验阻止提交;持久化路径由集成测试完成验证。
|
||||||
|
|
||||||
|
下一步:在真实采集来源中回归首次按需物化、Worker 下载失败重试与已选图片保存;然后实施定价确认。来源域限流、429/503 延迟重试和跨候选 URL 下载去重属于 Worker 下载器的后续可靠性加固,尚未在本 M3.6 变更中完成。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ export type ProductEditorSnapshot = {
|
|||||||
list_price: string | null;
|
list_price: string | null;
|
||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
};
|
};
|
||||||
|
/** Ordered final image assets for this platform listing. Source URLs
|
||||||
|
* are preview-only evidence and are never used for publishing. */
|
||||||
|
media: Array<{
|
||||||
|
asset_id: string;
|
||||||
|
content_type: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
byte_size: number;
|
||||||
|
width_px: number | null;
|
||||||
|
height_px: number | null;
|
||||||
|
source_url: string | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
attributes: Array<{
|
attributes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -391,6 +401,14 @@ export const saveProductEditorDraftRequestSchema = z
|
|||||||
physical_spec: variantPhysicalSpec,
|
physical_spec: variantPhysicalSpec,
|
||||||
package_spec: variantPackageSpec,
|
package_spec: variantPackageSpec,
|
||||||
offer_id: nullableText(500),
|
offer_id: nullableText(500),
|
||||||
|
media_asset_ids: z
|
||||||
|
.array(uuid)
|
||||||
|
.max(15)
|
||||||
|
.refine(
|
||||||
|
(ids) => new Set(ids).size === ids.length,
|
||||||
|
'SKU 图片不能重复',
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((variant, context) => {
|
.superRefine((variant, context) => {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- A platform listing owns the final, ordered image set. Keeping product_id,
|
||||||
|
-- profile_id and variant_id here makes the tenant and aggregate boundaries
|
||||||
|
-- enforceable even when a UUID is supplied by a client.
|
||||||
|
ALTER TABLE product_variant_platform_listings
|
||||||
|
ADD CONSTRAINT uq_variant_listing_aggregate
|
||||||
|
UNIQUE (workspace_id, id, product_id, profile_id, product_variant_id);
|
||||||
|
|
||||||
|
CREATE TABLE product_variant_listing_media (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL,
|
||||||
|
product_id UUID NOT NULL,
|
||||||
|
profile_id UUID NOT NULL,
|
||||||
|
listing_id UUID NOT NULL,
|
||||||
|
product_variant_id UUID NOT NULL,
|
||||||
|
asset_id UUID NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL CHECK (sort_order >= 0 AND sort_order < 15),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, listing_id, product_id, profile_id, product_variant_id)
|
||||||
|
REFERENCES product_variant_platform_listings(workspace_id, id, product_id, profile_id, product_variant_id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, asset_id)
|
||||||
|
REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, id),
|
||||||
|
UNIQUE (workspace_id, listing_id, sort_order),
|
||||||
|
UNIQUE (workspace_id, listing_id, asset_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_product_variant_listing_media_profile
|
||||||
|
ON product_variant_listing_media (workspace_id, profile_id, product_variant_id, sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE product_variant_listing_media ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE product_variant_listing_media FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY workspace_isolation ON product_variant_listing_media
|
||||||
|
USING (workspace_id = app_current_workspace_id())
|
||||||
|
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||||
Reference in New Issue
Block a user