feat(edit): 编辑上报细节优化
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user