146 lines
6.6 KiB
TypeScript
146 lines
6.6 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import type { CreateWatermarkDerivativeRequest, WatermarkDerivative } 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: WatermarkDerivative['status'];
|
|
source_asset_id: string;
|
|
watermark_template_id: string;
|
|
watermark_template_version: number;
|
|
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 WatermarkDerivativeService {
|
|
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: CreateWatermarkDerivativeRequest,
|
|
) {
|
|
return this.idempotency.run({
|
|
workspaceId,
|
|
userId,
|
|
method: 'POST',
|
|
path: `/api/media-assets/${assetId}/watermark-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 template = await tx.query<{ id: string; current_version: number }>(
|
|
`SELECT id, current_version FROM atlas.watermark_templates
|
|
WHERE id = $1 AND status = 'active' AND deleted_at IS NULL`,
|
|
[input.watermark_template_id],
|
|
);
|
|
if (!template.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在');
|
|
const version = template.rows[0].current_version;
|
|
const created = await tx.query<{ id: string }>(
|
|
`INSERT INTO atlas.media_watermark_derivatives
|
|
(workspace_id, product_id, source_asset_id, watermark_template_id,
|
|
watermark_template_version, created_by_user_id)
|
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)
|
|
RETURNING id`,
|
|
[input.product_id ?? null, assetId, input.watermark_template_id, version, 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_watermark_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.watermark_derivative.requested',
|
|
jsonb_build_object('derivative_id', $1::uuid), $1::text)`,
|
|
[id],
|
|
);
|
|
return { status: 202, body: { data: {
|
|
id,
|
|
status: 'queued',
|
|
source_asset_id: assetId,
|
|
watermark_template_id: input.watermark_template_id,
|
|
watermark_template_version: version,
|
|
output_asset: null,
|
|
error_code: null,
|
|
} satisfies WatermarkDerivative } };
|
|
});
|
|
}
|
|
|
|
/** 成功结果读取时才签发短时预览地址。 */
|
|
public async read(workspaceId: string, derivativeId: string): Promise<WatermarkDerivative> {
|
|
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.watermark_template_id,
|
|
d.watermark_template_version, 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_watermark_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,
|
|
watermark_template_id: row.watermark_template_id,
|
|
watermark_template_version: row.watermark_template_version,
|
|
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,
|
|
};
|
|
}
|
|
}
|