feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
@@ -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,
};
}
}
+166
View File
@@ -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 } } };
});
}
}
+139 -2
View File
@@ -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,
),
};
}
+6 -1
View File
@@ -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 {}
+16 -2
View File
@@ -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,
};
}
}