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
+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])