feat(ai-image): AI生图开发
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { MediaAiEdit, MediaAiEditSettings } 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 EditRow = {
|
||||
id: string;
|
||||
status: MediaAiEdit['status'];
|
||||
source_asset_id: string;
|
||||
output_asset_id: string | null;
|
||||
error_code: string | null;
|
||||
failure_source?: MediaAiEdit['failure_source'];
|
||||
failure_stage?: MediaAiEdit['failure_stage'];
|
||||
failure_detail?: 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 MediaAiEditService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
/** 创建任务前确认主图和辅图均为本工作空间已校验的正式素材。 */
|
||||
public async create(workspaceId: string, userId: string, sourceId: string, key: string, settings: MediaAiEditSettings) {
|
||||
return this.idempotency.run({
|
||||
workspaceId, userId, method: 'POST', path: `/api/media-assets/${sourceId}/ai-edits`,
|
||||
key, request: settings,
|
||||
}, async (tx) => {
|
||||
const allIds = [sourceId, ...settings.reference_asset_ids];
|
||||
const product = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.products WHERE id = $1', [settings.product_id],
|
||||
);
|
||||
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
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[])
|
||||
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],
|
||||
);
|
||||
if (assets.rows.length !== allIds.length)
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '图片素材不存在或不可处理');
|
||||
const created = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_ai_edits
|
||||
(workspace_id, product_id, source_asset_id, reference_asset_ids, settings, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3::uuid[], $4::jsonb, $5)
|
||||
RETURNING id`,
|
||||
[settings.product_id, sourceId, settings.reference_asset_ids, JSON.stringify(settings), 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_ai_edit', 'media_ai_edit', $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_ai_edit', $1,
|
||||
'media.ai_edit.requested', jsonb_build_object('edit_id', $1::uuid), $1::text)`, [id],
|
||||
);
|
||||
return { status: 202, body: { data: {
|
||||
id, status: 'queued', source_asset_id: sourceId, output_asset: null, error_code: null,
|
||||
failure_source: null, failure_stage: null, failure_detail: null,
|
||||
} satisfies MediaAiEdit } };
|
||||
});
|
||||
}
|
||||
|
||||
public async read(workspaceId: string, id: string): Promise<MediaAiEdit> {
|
||||
const row = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const result = await tx.query<EditRow>(
|
||||
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.error_code,
|
||||
o.result ->> 'failure_source' AS failure_source,
|
||||
o.result ->> 'failure_stage' AS failure_stage,
|
||||
o.result ->> 'failure_detail' AS failure_detail,
|
||||
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
|
||||
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
|
||||
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;
|
||||
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,
|
||||
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
|
||||
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 右侧素材库展示最近完成的生成图,预览链接每次读取时重新签发。 */
|
||||
public async list(workspaceId: string, productId: string): Promise<MediaAiEdit[]> {
|
||||
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const result = await tx.query<EditRow>(
|
||||
`SELECT e.id, e.status, e.source_asset_id, e.output_asset_id, e.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_ai_edits e
|
||||
JOIN atlas.media_assets a ON a.id = e.output_asset_id
|
||||
WHERE e.status = 'succeeded' AND e.product_id = $1
|
||||
ORDER BY e.completed_at DESC LIMIT 100`, [productId],
|
||||
);
|
||||
return result.rows;
|
||||
});
|
||||
return Promise.all(rows.map(async (row) => {
|
||||
const access = await this.media.accessUrl(workspaceId, row.output_asset_id!);
|
||||
return {
|
||||
id: row.id, status: 'succeeded' as const, source_asset_id: row.source_asset_id,
|
||||
error_code: null,
|
||||
failure_source: null, failure_stage: null, failure_detail: null,
|
||||
output_asset: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** 取消是工作空间内的幂等状态变更;已完成结果不回滚。 */
|
||||
public async cancel(workspaceId: string, userId: string, id: string, key: string) {
|
||||
return this.idempotency.run({
|
||||
workspaceId, userId, method: 'POST', path: `/api/media-ai-edits/${id}/cancel`, key, request: {},
|
||||
}, async (tx) => {
|
||||
const existing = await tx.query<{ status: MediaAiEdit['status'] }>(
|
||||
'SELECT status FROM atlas.media_ai_edits WHERE id = $1 FOR UPDATE', [id],
|
||||
);
|
||||
if (!existing.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', 'AI 编辑任务不存在');
|
||||
if (existing.rows[0].status === 'queued' || existing.rows[0].status === 'running') {
|
||||
await tx.query(
|
||||
`UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE id = $1`, [id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'cancelled', completed_at = now()
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`, [id],
|
||||
);
|
||||
}
|
||||
return { status: 200, body: { data: { id, status: existing.rows[0].status === 'queued'
|
||||
|| existing.rows[0].status === 'running' ? 'cancelled' : existing.rows[0].status } } };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -19,6 +21,11 @@ import {
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
createAspectRatioDerivativeRequestSchema,
|
||||
createWatermarkDerivativeRequestSchema,
|
||||
createMediaAiEditRequestSchema,
|
||||
listProductImageTasksQuerySchema,
|
||||
type CreateMediaAccessUrlRequest,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { z } from 'zod';
|
||||
@@ -26,6 +33,11 @@ import { ApiError } from '../common/api-error.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@@ -33,8 +45,132 @@ import { MediaService } from './media.service.js';
|
||||
export class MediaController {
|
||||
public constructor(
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
@Inject(AspectRatioDerivativeService) private readonly derivatives: AspectRatioDerivativeService,
|
||||
@Inject(WatermarkDerivativeService) private readonly watermarkDerivatives: WatermarkDerivativeService,
|
||||
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
|
||||
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
|
||||
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
|
||||
) {}
|
||||
|
||||
@Get('products/:productId/image-derivatives')
|
||||
public async listProductImageDerivatives(
|
||||
@Param('productId') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.derivativeHistory.list(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Get('products/:productId/image-tasks')
|
||||
public async listProductImageTasks(
|
||||
@Param('productId') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Query() query: unknown,
|
||||
) {
|
||||
return this.productImageTasks.list(workspace.id, uuid('product_id', productId),
|
||||
parse(listProductImageTasksQuerySchema, query));
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/ai-edits')
|
||||
public async createAiEdit(
|
||||
@Param('assetId') assetId: 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.aiEdits.create(
|
||||
workspace.id, request.auth.userId, uuid('asset_id', assetId),
|
||||
idempotencyKey(key), parse(createMediaAiEditRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-ai-edits/:editId')
|
||||
public async aiEdit(
|
||||
@Param('editId') editId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.aiEdits.read(workspace.id, uuid('edit_id', editId)) };
|
||||
}
|
||||
|
||||
@Get('media-ai-edits')
|
||||
public async listAiEdits(
|
||||
@Query('product_id') productId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.aiEdits.list(workspace.id, uuid('product_id', productId)) };
|
||||
}
|
||||
|
||||
@Post('media-ai-edits/:editId/cancel')
|
||||
public async cancelAiEdit(
|
||||
@Param('editId') editId: string,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (await this.aiEdits.cancel(
|
||||
workspace.id, request.auth.userId, uuid('edit_id', editId), idempotencyKey(key),
|
||||
)).body;
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/watermark-derivatives')
|
||||
public async createWatermarkDerivative(
|
||||
@Param('assetId') assetId: 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.watermarkDerivatives.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
uuid('asset_id', assetId),
|
||||
idempotencyKey(key),
|
||||
parse(createWatermarkDerivativeRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-watermark-derivatives/:derivativeId')
|
||||
public async watermarkDerivative(
|
||||
@Param('derivativeId') derivativeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.watermarkDerivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/aspect-ratio-derivatives')
|
||||
public async createAspectRatioDerivative(
|
||||
@Param('assetId') assetId: 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.derivatives.create(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
uuid('asset_id', assetId),
|
||||
idempotencyKey(key),
|
||||
parse(createAspectRatioDerivativeRequestSchema, body),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('media-derivatives/:derivativeId')
|
||||
public async aspectRatioDerivative(
|
||||
@Param('derivativeId') derivativeId: string,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return { data: await this.derivatives.read(workspace.id, uuid('derivative_id', derivativeId)) };
|
||||
}
|
||||
|
||||
@Get('media-assets/product-uploads')
|
||||
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
|
||||
public async productUploads(@CurrentWorkspace() workspace: Workspace) {
|
||||
@@ -102,17 +238,18 @@ export class MediaController {
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/access-urls')
|
||||
@ApiOkResponse({ description: '已签发短时预览 URL' })
|
||||
@ApiOkResponse({ description: '已签发短时预览或下载 URL' })
|
||||
public async accessUrl(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
parse(createMediaAccessUrlRequestSchema, body);
|
||||
const input = parse(createMediaAccessUrlRequestSchema, body) as CreateMediaAccessUrlRequest;
|
||||
return {
|
||||
data: await this.media.accessUrl(
|
||||
workspace.id,
|
||||
uuid('asset_id', assetId),
|
||||
input.purpose,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { MediaController } from './media.controller.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
import { AspectRatioDerivativeService } from './aspect-ratio-derivative.service.js';
|
||||
import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CompleteMediaUploadIntentRequest,
|
||||
CreateMediaAccessUrlRequest,
|
||||
CreateMediaUploadIntentRequest,
|
||||
ImportProductImageRequest,
|
||||
} from '@atlas/contracts';
|
||||
@@ -263,23 +264,36 @@ export class MediaService {
|
||||
}
|
||||
}
|
||||
|
||||
public async accessUrl(workspaceId: string, assetId: string) {
|
||||
public async accessUrl(
|
||||
workspaceId: string,
|
||||
assetId: string,
|
||||
purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview',
|
||||
) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`,
|
||||
`SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||
const extension = ({
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
...(purpose === 'download' ? {
|
||||
ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ProductImageDerivativeRecord } from '@atlas/contracts';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
|
||||
type HistoryRow = {
|
||||
id: string;
|
||||
kind: ProductImageDerivativeRecord['kind'];
|
||||
source_asset_id: string;
|
||||
output_asset_id: string;
|
||||
created_at: Date | string;
|
||||
content_type: string;
|
||||
byte_size: string;
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
asset_created_at: Date | string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductImageDerivativeHistoryService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
/** 只列当前商品成功的派生图;预览地址每次读取时重新签发。 */
|
||||
public async list(workspaceId: string, productId: string): Promise<ProductImageDerivativeRecord[]> {
|
||||
const rows = await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const product = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.products WHERE id = $1', [productId],
|
||||
);
|
||||
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
const result = await tx.query<HistoryRow>(
|
||||
`SELECT d.id, d.kind, d.source_asset_id, d.output_asset_id, d.created_at,
|
||||
a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at AS asset_created_at
|
||||
FROM (
|
||||
SELECT id, 'aspect_ratio_3_4'::text AS kind, source_asset_id,
|
||||
output_asset_id, created_at, completed_at
|
||||
FROM atlas.media_aspect_ratio_derivatives
|
||||
WHERE product_id = $1 AND status = 'succeeded'
|
||||
UNION ALL
|
||||
SELECT id, 'watermark'::text AS kind, source_asset_id,
|
||||
output_asset_id, created_at, completed_at
|
||||
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
|
||||
ORDER BY d.completed_at DESC, d.id DESC
|
||||
LIMIT 100`,
|
||||
[productId],
|
||||
);
|
||||
return result.rows;
|
||||
});
|
||||
return Promise.all(rows.map(async (row) => {
|
||||
const access = await this.media.accessUrl(workspaceId, row.output_asset_id);
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
source_asset_id: row.source_asset_id,
|
||||
created_at: isoTime(row.created_at),
|
||||
output_asset: {
|
||||
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: isoTime(row.asset_created_at),
|
||||
preview_url: access.url,
|
||||
preview_url_expires_at: access.expires_at,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function isoTime(value: Date | string) {
|
||||
return value instanceof Date ? value.toISOString() : String(value);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ListProductImageTasksQuery, ProductImageTask } from '@atlas/contracts';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type AiEditTaskRow = {
|
||||
id: string;
|
||||
status: ProductImageTask['status'];
|
||||
provider_task_id: string | null;
|
||||
output_asset_id: string | null;
|
||||
error_code: string | null;
|
||||
provider_error_raw: string | null;
|
||||
failure_source: ProductImageTask['failure_source'];
|
||||
failure_stage: ProductImageTask['failure_stage'];
|
||||
failure_detail: string | null;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductImageTaskService {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
/** 聚合当前商品的图片任务;新增套图任务时在此追加来源,接口结构保持稳定。 */
|
||||
public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const product = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.products WHERE id = $1', [productId],
|
||||
);
|
||||
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
|
||||
const params: unknown[] = [productId];
|
||||
let cursorFilter = '';
|
||||
if (query.cursor) {
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
params.push(cursor.active, cursor.at, cursor.id);
|
||||
cursorFilter = `AND ((e.status IN ('queued', 'running'))::int, e.created_at, e.id)
|
||||
< ($2::int, $3::timestamptz, $4::uuid)`;
|
||||
}
|
||||
params.push(query.limit + 1);
|
||||
const edits = await tx.query<AiEditTaskRow>(
|
||||
`SELECT e.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,
|
||||
o.result ->> 'failure_stage' AS failure_stage,
|
||||
o.result ->> 'failure_detail' AS failure_detail,
|
||||
e.created_at, e.completed_at
|
||||
FROM atlas.media_ai_edits e
|
||||
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||
WHERE e.product_id = $1 ${cursorFilter}
|
||||
ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC
|
||||
LIMIT $${params.length}`, params,
|
||||
);
|
||||
const hasMore = edits.rows.length > query.limit;
|
||||
const rows = edits.rows.slice(0, query.limit);
|
||||
const last = rows.at(-1);
|
||||
const data: ProductImageTask[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: 'single_ai_edit' as const,
|
||||
status: row.status,
|
||||
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
|
||||
total: 1,
|
||||
completed: row.status === 'succeeded' ? 1 : 0,
|
||||
output_asset_ids: row.output_asset_id ? [row.output_asset_id] : [],
|
||||
error_code: row.error_code,
|
||||
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
|
||||
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
|
||||
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
|
||||
provider_error_raw: row.status === 'failed' ? row.provider_error_raw ?? null : null,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
}));
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
has_more: hasMore,
|
||||
next_cursor: hasMore && last ? encodeCursor(last) : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function iso(value: Date | string): string {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function encodeCursor(row: AiEditTaskRow): string {
|
||||
return Buffer.from(JSON.stringify({
|
||||
active: row.status === 'queued' || row.status === 'running' ? 1 : 0,
|
||||
at: iso(row.created_at), id: row.id,
|
||||
})).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(value: string): { active: number; at: string; id: string } {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
if ((decoded.active === 0 || decoded.active === 1)
|
||||
&& typeof decoded.at === 'string' && !Number.isNaN(Date.parse(decoded.at))
|
||||
&& typeof decoded.id === 'string' && /^[0-9a-f-]{36}$/i.test(decoded.id)) {
|
||||
return { active: decoded.active, at: decoded.at, id: decoded.id };
|
||||
}
|
||||
} catch { /* 非法游标按字段错误返回。 */ }
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user