feat(edit): 编辑上报细节优化
This commit is contained in:
@@ -167,7 +167,7 @@ export class CollectionService {
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
}>(
|
||||
`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`,
|
||||
`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND deleted_at IS NULL ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`,
|
||||
[id, source.rows[0]?.source_snapshot_revision ?? 1],
|
||||
);
|
||||
return {
|
||||
@@ -280,7 +280,7 @@ export class CollectionService {
|
||||
const result = await tx.query<{
|
||||
source_snapshot_revision: string | number;
|
||||
}>(
|
||||
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`,
|
||||
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`,
|
||||
[id, input.transfer_ids],
|
||||
);
|
||||
if (result.rowCount !== input.transfer_ids.length)
|
||||
@@ -328,7 +328,7 @@ export class CollectionService {
|
||||
const result = await tx.query<{
|
||||
source_snapshot_revision: string | number;
|
||||
}>(
|
||||
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`,
|
||||
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.deleted_at IS NULL AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`,
|
||||
[id, input.transfer_ids],
|
||||
);
|
||||
if (result.rowCount !== input.transfer_ids.length)
|
||||
|
||||
@@ -87,9 +87,13 @@ export class ExtensionService {
|
||||
],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
[item.rows[0].id, payload.common.title?.value ?? null],
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title, purchase_url)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3) RETURNING id`,
|
||||
[
|
||||
item.rows[0].id,
|
||||
payload.common.title?.value ?? null,
|
||||
payload.source.canonicalUrl,
|
||||
],
|
||||
);
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
@@ -449,21 +453,24 @@ export class ExtensionService {
|
||||
const status = await tx.query<{
|
||||
status: string;
|
||||
source_title: string | null;
|
||||
variant_count: string;
|
||||
}>(
|
||||
'SELECT status, source_title FROM atlas.collection_items WHERE id = $1',
|
||||
[collectionItemId],
|
||||
`SELECT status, source_title,
|
||||
(SELECT count(*)::text FROM atlas.product_variants WHERE product_id=$2 AND deleted_at IS NULL) AS variant_count
|
||||
FROM atlas.collection_items WHERE id=$1`,
|
||||
[collectionItemId, productId],
|
||||
);
|
||||
return {
|
||||
collection_item: {
|
||||
id: collectionItemId,
|
||||
status: status.rows[0]?.status ?? 'received',
|
||||
source_title: status.rows[0]?.source_title ?? null,
|
||||
variant_count: 0,
|
||||
variant_count: Number(status.rows[0]?.variant_count ?? 0),
|
||||
},
|
||||
product_id: productId,
|
||||
reused,
|
||||
refreshed,
|
||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||
editor_entry_path: `/products/${productId}/editor?collection_item_id=${collectionItemId}`,
|
||||
materialization: {
|
||||
total: Number(count.rows[0]?.total ?? 0),
|
||||
pending_selection: Number(
|
||||
|
||||
@@ -47,7 +47,7 @@ export class AspectRatioDerivativeService {
|
||||
async (tx) => {
|
||||
const source = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets
|
||||
WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||
AND height_px BETWEEN 1 AND 10000`,
|
||||
[assetId],
|
||||
@@ -107,14 +107,14 @@ export class AspectRatioDerivativeService {
|
||||
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at AS asset_created_at
|
||||
FROM atlas.media_aspect_ratio_derivatives d
|
||||
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id
|
||||
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||
WHERE d.id = $1`,
|
||||
[derivativeId],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
});
|
||||
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片处理任务不存在');
|
||||
const access = row.output_asset_id
|
||||
const access = row.output_asset_id && row.content_type
|
||||
? await this.media.accessUrl(workspaceId, row.output_asset_id)
|
||||
: null;
|
||||
return {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ImageSuiteService {
|
||||
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
|
||||
);
|
||||
const assets = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[])
|
||||
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
|
||||
[request.reference_asset_ids],
|
||||
|
||||
@@ -44,7 +44,7 @@ export class MediaAiEditService {
|
||||
if (new Set(allIds).size !== allIds.length)
|
||||
throw new ApiError(422, 'INVALID_REFERENCE_IMAGE', '主图和辅助图不能重复');
|
||||
const assets = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[])
|
||||
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||
AND height_px BETWEEN 1 AND 10000`, [allIds],
|
||||
@@ -88,13 +88,13 @@ export class MediaAiEditService {
|
||||
a.created_at AS asset_created_at
|
||||
FROM atlas.media_ai_edits e
|
||||
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id
|
||||
LEFT JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
|
||||
WHERE e.id = $1`, [id],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
});
|
||||
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
|
||||
const access = row.output_asset_id ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
|
||||
const access = row.output_asset_id && row.content_type ? await this.media.accessUrl(workspaceId, row.output_asset_id) : null;
|
||||
return {
|
||||
id: row.id, status: row.status, source_asset_id: row.source_asset_id, error_code: row.error_code,
|
||||
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
|
||||
@@ -118,7 +118,7 @@ export class MediaAiEditService {
|
||||
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at AS asset_created_at
|
||||
FROM atlas.media_ai_edits e
|
||||
JOIN atlas.media_assets a ON a.id = e.output_asset_id
|
||||
JOIN atlas.media_assets a ON a.id = e.output_asset_id AND a.deleted_at IS NULL
|
||||
WHERE e.status = 'succeeded' AND e.product_id = $1
|
||||
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProductImageDeletionService } from './product-image-deletion.service.js';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
deleteProductImageRequestSchema,
|
||||
completeMediaUploadIntentRequestSchema,
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
@@ -47,6 +49,7 @@ import { ImageSuiteService } from './image-suite.service.js';
|
||||
@Controller()
|
||||
export class MediaController {
|
||||
public constructor(
|
||||
@Inject(ProductImageDeletionService) private readonly imageDeletions: ProductImageDeletionService,
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
@Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService,
|
||||
@Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService,
|
||||
@@ -56,6 +59,29 @@ export class MediaController {
|
||||
@Inject(ImageSuiteService) private readonly suites: ImageSuiteService,
|
||||
) {}
|
||||
|
||||
@Post('products/:productId/image-deletions')
|
||||
public async deleteProductImage(@Param('productId') productId: string, @Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
|
||||
const result = await this.imageDeletions.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(deleteProductImageRequestSchema, body));
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
@Get('products/:productId/image-deletions')
|
||||
public async pendingImageDeletions(@Param('productId') productId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.imageDeletions.pending(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
@Get('products/:productId/image-deletions/:deletionId')
|
||||
public async readImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.imageDeletions.read(workspace.id, uuid('product_id', productId), uuid('deletion_id', id)) };
|
||||
}
|
||||
@Post('products/:productId/image-deletions/:deletionId/retry')
|
||||
public async retryImageDeletion(@Param('productId') productId: string, @Param('deletionId') id: string,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: { status: (code: number) => void }) {
|
||||
const result = await this.imageDeletions.retry(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('deletion_id', id), idempotencyKey(key));
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/suite-plans')
|
||||
public async planSuite(@Param('productId') productId: string, @Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@@ -214,15 +240,15 @@ export class MediaController {
|
||||
}
|
||||
|
||||
@Get('media-assets/product-uploads')
|
||||
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
|
||||
public async productUploads(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.media.productUploads(workspace.id) };
|
||||
@ApiOkResponse({ description: '当前商品最近上传的图片及短时预览地址' })
|
||||
public async productUploads(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) {
|
||||
return { data: await this.media.productUploads(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Get('media-assets/product-imports')
|
||||
@ApiOkResponse({ description: '最近导入的网络商品图片及短时预览地址' })
|
||||
public async productImports(@CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.media.productImports(workspace.id) };
|
||||
@ApiOkResponse({ description: '当前商品最近导入的网络图片及短时预览地址' })
|
||||
public async productImports(@CurrentWorkspace() workspace: Workspace, @Query('product_id') productId: string) {
|
||||
return { data: await this.media.productImports(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Post('media-assets/product-imports')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProductImageDeletionService } from './product-image-deletion.service.js';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { MediaController } from './media.controller.js';
|
||||
@@ -11,7 +12,7 @@ import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||
providers: [ProductImageDeletionService, IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -100,6 +100,10 @@ export class MediaService {
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
if (input.purpose === 'product_image') {
|
||||
if (!input.product_id) throw new ApiError(422, 'VALIDATION_FAILED', '商品图片必须绑定商品');
|
||||
await this.requireProduct(tx, input.product_id);
|
||||
}
|
||||
await this.ensureBucket();
|
||||
const id = randomUUID();
|
||||
const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`;
|
||||
@@ -107,9 +111,9 @@ export class MediaService {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id, product_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
|
||||
$3, $4, $5, $6, $7, $8, $9)`,
|
||||
$3, $4, $5, $6, $7, $8, $9, $11)`,
|
||||
[
|
||||
id,
|
||||
input.purpose,
|
||||
@@ -121,6 +125,7 @@ export class MediaService {
|
||||
expiresAt,
|
||||
userId,
|
||||
this.config.OBJECT_STORAGE_PROVIDER,
|
||||
input.product_id ?? null,
|
||||
],
|
||||
);
|
||||
const url = await getSignedUrl(
|
||||
@@ -295,7 +300,7 @@ export class MediaService {
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
`SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
@@ -315,18 +320,19 @@ export class MediaService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string) {
|
||||
/** 返回当前商品最近上传的素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string, productId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const uploads = await tx.query<AssetRow & { storage_provider: string; bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
|
||||
FROM atlas.media_assets a
|
||||
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
|
||||
WHERE i.purpose = 'product_image' AND i.status = 'ready'
|
||||
WHERE i.purpose = 'product_image' AND i.status = 'ready' AND i.product_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY a.id
|
||||
ORDER BY uploaded_at DESC, a.id DESC
|
||||
LIMIT 100`,
|
||||
[productId],
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
return Promise.all(uploads.rows.map(async (asset) => ({
|
||||
@@ -354,6 +360,7 @@ export class MediaService {
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input },
|
||||
async (tx) => {
|
||||
await this.requireProduct(tx, input.product_id);
|
||||
const image = await downloadProductImage(input.url);
|
||||
const checksum = createHash('sha256').update(image.bytes).digest('hex');
|
||||
const existing = await tx.query<{ id: string }>(
|
||||
@@ -363,7 +370,7 @@ export class MediaService {
|
||||
let assetId = existing.rows[0]?.id ?? randomUUID();
|
||||
if (!existing.rows[0]) {
|
||||
await this.ensureBucket();
|
||||
const objectKey = `product-imports/${workspaceId}/${checksum}`;
|
||||
const objectKey = `product-imports/${workspaceId}/${checksum}/${randomUUID()}`;
|
||||
await this.s3.send(new PutObjectCommand({
|
||||
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||
Key: objectKey,
|
||||
@@ -388,11 +395,11 @@ export class MediaService {
|
||||
}
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_imports
|
||||
(workspace_id, source_url, asset_id, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
|
||||
ON CONFLICT (workspace_id, source_url)
|
||||
(workspace_id, source_url, asset_id, created_by_user_id, product_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, product_id, source_url)
|
||||
DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`,
|
||||
[input.url, assetId, userId],
|
||||
[input.url, assetId, userId, input.product_id],
|
||||
);
|
||||
const asset = await this.requireAsset(tx, assetId);
|
||||
const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
||||
@@ -410,7 +417,7 @@ export class MediaService {
|
||||
);
|
||||
}
|
||||
|
||||
public async productImports(workspaceId: string) {
|
||||
public async productImports(workspaceId: string, productId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const imports = await tx.query<AssetRow & {
|
||||
storage_provider: string;
|
||||
@@ -423,11 +430,13 @@ export class MediaService {
|
||||
FROM (
|
||||
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
|
||||
FROM atlas.product_image_imports
|
||||
WHERE product_id = $1
|
||||
ORDER BY asset_id, updated_at DESC, id DESC
|
||||
) i
|
||||
JOIN atlas.media_assets a ON a.id = i.asset_id
|
||||
JOIN atlas.media_assets a ON a.id = i.asset_id AND a.deleted_at IS NULL
|
||||
ORDER BY i.updated_at DESC, i.id DESC
|
||||
LIMIT 100`,
|
||||
[productId],
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
return Promise.all(imports.rows.map(async (asset) => ({
|
||||
@@ -445,6 +454,11 @@ export class MediaService {
|
||||
});
|
||||
}
|
||||
|
||||
private async requireProduct(tx: DatabaseTransaction, productId: string) {
|
||||
const result = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
|
||||
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
}
|
||||
|
||||
private async lockIntent(
|
||||
tx: DatabaseTransaction,
|
||||
intentId: string,
|
||||
@@ -463,7 +477,7 @@ export class MediaService {
|
||||
private async requireAsset(tx: DatabaseTransaction, assetId: string) {
|
||||
const rows = await tx.query<AssetRow>(
|
||||
`SELECT id, content_type, byte_size, width_px, height_px, created_at
|
||||
FROM atlas.media_assets WHERE id = $1`,
|
||||
FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[assetId],
|
||||
);
|
||||
if (!rows.rows[0])
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { DeleteProductImageRequest, ProductImageDeletion, Operation } from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type StoredAsset = { id: string; deleted_at: string | null; storage_provider: string; bucket: string; object_key: string };
|
||||
@Injectable()
|
||||
export class ProductImageDeletionService {
|
||||
constructor(@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService) {}
|
||||
|
||||
/** 先锁素材和校验全部引用,再在同一事务禁用候选并登记可靠清理任务。 */
|
||||
async create(workspaceId: string, userId: string, productId: string, key: string, input: DeleteProductImageRequest) {
|
||||
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions`, key, request: input }, async tx => {
|
||||
const product = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
|
||||
if (!product.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
let assetId = input.asset_id ?? null;
|
||||
if (input.collection_image_id) {
|
||||
const source = await tx.query<{ asset_id: string | null; status: string }>(
|
||||
'SELECT asset_id, status FROM atlas.collection_asset_transfers WHERE id = $1 AND product_id = $2 FOR UPDATE', [input.collection_image_id, productId]);
|
||||
if (!source.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集图片不存在');
|
||||
if (source.rows[0].status === 'running') throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片正在转存,请完成后再删除');
|
||||
assetId = source.rows[0].asset_id;
|
||||
}
|
||||
const existing = await tx.query<ProductImageDeletion>(
|
||||
`SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions
|
||||
WHERE product_id = $1 AND (asset_id = $2 OR collection_image_id = $3)`, [productId, assetId, input.collection_image_id ?? null]);
|
||||
if (existing.rows.length) return { status: 202, body: { data: await this.withOperation(tx, existing.rows[0]) } };
|
||||
let objects: Array<{ storage_provider: string; bucket: string; object_key: string }> = [];
|
||||
if (assetId) {
|
||||
const result = await tx.query<StoredAsset>('SELECT id, deleted_at, storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1 FOR UPDATE', [assetId]);
|
||||
const asset = result.rows[0];
|
||||
if (!asset || asset.deleted_at) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不存在或已删除');
|
||||
const owned = await tx.query(`SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id = $2 AND purpose = 'product_image' AND status = 'ready'
|
||||
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id = $2
|
||||
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id = $2
|
||||
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE output_asset_id = $1 AND product_id = $2
|
||||
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE output_asset_id = $1 AND product_id = $2
|
||||
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE output_asset_id = $1 AND product_id = $2 LIMIT 1`, [assetId, productId]);
|
||||
if (!owned.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片不属于当前商品');
|
||||
await this.requireUnused(tx, assetId, productId);
|
||||
const uploads = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
||||
"SELECT storage_provider, bucket, object_key FROM atlas.media_upload_intents WHERE asset_id = $1 AND status = 'ready'", [assetId]);
|
||||
objects = [asset, ...uploads.rows].map(({storage_provider, bucket, object_key}) => ({storage_provider, bucket, object_key}));
|
||||
objects = objects.filter((v, i) => objects.findIndex(o => o.storage_provider === v.storage_provider && o.bucket === v.bucket && o.object_key === v.object_key) === i);
|
||||
await tx.query('UPDATE atlas.media_assets SET deleted_at = now(), deleted_checksum_sha256 = checksum_sha256, checksum_sha256 = NULL WHERE id = $1', [assetId]);
|
||||
await tx.query('UPDATE atlas.collection_asset_transfers SET deleted_at = now() WHERE asset_id = $1 AND product_id = $2', [assetId, productId]);
|
||||
}
|
||||
if (input.collection_image_id) await tx.query(
|
||||
"UPDATE atlas.collection_asset_transfers SET deleted_at = now(), status = CASE WHEN status IN ('queued', 'pending_selection') THEN 'cancelled'::atlas.processing_status ELSE status END WHERE id = $1", [input.collection_image_id]);
|
||||
const id = randomUUID();
|
||||
await tx.query(`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id, status, completed_at)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'product_image_deletion', 'product_image_deletion', $1, $2,
|
||||
$3::atlas.operation_status, CASE WHEN $3 = 'succeeded' THEN now() ELSE NULL END)`, [id, userId, objects.length ? 'queued' : 'succeeded']);
|
||||
const deletion = await tx.query<ProductImageDeletion>(`INSERT INTO atlas.product_image_deletions
|
||||
(id, workspace_id, product_id, asset_id, collection_image_id, objects, status, completed_at)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5::jsonb, $6,
|
||||
CASE WHEN $6 = 'succeeded' THEN now() ELSE NULL END)
|
||||
RETURNING id, status, asset_id, collection_image_id, error_code`,
|
||||
[id, productId, assetId, input.collection_image_id ?? null, JSON.stringify(objects), objects.length ? 'queued' : 'succeeded']);
|
||||
if (objects.length) await this.enqueue(tx, id, randomUUID());
|
||||
return { status: 202, body: { data: await this.withOperation(tx, deletion.rows[0]) } };
|
||||
});
|
||||
}
|
||||
async pending(workspaceId: string, productId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async tx => {
|
||||
const rows = await tx.query<ProductImageDeletion>("SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE product_id = $1 AND status <> 'succeeded' ORDER BY created_at DESC LIMIT 100", [productId]);
|
||||
return Promise.all(rows.rows.map(row => this.withOperation(tx, row)));
|
||||
});
|
||||
}
|
||||
async read(workspaceId: string, productId: string, id: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async tx => {
|
||||
const result = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2', [id, productId]);
|
||||
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
|
||||
return this.withOperation(tx, result.rows[0]);
|
||||
});
|
||||
}
|
||||
async retry(workspaceId: string, userId: string, productId: string, id: string, key: string) {
|
||||
return this.idempotency.run({workspaceId, userId, method: 'POST', path: `/api/products/${productId}/image-deletions/${id}/retry`, key, request: {}}, async tx => {
|
||||
const row = await tx.query<ProductImageDeletion>('SELECT id, status, asset_id, collection_image_id, error_code FROM atlas.product_image_deletions WHERE id = $1 AND product_id = $2 FOR UPDATE', [id, productId]);
|
||||
if (!row.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片删除记录不存在');
|
||||
if (row.rows[0].status === 'failed') {
|
||||
await tx.query("UPDATE atlas.product_image_deletions SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1", [id]);
|
||||
await tx.query("UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1", [id]);
|
||||
await this.enqueue(tx, id, randomUUID());
|
||||
row.rows[0].status = 'queued'; row.rows[0].error_code = null;
|
||||
}
|
||||
return {status: 202, body: {data: await this.withOperation(tx, row.rows[0])}};
|
||||
});
|
||||
}
|
||||
private async withOperation(tx: DatabaseTransaction, row: ProductImageDeletion): Promise<ProductImageDeletion> {
|
||||
const result = await tx.query<Operation>(`SELECT id, kind, status, created_at::text, completed_at::text,
|
||||
CASE WHEN error_code IS NULL THEN NULL ELSE jsonb_build_object('code', error_code, 'message', error_message) END AS error
|
||||
FROM atlas.operations WHERE id = $1`, [row.id]);
|
||||
const operation = result.rows[0];
|
||||
return { ...row, operation: { ...operation,
|
||||
created_at: new Date(operation.created_at).toISOString(),
|
||||
completed_at: operation.completed_at ? new Date(operation.completed_at).toISOString() : null,
|
||||
} };
|
||||
}
|
||||
private async enqueue(tx: DatabaseTransaction, id: string, attempt: string) {
|
||||
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, 'product_image_deletion', $1, 'media.asset_delete.requested', jsonb_build_object('deletion_id', $1::uuid), $2)`, [id, attempt]);
|
||||
}
|
||||
private async requireUnused(tx: DatabaseTransaction, assetId: string, productId: string) {
|
||||
const refs = await tx.query(`SELECT 1 FROM atlas.product_variant_listing_media WHERE asset_id = $1
|
||||
UNION ALL SELECT 1 FROM atlas.watermark_template_versions WHERE image_asset_id = $1
|
||||
UNION ALL SELECT 1 FROM atlas.users WHERE avatar_asset_id = $1
|
||||
UNION ALL SELECT 1 FROM atlas.media_upload_intents WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
|
||||
UNION ALL SELECT 1 FROM atlas.product_image_imports WHERE asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid
|
||||
UNION ALL SELECT 1 FROM atlas.collection_asset_transfers WHERE asset_id = $1 AND product_id <> $2
|
||||
UNION ALL SELECT 1 FROM atlas.media_ai_edits WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||
OR (status IN ('queued','running') AND (source_asset_id = $1 OR $1 = ANY(reference_asset_ids)))
|
||||
UNION ALL SELECT 1 FROM atlas.media_aspect_ratio_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||
OR (status IN ('queued','running') AND source_asset_id = $1)
|
||||
UNION ALL SELECT 1 FROM atlas.media_watermark_derivatives WHERE (output_asset_id = $1 AND product_id IS DISTINCT FROM $2::uuid)
|
||||
OR (status IN ('queued','running') AND source_asset_id = $1)
|
||||
UNION ALL SELECT 1 FROM atlas.product_image_suite_references r JOIN atlas.product_image_suites s ON s.id = r.suite_id
|
||||
WHERE r.asset_id = $1 AND s.status IN ('queued','running') LIMIT 1`, [assetId, productId]);
|
||||
if (refs.rows.length) throw new ApiError(409, 'MEDIA_ASSET_IN_USE', '图片已被 SKU、其他商品或图片任务使用,请先移除引用并保存后再删除');
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export class ProductImageDerivativeHistoryService {
|
||||
FROM atlas.media_watermark_derivatives
|
||||
WHERE product_id = $1 AND status = 'succeeded'
|
||||
) d
|
||||
JOIN atlas.media_assets a ON a.id = d.output_asset_id
|
||||
JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||
ORDER BY d.completed_at DESC, d.id DESC
|
||||
LIMIT 100`,
|
||||
[productId],
|
||||
|
||||
@@ -20,6 +20,7 @@ type AiEditTaskRow = {
|
||||
total?: number;
|
||||
completed?: number;
|
||||
output_asset_ids?: string[];
|
||||
active_output_asset_ids?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -44,7 +45,8 @@ export class ProductImageTaskService {
|
||||
}
|
||||
params.push(query.limit + 1);
|
||||
const edits = await tx.query<AiEditTaskRow>(
|
||||
`SELECT e.* FROM (
|
||||
`SELECT e.*, ARRAY(SELECT a.id FROM unnest(e.output_asset_ids) WITH ORDINALITY refs(id, position)
|
||||
JOIN atlas.media_assets a ON a.id = refs.id AND a.deleted_at IS NULL ORDER BY refs.position) AS active_output_asset_ids FROM (
|
||||
SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
|
||||
o.result ->> 'provider_error_raw' AS provider_error_raw,
|
||||
o.result ->> 'failure_source' AS failure_source,
|
||||
@@ -85,7 +87,7 @@ export class ProductImageTaskService {
|
||||
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
|
||||
total: row.total ?? 1,
|
||||
completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0),
|
||||
output_asset_ids: row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []),
|
||||
output_asset_ids: row.active_output_asset_ids ?? row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []),
|
||||
error_code: row.error_code,
|
||||
failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null,
|
||||
failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null,
|
||||
|
||||
@@ -47,7 +47,7 @@ export class WatermarkDerivativeService {
|
||||
}, async (tx) => {
|
||||
const source = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets
|
||||
WHERE id = $1 AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
WHERE id = $1 AND deleted_at IS NULL AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
||||
AND height_px BETWEEN 1 AND 10000`,
|
||||
[assetId],
|
||||
@@ -112,14 +112,14 @@ export class WatermarkDerivativeService {
|
||||
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at AS asset_created_at
|
||||
FROM atlas.media_watermark_derivatives d
|
||||
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id
|
||||
LEFT JOIN atlas.media_assets a ON a.id = d.output_asset_id AND a.deleted_at IS NULL
|
||||
WHERE d.id = $1`,
|
||||
[derivativeId],
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
});
|
||||
if (!row) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印处理任务不存在');
|
||||
const access = row.output_asset_id
|
||||
const access = row.output_asset_id && row.content_type
|
||||
? await this.media.accessUrl(workspaceId, row.output_asset_id)
|
||||
: null;
|
||||
return {
|
||||
|
||||
@@ -327,20 +327,26 @@ export class PricingService {
|
||||
'PROFILE_NOT_FOUND',
|
||||
'商品发布配置不存在',
|
||||
);
|
||||
if (
|
||||
binding.variant_id &&
|
||||
!(
|
||||
await tx.query(
|
||||
`SELECT l.id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND l.status<>'imported' AND v.deleted_at IS NULL`,
|
||||
if (binding.variant_id) {
|
||||
const listing = (
|
||||
await tx.query<{ id: string; status: string; store_id: string }>(
|
||||
`SELECT l.id,l.status,l.store_id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND v.deleted_at IS NULL`,
|
||||
[binding.profile_id, binding.variant_id],
|
||||
)
|
||||
).rowCount
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRICING_VARIANT_UNAVAILABLE',
|
||||
'SKU 不存在或已经上架',
|
||||
);
|
||||
).rows[0];
|
||||
if (!listing)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRICING_VARIANT_UNAVAILABLE',
|
||||
'SKU 不存在或已删除',
|
||||
);
|
||||
if (listing.status === 'imported' && listing.store_id !== store.id)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRICING_VARIANT_STORE_MISMATCH',
|
||||
'已上架 SKU 请按首次导入店铺试算',
|
||||
);
|
||||
}
|
||||
}
|
||||
const rules = await this.rules(tx, input, store),
|
||||
exchange = await this.exchange(tx);
|
||||
@@ -633,14 +639,16 @@ export class PricingService {
|
||||
await tx.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
store_id: string;
|
||||
pricing_draft: PricingParameters | null;
|
||||
pricing_quote_id: string | null;
|
||||
}>(
|
||||
`SELECT id,status,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
|
||||
`SELECT id,status,store_id,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
|
||||
[profileId, variantId],
|
||||
)
|
||||
).rows[0];
|
||||
if (listing.status === 'imported') continue;
|
||||
// 已上架 SKU 只有显式应用新报价时更新本地记录,普通草稿保存保留历史报价。
|
||||
if (listing.status === 'imported' && !v.pricing_selection) continue;
|
||||
const state = (
|
||||
await tx.query<{
|
||||
product_id: string;
|
||||
@@ -652,8 +660,12 @@ export class PricingService {
|
||||
purchase_cost_cny: string;
|
||||
package: PricingPreviewRequest['package'];
|
||||
}>(
|
||||
`SELECT v.product_id,p.platform_id,p.target_store_id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=p.target_store_id WHERE v.id=$2`,
|
||||
[profileId, variantId],
|
||||
`SELECT v.product_id,p.platform_id,s.id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=coalesce($3::uuid,p.target_store_id) WHERE v.id=$2`,
|
||||
[
|
||||
profileId,
|
||||
variantId,
|
||||
listing.status === 'imported' ? listing.store_id : null,
|
||||
],
|
||||
)
|
||||
).rows[0];
|
||||
const params =
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ProductRow = {
|
||||
model: string | null;
|
||||
barcode: string | null;
|
||||
purchase_url: string | null;
|
||||
source_url: string | null;
|
||||
supply_note: string | null;
|
||||
default_physical_length_mm: string | null;
|
||||
default_physical_width_mm: string | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { productAttributeValueRules } from '@atlas/contracts';
|
||||
import type {
|
||||
OzonPublishItemSnapshot,
|
||||
ProductAttributeSchema,
|
||||
@@ -325,11 +326,12 @@ export class ProductEditorPublishOperations {
|
||||
// Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。
|
||||
if (definition.editor_field === 'tags' && values.length)
|
||||
values = [values.join(' ')];
|
||||
if (values.length > definition.max_value_count)
|
||||
const { maxValueCount } = productAttributeValueRules(definition);
|
||||
if (values.length > maxValueCount)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
`${definition.name}最多 ${definition.max_value_count} 个值`,
|
||||
`${definition.name}最多 ${maxValueCount} 个值`,
|
||||
[
|
||||
{
|
||||
field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PricingService } from '../pricing/pricing.service.js';
|
||||
import type { PricingParameters, PricingOption } from '@atlas/contracts';
|
||||
import { productAttributeValueRules } from '@atlas/contracts';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
ProductEditorContext,
|
||||
@@ -229,6 +230,7 @@ export class ProductEditorService {
|
||||
sort_order: number;
|
||||
purchase_cost_cny: string | null;
|
||||
listing_id: string;
|
||||
listing_store_id: string;
|
||||
offer_id: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
@@ -256,7 +258,7 @@ export class ProductEditorService {
|
||||
}>(
|
||||
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
|
||||
v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
|
||||
l.title, l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.store_id AS listing_store_id, l.title, l.description, l.tags, l.status, l.selling_price::text,
|
||||
l.list_price::text, l.currency_code, l.pricing_draft, l.pricing_valid,
|
||||
(SELECT calculation_snapshot FROM atlas.pricing_quote_options o JOIN atlas.pricing_quotes q ON q.id=o.quote_id AND q.selected_option_id=o.id WHERE q.id=l.pricing_quote_id) AS pricing_result, v.specification_values,
|
||||
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
|
||||
@@ -417,6 +419,7 @@ export class ProductEditorService {
|
||||
),
|
||||
listing: {
|
||||
id: variant.listing_id,
|
||||
store_id: variant.listing_store_id,
|
||||
offer_id: variant.offer_id,
|
||||
status: variant.status,
|
||||
pricing_draft: variant.pricing_draft,
|
||||
@@ -438,7 +441,8 @@ export class ProductEditorService {
|
||||
brand: product.brand,
|
||||
model: product.model,
|
||||
barcode: product.barcode,
|
||||
purchase_url: product.purchase_url,
|
||||
// 历史草稿未填写货源链接时沿用采集页面,手工链接始终优先。
|
||||
purchase_url: product.purchase_url || product.source_url,
|
||||
supply_note: product.supply_note,
|
||||
},
|
||||
default_physical_spec: defaultPhysicalSpec,
|
||||
@@ -1377,7 +1381,7 @@ export class ProductEditorService {
|
||||
height_px: number | null;
|
||||
}>(
|
||||
`SELECT id, content_type, byte_size::text, width_px, height_px
|
||||
FROM atlas.media_assets WHERE id = ANY($1::uuid[])`,
|
||||
FROM atlas.media_assets WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
|
||||
[assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
@@ -1424,16 +1428,16 @@ export class ProductEditorService {
|
||||
? await transaction.query<{ asset_id: string }>(
|
||||
`SELECT DISTINCT asset_id FROM atlas.media_upload_intents
|
||||
WHERE purpose = 'product_image' AND status = 'ready'
|
||||
AND asset_id = ANY($1::uuid[])`,
|
||||
[assetIds],
|
||||
AND product_id = $1 AND asset_id = ANY($2::uuid[])`,
|
||||
[input.product.id, assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const uploadedIds = new Set(uploaded.rows.map((item) => item.asset_id));
|
||||
const imported = assetIds.length
|
||||
? await transaction.query<{ asset_id: string }>(
|
||||
`SELECT DISTINCT asset_id FROM atlas.product_image_imports
|
||||
WHERE asset_id = ANY($1::uuid[])`,
|
||||
[assetIds],
|
||||
WHERE product_id = $1 AND asset_id = ANY($2::uuid[])`,
|
||||
[input.product.id, assetIds],
|
||||
)
|
||||
: { rows: [] };
|
||||
const importedIds = new Set(imported.rows.map((item) => item.asset_id));
|
||||
@@ -1810,10 +1814,7 @@ export class ProductEditorService {
|
||||
values: string[],
|
||||
unchanged: boolean,
|
||||
) {
|
||||
// 商品颜色允许多选;该用户输入规则优先于部分 Ozon 类目 schema 的单值标记。
|
||||
const isProductColor = definition.name.trim() === '商品颜色';
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor;
|
||||
const maxValueCount = isProductColor ? 100 : definition.max_value_count;
|
||||
const { allowsMultipleValues, maxValueCount } = productAttributeValueRules(definition);
|
||||
if (
|
||||
values.length > maxValueCount ||
|
||||
(!allowsMultipleValues && values.length !== 1)
|
||||
@@ -2401,7 +2402,7 @@ export class ProductEditorService {
|
||||
p.physical_spec_status, p.package_spec_status,
|
||||
p.package_weight_mode, p.package_dimensions_mode,
|
||||
p.ai_source_material_overrides, p.ai_source_material_base_revision,
|
||||
c.normalized_payload, c.source_snapshot_revision
|
||||
c.source_url, c.normalized_payload, c.source_snapshot_revision
|
||||
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
|
||||
WHERE c.id = $1 AND c.deleted_at IS NULL`,
|
||||
[collectionItemId],
|
||||
@@ -2427,7 +2428,7 @@ export class ProductEditorService {
|
||||
p.physical_spec_status, p.package_spec_status,
|
||||
p.package_weight_mode, p.package_dimensions_mode,
|
||||
p.ai_source_material_overrides, p.ai_source_material_base_revision,
|
||||
c.normalized_payload, c.source_snapshot_revision
|
||||
c.source_url, c.normalized_payload, c.source_snapshot_revision
|
||||
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
|
||||
WHERE p.id = $1 AND c.deleted_at IS NULL${lockSourceSnapshot ? ' FOR UPDATE OF c' : ''}`,
|
||||
[productId],
|
||||
|
||||
@@ -6,12 +6,14 @@ 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 { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let webAccessToken = '';
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
redirect: 'manual',
|
||||
@@ -124,7 +126,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(registration.response.status, 201);
|
||||
userId = registration.body.data.user.id;
|
||||
workspaceId = registration.body.data.workspace.id;
|
||||
const webAccessToken = registration.body.data.access_token as string;
|
||||
webAccessToken = registration.body.data.access_token as string;
|
||||
const session = await request('/api/extension/session', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
@@ -166,11 +168,12 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
],
|
||||
evidence: [{ ref: 'page', kind: 'page', path: 'test' }],
|
||||
};
|
||||
const firstKey = randomUUID();
|
||||
const first = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'Idempotency-Key': firstKey,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -179,6 +182,22 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||
assert.equal(first.body.data.materialization.queued, 0);
|
||||
const editorEntry = new URL(first.body.data.editor_entry_path, 'http://atlas.example.test');
|
||||
assert.equal(editorEntry.pathname, `/products/${first.body.data.product_id}/editor`);
|
||||
assert.equal(editorEntry.searchParams.get('collection_item_id'), first.body.data.collection_item.id);
|
||||
const replay = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': firstKey,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.deepEqual(replay.body, first.body);
|
||||
const profilesBeforeEntry = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query('SELECT id FROM atlas.product_platform_profiles WHERE product_id=$1', [first.body.data.product_id]),
|
||||
);
|
||||
assert.equal(profilesBeforeEntry.rowCount, 0);
|
||||
const source1688 = {
|
||||
source: {
|
||||
platform: '1688', itemKey: '1688-price-image-test',
|
||||
@@ -260,7 +279,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
const editorContext = await request(
|
||||
`/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`,
|
||||
`/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -271,6 +290,39 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(editorContext.response.status, 201);
|
||||
assert.equal(editorContext.body.data.product_id, first.body.data.product_id);
|
||||
assert.equal(editorContext.body.data.editor_path, editorEntry.pathname);
|
||||
const readEditor = (productId: string) => request(
|
||||
`/api/products/${productId}/editor`,
|
||||
{ headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
} },
|
||||
);
|
||||
const initialEditor = await readEditor(first.body.data.product_id);
|
||||
assert.equal(initialEditor.response.status, 200);
|
||||
assert.equal(initialEditor.body.data.product.common.purchase_url, payload.source.canonicalUrl);
|
||||
const sourceLink = await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
'SELECT purchase_url FROM atlas.products WHERE id=$1',
|
||||
[captured1688.body.data.product_id],
|
||||
));
|
||||
assert.equal(sourceLink.rows[0].purchase_url, source1688.source.canonicalUrl);
|
||||
await request(`/api/collection-items/${captured1688.body.data.collection_item.id}/editor-contexts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
});
|
||||
// 模拟历史采集商品:原来只有采集记录保存了页面地址,商品货源字段为空。
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
'UPDATE atlas.products SET purchase_url=NULL WHERE id=$1',
|
||||
[captured1688.body.data.product_id],
|
||||
));
|
||||
const historicalEditor = await readEditor(captured1688.body.data.product_id);
|
||||
assert.equal(historicalEditor.response.status, 200);
|
||||
assert.equal(historicalEditor.body.data.product.common.purchase_url, source1688.source.canonicalUrl);
|
||||
// Simulate real draft work before the source page is collected again.
|
||||
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
||||
// fields / platform mapping just because a newer source snapshot arrives.
|
||||
@@ -281,7 +333,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET source_title = '人工确认商品名'
|
||||
`UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html'
|
||||
WHERE id = $1`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
@@ -366,6 +418,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(second.response.status, 200);
|
||||
assert.equal(second.body.data.reused, true);
|
||||
assert.equal(second.body.data.refreshed, true);
|
||||
assert.equal(second.body.data.product_id, first.body.data.product_id);
|
||||
assert.equal(second.body.data.editor_entry_path, first.body.data.editor_entry_path);
|
||||
assert.equal(
|
||||
second.body.data.collection_item.id,
|
||||
first.body.data.collection_item.id,
|
||||
@@ -382,6 +436,10 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(editorAfterRefresh.response.status, 200);
|
||||
assert.equal(
|
||||
editorAfterRefresh.body.data.product.common.purchase_url,
|
||||
'https://detail.1688.com/offer/888888888.html',
|
||||
);
|
||||
assert.equal(
|
||||
editorAfterRefresh.body.data.product.common.source_title,
|
||||
'人工确认商品名',
|
||||
@@ -494,4 +552,98 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId);
|
||||
});
|
||||
|
||||
it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => {
|
||||
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({
|
||||
sourceVariantKey: String(8_000_000_000_000 + index),
|
||||
sourceSku: String(8_000_000_000_000 + index),
|
||||
displayName: `来源 SKU ${index}`,
|
||||
selections: [], confidence: 'verified', evidenceRefs: [],
|
||||
sourcePrice: { amount: '20.00', currencyCode: 'CNY' },
|
||||
}));
|
||||
const payload = {
|
||||
source: {
|
||||
platform: '1688', itemKey: `large-${randomUUID()}`,
|
||||
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
|
||||
collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test',
|
||||
},
|
||||
common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] },
|
||||
variantAxes: [], variants, assets: [], evidence: [],
|
||||
};
|
||||
const capture = (body: unknown) => request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const captured = await capture(payload);
|
||||
assert.equal(captured.response.status, 201, JSON.stringify(captured.body));
|
||||
assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
|
||||
const collectionId = captured.body.data.collection_item.id;
|
||||
const productId = captured.body.data.product_id;
|
||||
const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } });
|
||||
const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id;
|
||||
const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
});
|
||||
assert.equal(context.response.status, 201, JSON.stringify(context.body));
|
||||
const read = () => request(`/api/products/${productId}/editor`, {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId },
|
||||
});
|
||||
const before = await read();
|
||||
assert.equal(before.response.status, 200);
|
||||
const snapshot = before.body.data;
|
||||
assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT);
|
||||
assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku);
|
||||
const draft = {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: [],
|
||||
common: {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [],
|
||||
platform_product_category_id: null,
|
||||
},
|
||||
variants: snapshot.variants.map((variant: any, index: number) => ({
|
||||
id: variant.id, client_key: null,
|
||||
display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name,
|
||||
source_sku: variant.source_sku, specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec, tags: [], description: null,
|
||||
offer_id: null, media_asset_ids: [],
|
||||
})),
|
||||
};
|
||||
const saved = await request(`/api/products/${productId}/editor-draft`, {
|
||||
method: 'PUT', headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||||
}, body: JSON.stringify(draft),
|
||||
});
|
||||
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
|
||||
const after = await read();
|
||||
assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
|
||||
assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
|
||||
const refreshed = await capture(payload);
|
||||
assert.equal(refreshed.response.status, 200);
|
||||
assert.equal(refreshed.body.data.product_id, productId);
|
||||
assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
|
||||
const reread = await read();
|
||||
assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id));
|
||||
assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
|
||||
const revision = reread.body.data.source_material.source_snapshot_revision;
|
||||
const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] });
|
||||
assert.equal(rejected.response.status, 422);
|
||||
assert.equal(rejected.body.error.details[0].field, 'variants');
|
||||
const unchanged = await read();
|
||||
assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision);
|
||||
assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -20,10 +20,15 @@ vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
|
||||
|
||||
describe('网络商品图片导入', () => {
|
||||
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||
const bytes = Buffer.from('validated-image');
|
||||
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('FROM atlas.products')) {
|
||||
expect(args).toEqual([productId]);
|
||||
return { rows: [{ id: productId }] };
|
||||
}
|
||||
if (sql.includes('WHERE checksum_sha256 = $1')) {
|
||||
expect(args).toEqual([checksum]);
|
||||
return { rows: [{ id: assetId }] };
|
||||
@@ -31,6 +36,8 @@ describe('网络商品图片导入', () => {
|
||||
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
|
||||
expect(args?.[0]).toBe('https://example.com/photo.png');
|
||||
expect(args?.[1]).toBe(assetId);
|
||||
expect(args?.[3]).toBe(productId);
|
||||
expect(sql).toContain('ON CONFLICT (workspace_id, product_id, source_url)');
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT id, content_type')) {
|
||||
@@ -86,7 +93,7 @@ describe('网络商品图片导入', () => {
|
||||
'workspace-id',
|
||||
'user-id',
|
||||
'idempotency-key',
|
||||
{ url: 'https://example.com/photo.png' },
|
||||
{ url: 'https://example.com/photo.png', product_id: productId },
|
||||
);
|
||||
const body = result.body as {
|
||||
data: { id: string; source_url: string; preview_url: string };
|
||||
@@ -97,6 +104,6 @@ describe('网络商品图片导入', () => {
|
||||
source_url: 'https://example.com/photo.png',
|
||||
});
|
||||
expect(body.data.preview_url).toContain('product-imports/existing.png');
|
||||
expect(query).toHaveBeenCalledTimes(4);
|
||||
expect(query).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
@@ -166,7 +167,7 @@ describe('商品图片上传策略', () => {
|
||||
|
||||
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
||||
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||
rows: [],
|
||||
rows: [{ id: productId }],
|
||||
}));
|
||||
const run = vi.fn(
|
||||
async (
|
||||
@@ -193,13 +194,15 @@ describe('商品图片上传策略', () => {
|
||||
'key',
|
||||
{
|
||||
purpose: 'product_image',
|
||||
product_id: productId,
|
||||
filename: 'sku.png',
|
||||
content_type: 'image/png',
|
||||
byte_size: 1024,
|
||||
checksum_sha256: 'a'.repeat(64),
|
||||
},
|
||||
);
|
||||
const args = query.mock.calls[0]?.[1] as unknown[];
|
||||
const args = query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.media_upload_intents'))?.[1] as unknown[];
|
||||
expect(args[10]).toBe(productId);
|
||||
expect(args[1]).toBe('product_image');
|
||||
expect(args[3]).toContain('/product-images/');
|
||||
const body = result.body as { data: { upload: { url: string } } };
|
||||
@@ -217,6 +220,7 @@ describe('商品图片上传策略', () => {
|
||||
createMediaUploadIntentRequestSchema.safeParse({
|
||||
...request,
|
||||
purpose: 'product_image',
|
||||
product_id: productId,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -229,11 +233,20 @@ describe('商品图片上传策略', () => {
|
||||
createMediaUploadIntentRequestSchema.safeParse({
|
||||
...request,
|
||||
purpose: 'product_image',
|
||||
product_id: productId,
|
||||
byte_size: 21 * 1024 * 1024,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('商品上传和网络导入必须绑定商品,水印仍无需商品', () => {
|
||||
const request = { filename: 'image.png', content_type: 'image/png', byte_size: 1024, checksum_sha256: 'a'.repeat(64) };
|
||||
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'product_image' }).success).toBe(false);
|
||||
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image' }).success).toBe(true);
|
||||
expect(createMediaUploadIntentRequestSchema.safeParse({ ...request, purpose: 'watermark_image', product_id: productId }).success).toBe(false);
|
||||
expect(importProductImageRequestSchema.safeParse({ url: 'https://example.test/image.png' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
|
||||
const bytes = png(9000, 9000);
|
||||
const service = serviceWithObject(bytes);
|
||||
@@ -257,6 +270,7 @@ describe('商品图片上传策略', () => {
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
expect(sql).toContain("i.purpose = 'product_image'");
|
||||
expect(sql).toContain("i.status = 'ready'");
|
||||
expect(sql).toContain('i.product_id = $1');
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
@@ -295,7 +309,7 @@ describe('商品图片上传策略', () => {
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const uploads = await service.productUploads('workspace-id');
|
||||
const uploads = await service.productUploads('workspace-id', productId);
|
||||
expect(uploads).toHaveLength(1);
|
||||
expect(uploads[0]).toMatchObject({
|
||||
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
|
||||
@@ -308,6 +322,7 @@ describe('商品图片上传策略', () => {
|
||||
expect(
|
||||
importProductImageRequestSchema.safeParse({
|
||||
url: 'https://example.com/image.jpg',
|
||||
product_id: productId,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -654,4 +654,72 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
|
||||
assert.equal(rateCard.rows[0].status, 'draft');
|
||||
assert.equal(rateCard.rows[0].reviewed_at, null);
|
||||
});
|
||||
it('已上架 SKU 可确认本地新价格,保留首次导入身份且不触发平台任务', async () => {
|
||||
const otherStore = randomUUID();
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query('UPDATE atlas.products SET model=$2 WHERE id=$1', [product, 'LOCAL-RECORD-MODEL']);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET status='imported',offer_id='LOCAL-RECORD-SKU',platform_product_id='123456',first_imported_at=now() WHERE profile_id=$1 AND product_variant_id=$2`,
|
||||
[profile, variant],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores(id,workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,$3,'未来目标店铺',$4,'RUB','active','valid')`,
|
||||
[otherStore, workspace, platform, randomUUID()],
|
||||
);
|
||||
await tx.query('UPDATE atlas.product_platform_profiles SET target_store_id=$2 WHERE id=$1', [profile, otherStore]);
|
||||
});
|
||||
const facts = () => db.workspaceTransaction(workspace, async (tx) => ({
|
||||
listing: (await tx.query(
|
||||
'SELECT status,store_id,offer_id,platform_product_id,first_imported_at,selling_price::text,list_price::text,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2',
|
||||
[profile, variant],
|
||||
)).rows[0],
|
||||
publishJobs: (await tx.query('SELECT id FROM atlas.product_publish_jobs WHERE product_id=$1', [product])).rows.length,
|
||||
events: (await tx.query('SELECT id FROM atlas.outbox_events')).rows.length,
|
||||
}));
|
||||
const before = await facts();
|
||||
const state = await read();
|
||||
assert.equal(state.variants.find((v) => v.id === variant)?.listing.store_id, store);
|
||||
const input = quote();
|
||||
input.pricing.mode = 'fixed_sale';
|
||||
input.pricing.selling_price = '100';
|
||||
input.pricing.list_price_discount_rate = '0.5';
|
||||
const wrongStore = await request('/pricing/quote-previews', 'POST', { ...input, store_id: otherStore });
|
||||
assert.equal(wrongStore.status, 422);
|
||||
assert.equal(wrongStore.error.code, 'PRICING_VARIANT_STORE_MISMATCH');
|
||||
const preview = await request('/pricing/quote-previews', 'POST', input);
|
||||
assert.equal(preview.status, 201, JSON.stringify(preview));
|
||||
const payload = draft(state);
|
||||
payload.profile.target_store_id = otherStore;
|
||||
const changed = payload.variants.find((v) => v.id === variant)!;
|
||||
changed.pricing_draft = input.pricing;
|
||||
changed.pricing_selection = {
|
||||
preview_id: preview.data.preview_id,
|
||||
option_id: preview.data.recommended_option_id,
|
||||
input_hash: preview.data.input_hash,
|
||||
};
|
||||
changed.purchase_cost_cny = '29.00';
|
||||
assert.equal((await save(state, payload)).status, 409);
|
||||
assert.deepEqual(await facts(), before);
|
||||
changed.purchase_cost_cny = '28.00';
|
||||
const key = randomUUID();
|
||||
const saved = await save(state, payload, key);
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved));
|
||||
assert.deepEqual((await save(state, payload, key)).data, saved.data);
|
||||
const after = await facts();
|
||||
assert.equal(after.listing.selling_price, '100.0000');
|
||||
assert.equal(after.listing.list_price, '200.0000');
|
||||
assert.notEqual(after.listing.pricing_quote_id, before.listing.pricing_quote_id);
|
||||
for (const field of ['status', 'store_id', 'offer_id', 'platform_product_id', 'first_imported_at'])
|
||||
assert.deepEqual(after.listing[field], before.listing[field]);
|
||||
assert.equal(after.publishJobs, before.publishJobs);
|
||||
assert.equal(after.events, before.events);
|
||||
const reread = await read();
|
||||
const listing = reread.variants.find((v) => v.id === variant)!.listing;
|
||||
assert.equal(listing.status, 'imported');
|
||||
assert.equal(listing.pricing_result?.selling_price, '100.00');
|
||||
const ordinary = draft(reread);
|
||||
ordinary.profile.target_store_id = otherStore;
|
||||
assert.equal((await save(reread, ordinary)).status, 200);
|
||||
assert.deepEqual((await facts()).listing, after.listing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PRODUCT_AI_HINT_MAX_LENGTH, requestProductAiTaskSchema } from '@atlas/contracts';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { ProductAiService } from '../src/product-editor/product-ai.service.js';
|
||||
|
||||
describe('SKU 文案用户要求', () => {
|
||||
it('将用户要求与 SKU 输入原子固化,并纳入幂等请求,不改写商品字段', async () => {
|
||||
const hint = requestProductAiTaskSchema.parse({ hint: ' 改为红色款,25cm;标题不要写包邮。 ' }).hint;
|
||||
let stored: Record<string, unknown> | undefined;
|
||||
let outbox: unknown;
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT product_id')) return { rows: [{ product_id: 'product' }] };
|
||||
if (sql.includes('SELECT product.revision')) return { rows: [{ product_revision: 3, profile_revision: 4, source_snapshot_revision: 5 }] };
|
||||
if (sql.includes('INSERT INTO atlas.product_ai_tasks')) {
|
||||
stored = JSON.parse(values[4] as string);
|
||||
return { rows: [{ id: 'task' }] };
|
||||
}
|
||||
if (sql.includes('INSERT INTO atlas.outbox_events')) outbox = JSON.parse(values[1] as string);
|
||||
return { rows: [] };
|
||||
});
|
||||
const transaction = { query };
|
||||
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: typeof transaction) => Promise<unknown>) => work(transaction));
|
||||
const snapshot = {
|
||||
product: { id: 'product', revision: 3, common: { model: 'model' } },
|
||||
profile: { id: 'profile', revision: 4, platform_id: 'platform', target_store: { id: 'store' }, category: { id: null }, title: null, tags: [], description: null },
|
||||
source_material: { source_snapshot_revision: 5, title: '采集蓝色 20cm', description: null },
|
||||
variants: [{ id: 'sku', display_name: '蓝色款', specification_values: [], listing: { status: 'not_listed', offer_id: 'model-blue' }, tags: [] }],
|
||||
} as unknown as ProductEditorSnapshot;
|
||||
const service = new ProductAiService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductAiService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof ProductAiService>[1],
|
||||
{ read: vi.fn(async () => snapshot) } as unknown as ConstructorParameters<typeof ProductAiService>[2],
|
||||
);
|
||||
const result = await service.create('workspace', 'user', 'profile', 'platform', 'sku', 'key', hint);
|
||||
expect(result).toMatchObject({ status: 202, body: { data: { ai_task_id: 'task', status: 'queued' } } });
|
||||
expect(stored).toMatchObject({ hint, variant_id: 'sku', revision: { product: 3, profile: 4, source_snapshot: 5 } });
|
||||
expect(stored?.source_material).toEqual(snapshot.source_material);
|
||||
expect(run.mock.calls[0][0]).toMatchObject({ request: { hint, platform_id: 'platform' } });
|
||||
expect(outbox).toEqual({ task_id: 'task' });
|
||||
expect(statements.some((sql) => /UPDATE atlas\.(products|product_variants|collection_items)/.test(sql))).toBe(false);
|
||||
expect(workspaceTransaction.mock.calls.every(([workspace]) => workspace === 'workspace')).toBe(true);
|
||||
});
|
||||
|
||||
it('兼容不传要求,接受 1000 字符并拒绝超限和未知字段', () => {
|
||||
expect(requestProductAiTaskSchema.parse({})).toEqual({});
|
||||
expect(requestProductAiTaskSchema.parse({ hint: ' ' })).toEqual({ hint: '' });
|
||||
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH) }).success).toBe(true);
|
||||
expect(requestProductAiTaskSchema.safeParse({ hint: '要'.repeat(PRODUCT_AI_HINT_MAX_LENGTH + 1) }).success).toBe(false);
|
||||
expect(requestProductAiTaskSchema.safeParse({ hint: '红色款', source_override: {} }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,18 @@ const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
|
||||
const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
function mediaTransaction(processed: boolean) {
|
||||
function mediaTransaction(processed: boolean, library?: 'upload' | 'import', owner = productId) {
|
||||
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||
if (sql.includes('FROM atlas.product_variant_platform_listings'))
|
||||
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
|
||||
if (sql.includes('FROM atlas.media_assets'))
|
||||
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
|
||||
if (sql.includes('FROM atlas.media_upload_intents') || sql.includes('FROM atlas.product_image_imports')) {
|
||||
expect(values).toEqual([productId, [assetId]]);
|
||||
expect(sql).toContain('product_id = $1');
|
||||
const matches = library === 'upload' ? sql.includes('media_upload_intents') : library === 'import' && sql.includes('product_image_imports');
|
||||
return { rows: matches && owner === productId ? [{ asset_id: assetId }] : [] };
|
||||
}
|
||||
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
|
||||
expect(values).toEqual([productId, [assetId]]);
|
||||
return { rows: processed ? [{ asset_id: assetId }] : [] };
|
||||
@@ -34,6 +40,13 @@ describe('SKU 图片处理结果的保存资格', () => {
|
||||
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
|
||||
};
|
||||
|
||||
it.each(['upload', 'import'] as const)('%s 图片仅允许所属商品保存', async (library) => {
|
||||
await service.persistListingMedia(mediaTransaction(false, library), input);
|
||||
await expect(service.persistListingMedia(mediaTransaction(false, library, '其他商品'), input)).rejects.toMatchObject({
|
||||
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
|
||||
const tx = mediaTransaction(true);
|
||||
await service.persistListingMedia(tx, input);
|
||||
|
||||
@@ -1933,18 +1933,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(workspace_id, purpose, storage_provider, bucket, object_key,
|
||||
expected_content_type, expected_byte_size, expected_checksum_sha256,
|
||||
status, asset_id, expires_at, completed_at)
|
||||
status, asset_id, expires_at, completed_at, product_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'product_image',
|
||||
'test', 'test', $1, 'image/png', 1024, $2,
|
||||
'ready', $3, now() + interval '1 hour', now())`,
|
||||
[objectKey, checksum, assetId],
|
||||
'ready', $3, now() + interval '1 hour', now(), $4)`,
|
||||
[objectKey, checksum, assetId, first.productId],
|
||||
);
|
||||
} else {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_imports
|
||||
(workspace_id, source_url, asset_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2)`,
|
||||
[`https://example.test/${randomUUID()}.png`, assetId],
|
||||
(workspace_id, source_url, asset_id, product_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||
[`https://example.test/${randomUUID()}.png`, assetId, first.productId],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1971,20 +1971,38 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(expandedSnapshot.body.data.variants[0].media.length, 30);
|
||||
const uploaded = await request('/api/media-assets/product-uploads', {
|
||||
const uploaded = await request(`/api/media-assets/product-uploads?product_id=${first.productId}`, {
|
||||
headers: headers(),
|
||||
});
|
||||
assert.equal(uploaded.response.status, 200);
|
||||
assert.ok(uploaded.body.data.some((item: any) => item.id === additionalAssets[0]));
|
||||
const imported = await request('/api/media-assets/product-imports', {
|
||||
const usedDeletion = await request(`/api/products/${first.productId}/image-deletions`, {
|
||||
method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||||
body: JSON.stringify({ asset_id: additionalAssets[0] }),
|
||||
});
|
||||
assert.equal(usedDeletion.response.status, 409);
|
||||
assert.equal(usedDeletion.body.error.code, 'MEDIA_ASSET_IN_USE');
|
||||
const invalidDeletion = await request(`/api/products/${first.productId}/image-deletions`, {
|
||||
method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: '{}',
|
||||
});
|
||||
assert.equal(invalidDeletion.response.status, 422);
|
||||
|
||||
const imported = await request(`/api/media-assets/product-imports?product_id=${first.productId}`, {
|
||||
headers: headers(),
|
||||
});
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1]));
|
||||
const unscoped = await request('/api/media-assets/product-uploads', { headers: headers() });
|
||||
assert.equal(unscoped.response.status, 422);
|
||||
for (const kind of ['product-uploads', 'product-imports']) {
|
||||
const unrelated = await request(`/api/media-assets/${kind}?product_id=${randomUUID()}`, { headers: headers() });
|
||||
assert.equal(unrelated.response.status, 200);
|
||||
assert.deepEqual(unrelated.body.data, []);
|
||||
}
|
||||
const privateImport = await request('/api/media-assets/product-imports', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||||
body: JSON.stringify({ url: 'https://127.0.0.1/private.png' }),
|
||||
body: JSON.stringify({ product_id: first.productId, url: 'https://127.0.0.1/private.png' }),
|
||||
});
|
||||
assert.equal(privateImport.response.status, 422);
|
||||
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductImageDeletionService } from '../src/media/product-image-deletion.service.js';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { runMediaAssetDeletion, markMediaAssetDeletionFailed } from '../../worker/src/media-asset-delete.js';
|
||||
|
||||
/** 全部业务实体在结束时回滚,清理对象请求使用替身。 */
|
||||
describe.skipIf(!process.env.DATABASE_URL)('商品图片永久删除数据库闭环', () => {
|
||||
it('校验归属和引用,可靠清理可重试且幂等,删除后不能新增引用', async () => {
|
||||
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||
const rollback = Symbol('rollback');
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
const ws = randomUUID(), foreignWs = randomUUID(), user = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test')", [user, `${user}@example.test`]);
|
||||
for (const id of [ws, foreignWs]) await tx.query("INSERT INTO atlas.workspaces (id,name,slug) VALUES ($1::uuid,'删除测试',$1::text)", [id]);
|
||||
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
|
||||
await setWorkspace(ws);
|
||||
const item = randomUUID(), product = randomUUID(), otherProduct = randomUUID(), asset = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [item, ws]);
|
||||
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [product, ws, item]);
|
||||
const otherItem = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [otherItem, ws]);
|
||||
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [otherProduct, ws, otherItem]);
|
||||
const checksum = 'a'.repeat(64);
|
||||
await tx.query(`INSERT INTO atlas.media_assets (id,workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256,width_px,height_px)
|
||||
VALUES ($1,$2,'local_minio','test','test/canonical','image/png',100,$3,800,800)`, [asset, ws, checksum]);
|
||||
await tx.query(`INSERT INTO atlas.media_upload_intents (workspace_id,product_id,purpose,storage_provider,bucket,object_key,expected_content_type,expected_byte_size,
|
||||
expected_checksum_sha256,status,asset_id,expires_at,completed_at)
|
||||
VALUES ($1,$2,'product_image','local_minio','test','test/duplicate','image/png',100,$3,'ready',$4,now()+interval '1 hour',now())`, [ws,product,checksum,asset]);
|
||||
const scoped = { workspaceTransaction: async (id: string, work: (transaction: typeof tx) => Promise<unknown>) => { await setWorkspace(id); return work(tx); } } as Database;
|
||||
const service = new ProductImageDeletionService(scoped, new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'delete-test' } as never));
|
||||
await expect(service.create(ws,user,otherProduct,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:404});
|
||||
await tx.query('UPDATE atlas.users SET avatar_asset_id = $1 WHERE id = $2', [asset,user]);
|
||||
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
|
||||
await tx.query('SAVEPOINT in_use_delete');
|
||||
await expect(tx.query('UPDATE atlas.media_assets SET deleted_at = now(), checksum_sha256 = NULL WHERE id = $1', [asset])).rejects.toMatchObject({code:'23514'});
|
||||
await tx.query('ROLLBACK TO SAVEPOINT in_use_delete');
|
||||
await tx.query('UPDATE atlas.users SET avatar_asset_id = NULL WHERE id = $1', [user]);
|
||||
await tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/shared.png',$3)", [ws,otherProduct,asset]);
|
||||
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
|
||||
await tx.query('DELETE FROM atlas.product_image_imports WHERE product_id = $1', [otherProduct]);
|
||||
const key = randomUUID();
|
||||
const result = await service.create(ws,user,product,key,{asset_id:asset});
|
||||
const deletion = (result.body as {data:{id:string;status:string}}).data;
|
||||
expect(deletion.status).toBe('queued');
|
||||
expect((await service.create(ws,user,product,key,{asset_id:asset})).body).toEqual(result.body);
|
||||
const removed = (await tx.query('SELECT deleted_at,checksum_sha256,deleted_checksum_sha256 FROM atlas.media_assets WHERE id = $1',[asset])).rows[0];
|
||||
expect(removed.deleted_at).toBeTruthy(); expect(removed.checksum_sha256).toBeNull(); expect(removed.deleted_checksum_sha256).toBe(checksum);
|
||||
expect((await tx.query("SELECT event_payload FROM atlas.outbox_events WHERE event_type = 'media.asset_delete.requested'")).rows).toHaveLength(1);
|
||||
await tx.query('SAVEPOINT deleted_reference');
|
||||
await expect(tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/forbidden.png',$3)",[ws,product,asset])).rejects.toMatchObject({code:'23514'});
|
||||
await tx.query('ROLLBACK TO SAVEPOINT deleted_reference');
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('存储暂不可用')).mockResolvedValue({});
|
||||
const storage = () => ({ send }) as never;
|
||||
const job = {eventId:randomUUID(),workspaceId:ws,eventType:'media.asset_delete.requested',payload:{deletion_id:deletion.id}};
|
||||
await expect(runMediaAssetDeletion(scoped,storage,job)).rejects.toThrow('存储暂不可用');
|
||||
await markMediaAssetDeletionFailed(scoped,job);
|
||||
const failed = await service.read(ws,product,deletion.id);
|
||||
expect(failed.status).toBe('failed'); expect(failed.operation.status).toBe('failed');
|
||||
await service.retry(ws,user,product,deletion.id,randomUUID());
|
||||
await runMediaAssetDeletion(scoped,storage,job);
|
||||
const succeeded = await service.read(ws,product,deletion.id);
|
||||
expect(succeeded.status).toBe('succeeded'); expect(succeeded.operation.status).toBe('succeeded');
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
await runMediaAssetDeletion(scoped,storage,job);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
await tx.query(`INSERT INTO atlas.media_assets (workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256)
|
||||
VALUES ($1,'local_minio','test','test/new-object','image/png',100,$2)`,[ws,checksum]);
|
||||
const transfer = randomUUID();
|
||||
await tx.query(`INSERT INTO atlas.collection_asset_transfers (id,workspace_id,collection_item_id,product_id,role,source_url)
|
||||
VALUES ($1,$2,$3,$4,'source_main','https://example.test/source.png')`,[transfer,ws,item,product]);
|
||||
const sourceDelete = await service.create(ws,user,product,randomUUID(),{collection_image_id:transfer});
|
||||
expect((sourceDelete.body as {data:{status:string}}).data.status).toBe('succeeded');
|
||||
expect((await tx.query('SELECT status,deleted_at FROM atlas.collection_asset_transfers WHERE id = $1',[transfer])).rows[0]).toMatchObject({status:'cancelled'});
|
||||
await expect(service.read(foreignWs,product,deletion.id)).rejects.toMatchObject({status:404});
|
||||
throw rollback;
|
||||
});
|
||||
} catch (error) { if (error !== rollback) throw error; }
|
||||
finally { await db.close(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createApplicationDatabase, type Database, type DatabaseTransaction } from '@atlas/database';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { MediaService } from '../src/media/media.service.js';
|
||||
|
||||
const bytes = Buffer.from('validated-product-image');
|
||||
vi.mock('../src/media/product-image-download.js', () => ({
|
||||
downloadProductImage: vi.fn(async () => ({ bytes, contentType: 'image/png', width: 800, height: 800 })),
|
||||
}));
|
||||
|
||||
/** 归属验证只在回滚事务中创建数据,存储和公网下载由测试替身承担。 */
|
||||
describe.skipIf(!process.env.DATABASE_URL)('商品上传与网络图片数据库隔离', () => {
|
||||
it('候选只属于当前商品,重复素材可独立绑定,FK 与 RLS 拒绝越权', async () => {
|
||||
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||
const rollback = Symbol('rollback');
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const workspaceId = randomUUID(), otherWorkspace = randomUUID(), userId = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')", [userId, `${userId}@example.test`]);
|
||||
for (const id of [workspaceId, otherWorkspace])
|
||||
await tx.query("INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '图片归属测试', $1::text)", [id]);
|
||||
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
|
||||
const product = async (ws: string) => {
|
||||
await setWorkspace(ws);
|
||||
const id = randomUUID(), itemId = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)", [itemId, ws]);
|
||||
await tx.query('INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)', [id, ws, itemId]);
|
||||
return id;
|
||||
};
|
||||
const productA = await product(workspaceId), productB = await product(workspaceId), foreignProduct = await product(otherWorkspace);
|
||||
await setWorkspace(workspaceId);
|
||||
const assetA = randomUUID(), assetB = randomUUID(), legacyAsset = randomUUID();
|
||||
const checksum = createHash('sha256').update(bytes).digest('hex');
|
||||
for (const id of [assetA, assetB, legacyAsset])
|
||||
await tx.query(`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/png', $3, $4, 800, 800)`,
|
||||
[id, workspaceId, bytes.length, id === assetA ? checksum : id]);
|
||||
const upload = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.media_upload_intents
|
||||
(workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, status, asset_id, expires_at, completed_at, product_id)
|
||||
VALUES ($1, 'product_image', 'local_minio', 'test', $2, 'image/png', $3, $4, 'ready', $5, now() + interval '1 hour', now(), $6)`,
|
||||
[workspaceId, randomUUID(), bytes.length, checksum, assetId, productId]);
|
||||
const imported = async (productId: string | null, assetId: string) => tx.query(`INSERT INTO atlas.product_image_imports
|
||||
(workspace_id, product_id, source_url, asset_id) VALUES ($1, $2, 'https://example.test/shared.png', $3)`,
|
||||
[workspaceId, productId, assetId]);
|
||||
await upload(productA, assetA);
|
||||
await upload(productB, assetB);
|
||||
await upload(null, legacyAsset);
|
||||
await imported(productA, assetA);
|
||||
await imported(productB, assetB);
|
||||
await imported(null, legacyAsset);
|
||||
const scoped = { workspaceTransaction: async <T>(ws: string, work: (tx: DatabaseTransaction) => Promise<T>) => {
|
||||
await setWorkspace(ws);
|
||||
return work(tx);
|
||||
} } as Database;
|
||||
const media = new MediaService(new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'image-test-secret' } as never), scoped, {
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000', OBJECT_STORAGE_REGION: 'us-east-1', OBJECT_STORAGE_BUCKET: 'test',
|
||||
OBJECT_STORAGE_PROVIDER: 'local_minio', objectStorageForcePathStyle: true, objectStorageAccessKey: 'test', objectStorageSecretKey: 'test',
|
||||
} as never);
|
||||
Object.assign(media, { ensureBucket: async () => undefined, inspectObject: async () => ({
|
||||
byteSize: bytes.length, checksum, contentType: 'image/png', width: 800, height: 800,
|
||||
}) });
|
||||
const ids = (rows: Array<{ id: string }>) => rows.map((row) => row.id).sort();
|
||||
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
|
||||
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetB]);
|
||||
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
|
||||
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetB]);
|
||||
const input = { purpose: 'product_image' as const, product_id: productB, filename: 'shared.png',
|
||||
content_type: 'image/png' as const, byte_size: bytes.length, checksum_sha256: checksum };
|
||||
const key = randomUUID();
|
||||
const intent = await media.createIntent(workspaceId, userId, key, input);
|
||||
const uploadId = (intent.body as { data: { upload_id: string } }).data.upload_id;
|
||||
const repeated = await media.createIntent(workspaceId, userId, key, input);
|
||||
expect((repeated.body as { data: { upload_id: string } }).data.upload_id).toBe(uploadId);
|
||||
expect((await tx.query('SELECT product_id FROM atlas.media_upload_intents WHERE id = $1', [uploadId])).rows[0].product_id).toBe(productB);
|
||||
await media.completeIntent(workspaceId, userId, uploadId, randomUUID(), { checksum_sha256: checksum });
|
||||
expect(ids(await media.productUploads(workspaceId, productB))).toEqual([assetA, assetB].sort());
|
||||
expect(ids(await media.productUploads(workspaceId, productA))).toEqual([assetA]);
|
||||
await media.importProductImage(workspaceId, userId, randomUUID(), { product_id: productB, url: 'https://example.test/shared.png' });
|
||||
expect(ids(await media.productImports(workspaceId, productB))).toEqual([assetA]);
|
||||
expect(ids(await media.productImports(workspaceId, productA))).toEqual([assetA]);
|
||||
const forbidden = async (work: () => Promise<unknown>) => {
|
||||
await tx.query('SAVEPOINT forbidden_image');
|
||||
try { await expect(work()).rejects.toMatchObject({ code: '23503' }); }
|
||||
finally { await tx.query('ROLLBACK TO SAVEPOINT forbidden_image'); }
|
||||
};
|
||||
await forbidden(() => upload(foreignProduct, assetA));
|
||||
await forbidden(() => imported(foreignProduct, assetA));
|
||||
await tx.query('SAVEPOINT unknown_product');
|
||||
await expect(media.createIntent(workspaceId, userId, randomUUID(), { ...input, product_id: foreignProduct })).rejects.toMatchObject({ status: 404 });
|
||||
await tx.query('ROLLBACK TO SAVEPOINT unknown_product');
|
||||
expect(await media.productUploads(otherWorkspace, productA)).toEqual([]);
|
||||
expect(await media.productImports(otherWorkspace, productA)).toEqual([]);
|
||||
throw rollback;
|
||||
});
|
||||
} catch (error) { if (error !== rollback) throw error; }
|
||||
finally { await db.close(); }
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,125 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('商品颜色按属性 ID 支持多选保存、重读和发布,其他单值属性仍限制数量', async () => {
|
||||
const f = await fixture(1);
|
||||
const dictionaryId = `color-${randomUUID()}`;
|
||||
await db.workspaceTransaction(workspace, async tx => {
|
||||
const ownCategory = (await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path)
|
||||
VALUES($1,$2,'1002','颜色多选测试','颜色多选测试') RETURNING id`,
|
||||
[platform, String(Date.now()) + '96'],
|
||||
)).rows[0].id;
|
||||
const color = (await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,is_collection,max_value_count,dictionary_id)
|
||||
VALUES($1,$2,'10096','Цвет товара','dictionary',true,true,false,1,$3) RETURNING id`,
|
||||
[platform, ownCategory, dictionaryId],
|
||||
)).rows[0].id;
|
||||
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_aspect,is_collection,max_value_count)
|
||||
VALUES($1,$2,'200001','单值测试','text',true,false,1)`, [platform, ownCategory]);
|
||||
await tx.query(`INSERT INTO atlas.platform_catalog_dictionary_values
|
||||
(platform_id,platform_product_category_id,attribute_id,dictionary_id,external_value_id,value)
|
||||
VALUES($1,$2,$3,$4,'61579','红色'),($1,$2,$3,$4,'61581','蓝色')`,
|
||||
[platform, ownCategory, color, dictionaryId]);
|
||||
await tx.query(`INSERT INTO atlas.platform_catalog_resource_states
|
||||
(platform_id,scope_key,scope,category_id,attribute_id,last_success_at,is_complete)
|
||||
VALUES($1,'attributes:' || $2::text,'attributes',$2::uuid,NULL,now(),true),
|
||||
($1,'dictionary:' || $3::text,'dictionary',$2::uuid,$3::uuid,now(),true)`,
|
||||
[platform, ownCategory, color]);
|
||||
await tx.query('UPDATE atlas.product_platform_profiles SET platform_product_category_id=$2 WHERE id=$1', [f.profile, ownCategory]);
|
||||
await tx.query('UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=NULL WHERE profile_id=$1', [f.profile]);
|
||||
await tx.query(`UPDATE atlas.media_assets SET width_px=800,height_px=800
|
||||
WHERE id IN (SELECT asset_id FROM atlas.product_variant_listing_media WHERE profile_id=$1)`, [f.profile]);
|
||||
});
|
||||
const before = (await request(`/products/${f.product}/editor`)).body.data;
|
||||
const colorDraft = {
|
||||
scope: 'variant', variant_id: f.ids[0], platform_attribute_id: '10096', complex_id: '0',
|
||||
values: ['61579', '61581'], language_code: null, source: 'manual', confirmation_status: 'confirmed',
|
||||
};
|
||||
const draft = {
|
||||
ai_source_material: before.source_material,
|
||||
ai_source_overridden_fields: before.source_material_state.overridden_fields,
|
||||
common: { ...before.product.common, default_physical_spec: before.product.default_physical_spec,
|
||||
default_package_spec: before.product.default_package_spec },
|
||||
profile: { target_store_id: store, fulfillment: before.profile.fulfillment, title: before.profile.title,
|
||||
description: before.profile.description, tags: before.profile.tags,
|
||||
platform_product_category_id: before.profile.category.id, category_source: 'manual' },
|
||||
attributes: [colorDraft],
|
||||
variants: before.variants.map((v: any) => ({
|
||||
id: v.id, client_key: null, display_name: v.display_name, source_sku: v.source_sku,
|
||||
specification_values: v.specification_values, purchase_cost_cny: v.purchase_cost_cny,
|
||||
title: v.title, tags: v.tags, description: v.description, physical_spec: v.physical_spec,
|
||||
package_spec: v.package_spec, offer_id: v.listing.offer_id,
|
||||
media_asset_ids: v.media.map((m: any) => m.asset_id),
|
||||
})),
|
||||
};
|
||||
const save = (attributes = draft.attributes) => request(`/products/${f.product}/editor-draft`, 'PUT',
|
||||
{ ...draft, attributes }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' });
|
||||
const invalidSingle = await save([{ ...colorDraft, platform_attribute_id: '200001', values: ['one', 'two'] }]);
|
||||
assert.equal(invalidSingle.status, 422);
|
||||
assert.equal(invalidSingle.body.error.code, 'ATTRIBUTE_VALUE_COUNT_INVALID');
|
||||
const invalidDictionary = await save([{ ...colorDraft, values: ['61579', '99999999'] }]);
|
||||
assert.equal(invalidDictionary.status, 422);
|
||||
const saved = await save();
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved));
|
||||
const after = (await request(`/products/${f.product}/editor`)).body.data;
|
||||
assert.deepEqual(after.attributes.find((a: any) => a.platform_attribute_id === '10096').values, ['61579', '61581']);
|
||||
// fixture 使用预先确认的模拟报价;草稿保存后恢复其有效标记,仅验证颜色发布映射。
|
||||
await db.workspaceTransaction(workspace, tx => tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings listing SET pricing_valid=true,
|
||||
pricing_quote_id=(SELECT quote.id FROM atlas.pricing_quotes quote
|
||||
WHERE quote.profile_id=listing.profile_id AND quote.product_variant_id=listing.product_variant_id LIMIT 1)
|
||||
WHERE listing.profile_id=$1`, [f.profile]));
|
||||
const job = await request(`/product-platform-profiles/${f.profile}/publish-jobs`, 'POST',
|
||||
{ variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': `"${after.product.revision}:${after.profile.revision}"` });
|
||||
assert.equal(job.status, 202, JSON.stringify(job));
|
||||
const snapshot = await db.workspaceTransaction(workspace, async tx =>
|
||||
(await tx.query<{ snapshot: { attributes: Array<{ id: number; values: unknown[] }> } }>(
|
||||
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.body.data.publish_job.id])).rows[0].snapshot);
|
||||
assert.deepEqual(snapshot.attributes.find(a => a.id === 10096)?.values, [
|
||||
{ dictionary_value_id: 61579, value: '红色' },
|
||||
{ dictionary_value_id: 61581, value: '蓝色' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('简介保留 SKU 覆盖和 SPU 继承的换行,Ozon 请求转为 br 且不修改快照', async () => {
|
||||
const f = await fixture();
|
||||
const common = 'Общее описание\r\n\r\nХарактеристики:\r\n• Один & два';
|
||||
const own = 'Описание SKU\n\nРазмер < 10\n• Красный';
|
||||
await db.workspaceTransaction(workspace, async tx => {
|
||||
await tx.query(`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count)
|
||||
VALUES($1,$2,'4191','简介','text',false,false,1) ON CONFLICT DO NOTHING`, [platform, category]);
|
||||
await tx.query('UPDATE atlas.product_platform_profiles SET description=$2 WHERE id=$1', [f.profile, common]);
|
||||
await tx.query('UPDATE atlas.product_variant_platform_listings SET description=$3 WHERE profile_id=$1 AND product_variant_id=$2', [f.profile,f.ids[0],own]);
|
||||
});
|
||||
const created = await publish(f);
|
||||
assert.equal(created.status, 202, JSON.stringify(created));
|
||||
const job = created.body.data.publish_job as ProductPublishJob;
|
||||
const snapshots = () => db.workspaceTransaction(workspace, async tx => (await tx.query<{
|
||||
variant_id: string; snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }> }> };
|
||||
}>('SELECT variant_id,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id])).rows);
|
||||
const before = await snapshots();
|
||||
for (const row of before) assert.equal(row.snapshot.attributes.find(a=>a.id===4191)?.values[0].value,
|
||||
row.variant_id === f.ids[0] ? own : common);
|
||||
let submitted = false;
|
||||
await run(job, async (_client, _key, path, body) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
assert.equal(path, '/v3/product/import');
|
||||
submitted = true;
|
||||
const items = (body as { items: Array<{ attributes: Array<{ id: number; values: Array<{ value: string }> }> }> }).items;
|
||||
assert.deepEqual(new Set(items.map(item=>item.attributes.find(a=>a.id===4191)?.values[0].value)), new Set([
|
||||
'Описание SKU<br><br>Размер < 10<br>• Красный',
|
||||
'Общее описание<br><br>Характеристики:<br>• Один & два',
|
||||
]));
|
||||
return { result: { task_id: 778899 } };
|
||||
});
|
||||
assert.equal(submitted,true);
|
||||
assert.deepEqual(await snapshots(), before);
|
||||
});
|
||||
|
||||
it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
|
||||
Reference in New Issue
Block a user