140 lines
6.1 KiB
TypeScript
140 lines
6.1 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import type { AspectRatioDerivative, CreateAspectRatioDerivativeRequest } from '@atlas/contracts';
|
|
import type { Database } from '@atlas/database';
|
|
import { ApiError } from '../common/api-error.js';
|
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
|
import { DATABASE } from '../database/database.module.js';
|
|
import { MediaService } from './media.service.js';
|
|
|
|
type DerivativeRow = {
|
|
id: string;
|
|
status: AspectRatioDerivative['status'];
|
|
source_asset_id: string;
|
|
output_asset_id: string | null;
|
|
error_code: string | null;
|
|
content_type: string | null;
|
|
byte_size: string | null;
|
|
width_px: number | null;
|
|
height_px: number | null;
|
|
asset_created_at: Date | string | null;
|
|
};
|
|
|
|
@Injectable()
|
|
export class AspectRatioDerivativeService {
|
|
public constructor(
|
|
@Inject(DATABASE) private readonly database: Database,
|
|
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
|
@Inject(MediaService) private readonly media: MediaService,
|
|
) {}
|
|
|
|
/** 任务、用户可见进度和 Outbox 必须在同一工作空间事务中创建。 */
|
|
public async create(
|
|
workspaceId: string,
|
|
userId: string,
|
|
assetId: string,
|
|
key: string,
|
|
input: CreateAspectRatioDerivativeRequest,
|
|
) {
|
|
return this.idempotency.run(
|
|
{
|
|
workspaceId,
|
|
userId,
|
|
method: 'POST',
|
|
path: `/api/media-assets/${assetId}/aspect-ratio-derivatives`,
|
|
key,
|
|
request: input,
|
|
},
|
|
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')
|
|
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000
|
|
AND height_px BETWEEN 1 AND 10000`,
|
|
[assetId],
|
|
);
|
|
if (!source.rows[0])
|
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
|
|
if (input.product_id) {
|
|
const product = await tx.query<{ id: string }>(
|
|
'SELECT id FROM atlas.products WHERE id = $1',
|
|
[input.product_id],
|
|
);
|
|
if (!product.rows[0])
|
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
|
}
|
|
const created = await tx.query<{ id: string }>(
|
|
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
|
(workspace_id, product_id, source_asset_id, created_by_user_id)
|
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
|
|
RETURNING id`,
|
|
[input.product_id ?? null, assetId, userId],
|
|
);
|
|
const id = created.rows[0].id;
|
|
await tx.query(
|
|
`INSERT INTO atlas.operations
|
|
(id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
|
VALUES ($1, current_setting('app.workspace_id')::uuid,
|
|
'media_aspect_ratio_derivative', 'media_derivative', $1, $2)`,
|
|
[id, userId],
|
|
);
|
|
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, 'media_derivative', $1,
|
|
'media.aspect_ratio_derivative.requested',
|
|
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
|
|
[id],
|
|
);
|
|
return {
|
|
status: 202,
|
|
body: { data: {
|
|
id,
|
|
status: 'queued',
|
|
source_asset_id: assetId,
|
|
output_asset: null,
|
|
error_code: null,
|
|
} satisfies AspectRatioDerivative },
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
/** 短时预览地址仅在读取成功结果时签发,不写入任务或草稿。 */
|
|
public async read(workspaceId: string, derivativeId: string): Promise<AspectRatioDerivative> {
|
|
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const result = await tx.query<DerivativeRow>(
|
|
`SELECT d.id, d.status, d.source_asset_id, d.output_asset_id, d.error_code,
|
|
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
|
|
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
|
|
? 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,
|
|
output_asset: row.output_asset_id && access ? {
|
|
id: row.output_asset_id,
|
|
content_type: row.content_type!,
|
|
byte_size: Number(row.byte_size),
|
|
width_px: row.width_px,
|
|
height_px: row.height_px,
|
|
created_at: row.asset_created_at instanceof Date
|
|
? row.asset_created_at.toISOString()
|
|
: String(row.asset_created_at),
|
|
preview_url: access.url,
|
|
preview_url_expires_at: access.expires_at,
|
|
} : null,
|
|
};
|
|
}
|
|
}
|