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

This commit is contained in:
2026-10-06 18:30:05 +08:00
parent e20cd7547f
commit 79b310efe7
92 changed files with 2764 additions and 368 deletions
@@ -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 {
+1 -1
View File
@@ -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],
+4 -4
View File
@@ -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],
);
+32 -6
View File
@@ -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')
+2 -1
View File
@@ -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 {}
+28 -14
View File
@@ -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 {