feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuitePlanErrorMessages,
|
||||
createMediaAiEditRequestSchema,
|
||||
type CreateImageSuiteRequest,
|
||||
type ImageSuite,
|
||||
type ImageSuiteInput,
|
||||
type ImageSuitePlanInput,
|
||||
type ImageSuiteShot,
|
||||
type ListProductImageTasksQuery,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } 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 { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
|
||||
type SuiteRow = Omit<
|
||||
ImageSuite,
|
||||
'items' | 'operation' | 'created_at' | 'completed_at'
|
||||
> & { created_at: Date | string; completed_at: Date | string | null };
|
||||
const iso = (value: Date | string) =>
|
||||
value instanceof Date ? value.toISOString() : value;
|
||||
|
||||
@Injectable()
|
||||
export class ImageSuiteService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
|
||||
) {}
|
||||
|
||||
public async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
productId: string,
|
||||
key: string,
|
||||
request:
|
||||
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
|
||||
purpose: 'plan' | 'generate',
|
||||
) {
|
||||
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path, key, request },
|
||||
async (tx) => {
|
||||
const variant = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
|
||||
[request.variant_id, productId],
|
||||
);
|
||||
if (!variant.rows[0])
|
||||
throw new ApiError(
|
||||
404,
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
|
||||
);
|
||||
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`,
|
||||
[request.reference_asset_ids],
|
||||
);
|
||||
if (assets.rows.length !== request.reference_asset_ids.length)
|
||||
throw new ApiError(
|
||||
404,
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'参考素材不存在或不可处理',
|
||||
);
|
||||
const shots =
|
||||
purpose === 'generate'
|
||||
? createImageSuiteSchema.parse(request).shots
|
||||
: [];
|
||||
const { shots: _shots, ...input } =
|
||||
request as CreateImageSuiteRequest;
|
||||
const created = await tx.query<SuiteRow>(
|
||||
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
|
||||
[
|
||||
productId,
|
||||
input.variant_id,
|
||||
purpose,
|
||||
JSON.stringify(input),
|
||||
JSON.stringify(shots),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
const row = created.rows[0];
|
||||
for (const [
|
||||
position,
|
||||
assetId,
|
||||
] of input.reference_asset_ids.entries())
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||
[row.id, assetId, position],
|
||||
);
|
||||
await this.operation(
|
||||
tx,
|
||||
row.id,
|
||||
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||
userId,
|
||||
);
|
||||
if (purpose === 'plan')
|
||||
await this.outbox(
|
||||
tx,
|
||||
'image_suite.plan.requested',
|
||||
row.id,
|
||||
{ suite_id: row.id },
|
||||
);
|
||||
else
|
||||
for (const [index, shot] of shots.entries()) {
|
||||
const supporting = input.reference_asset_ids.slice(1);
|
||||
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
|
||||
const selected = shot.reference_asset_ids ?? [
|
||||
input.reference_asset_ids[0],
|
||||
...Array.from(
|
||||
{ length: Math.min(2, supporting.length) },
|
||||
(_, offset) =>
|
||||
supporting[
|
||||
(index + offset) % supporting.length
|
||||
],
|
||||
),
|
||||
];
|
||||
const settings = createMediaAiEditRequestSchema.parse({
|
||||
product_id: productId,
|
||||
reference_asset_ids: selected.slice(1),
|
||||
mode: 'edit',
|
||||
ratio: input.ratio,
|
||||
target_language: input.language,
|
||||
background:
|
||||
shot.category === 'white' ? 'white' : 'auto',
|
||||
style_set: input.style_set,
|
||||
style_prompt: input.style_prompt,
|
||||
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
|
||||
suite_context: {
|
||||
type: shot.type,
|
||||
product_name: input.product_name,
|
||||
color: input.color,
|
||||
dimensions: input.dimensions,
|
||||
weight: input.weight,
|
||||
collected_info: input.collected_info,
|
||||
selling_points: input.selling_points,
|
||||
},
|
||||
});
|
||||
const id = randomUUID();
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
|
||||
source_asset_id, reference_asset_ids, settings, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
|
||||
[
|
||||
id,
|
||||
productId,
|
||||
row.id,
|
||||
shot.id,
|
||||
index,
|
||||
selected[0],
|
||||
selected.slice(1),
|
||||
JSON.stringify(settings),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
await this.operation(tx, id, 'media_ai_edit', userId);
|
||||
await this.outbox(tx, 'media.ai_edit.requested', id, {
|
||||
edit_id: id,
|
||||
});
|
||||
}
|
||||
return { status: 202, body: { data: this.resource(row) } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async read(
|
||||
workspaceId: string,
|
||||
productId: string,
|
||||
id: string,
|
||||
): Promise<ImageSuite> {
|
||||
const { row, children } = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const row = await this.find(tx, productId, id);
|
||||
const children = await tx.query<{
|
||||
id: string;
|
||||
suite_shot_id: string;
|
||||
}>(
|
||||
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
);
|
||||
return { row, children: children.rows };
|
||||
},
|
||||
);
|
||||
return {
|
||||
...this.resource(row),
|
||||
items: await Promise.all(
|
||||
children.map(async (child) => ({
|
||||
...(await this.edits.read(workspaceId, child.id)),
|
||||
shot_id: child.suite_shot_id,
|
||||
edit_id: child.id,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
public async list(
|
||||
workspaceId: string,
|
||||
productId: string,
|
||||
query: ListProductImageTasksQuery,
|
||||
) {
|
||||
let cursor: { at: string; id: string } | null = null;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
cursor = JSON.parse(
|
||||
Buffer.from(query.cursor, 'base64url').toString(),
|
||||
);
|
||||
if (
|
||||
!cursor ||
|
||||
typeof cursor.at !== 'string' ||
|
||||
typeof cursor.id !== 'string' ||
|
||||
!Number.isFinite(Date.parse(cursor.at)) ||
|
||||
!/^[0-9a-f-]{36}$/i.test(cursor.id)
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||
}
|
||||
}
|
||||
const rows = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const found = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.products WHERE id = $1',
|
||||
[productId],
|
||||
);
|
||||
if (!found.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
return (
|
||||
await tx.query<SuiteRow>(
|
||||
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
|
||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
|
||||
ORDER BY created_at DESC, id DESC LIMIT $4`,
|
||||
[
|
||||
productId,
|
||||
cursor?.at ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
)
|
||||
).rows;
|
||||
},
|
||||
);
|
||||
const page = rows.slice(0, query.limit),
|
||||
last = page.at(-1),
|
||||
hasMore = rows.length > query.limit;
|
||||
return {
|
||||
data: page.map((row) => this.resource(row)),
|
||||
meta: {
|
||||
has_more: hasMore,
|
||||
next_cursor:
|
||||
hasMore && last
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
at: iso(last.created_at),
|
||||
id: last.id,
|
||||
}),
|
||||
).toString('base64url')
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async action(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
productId: string,
|
||||
id: string,
|
||||
key: string,
|
||||
action: 'cancel' | 'retry',
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
|
||||
key,
|
||||
request: {},
|
||||
},
|
||||
async (tx) => {
|
||||
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
|
||||
const children = await tx.query<{ id: string; status: string }>(
|
||||
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
|
||||
[id],
|
||||
);
|
||||
const row = await this.find(tx, productId, id, true);
|
||||
if (action === 'cancel') {
|
||||
if (['queued', 'running'].includes(row.status)) {
|
||||
await tx.query(
|
||||
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
|
||||
[id, children.rows.map((child) => child.id)],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
row.purpose !== 'generate' ||
|
||||
['queued', 'running'].includes(row.status)
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'INVALID_TASK_STATE',
|
||||
'当前任务不能重试,请等待生成完成',
|
||||
);
|
||||
const failed = children.rows.filter((child) =>
|
||||
['failed', 'cancelled'].includes(child.status),
|
||||
);
|
||||
if (failed.length) {
|
||||
await tx.query(
|
||||
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
for (const child of failed) {
|
||||
const attempt = await tx.query<{
|
||||
generation_attempt: number;
|
||||
}>(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
|
||||
[child.id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||
[child.id],
|
||||
);
|
||||
await this.outbox(
|
||||
tx,
|
||||
'media.ai_edit.requested',
|
||||
child.id,
|
||||
{
|
||||
edit_id: child.id,
|
||||
attempt: attempt.rows[0].generation_attempt,
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: this.resource(await this.find(tx, productId, id)),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async find(
|
||||
tx: DatabaseTransaction,
|
||||
productId: string,
|
||||
id: string,
|
||||
lock = false,
|
||||
) {
|
||||
const result = await tx.query<SuiteRow>(
|
||||
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
|
||||
[id, productId],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
|
||||
return result.rows[0];
|
||||
}
|
||||
private resource(row: SuiteRow): ImageSuite {
|
||||
return {
|
||||
id: row.id,
|
||||
product_id: row.product_id,
|
||||
variant_id: row.variant_id,
|
||||
purpose: row.purpose,
|
||||
status: row.status,
|
||||
input: row.input,
|
||||
shots: row.shots as ImageSuiteShot[],
|
||||
items: [],
|
||||
error_code: row.error_code,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
operation: {
|
||||
id: row.id,
|
||||
kind:
|
||||
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||
status: row.status === 'partial' ? 'failed' : row.status,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
error: row.error_code
|
||||
? {
|
||||
code: row.error_code,
|
||||
message:
|
||||
row.purpose === 'plan'
|
||||
? (imageSuitePlanErrorMessages[
|
||||
row.error_code
|
||||
] ??
|
||||
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
|
||||
: '部分图片未完成,可重试失败项',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
private async operation(
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
kind: string,
|
||||
userId: string,
|
||||
) {
|
||||
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, $2, $2, $1, $3)`,
|
||||
[id, kind, userId],
|
||||
);
|
||||
}
|
||||
private async outbox(
|
||||
tx: DatabaseTransaction,
|
||||
event: string,
|
||||
id: string,
|
||||
payload: Record<string, string | number>,
|
||||
dedup = id,
|
||||
) {
|
||||
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, 'image_suite', $1, $2, $3::jsonb, $4)`,
|
||||
[id, event, JSON.stringify(payload), dedup],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
createWatermarkDerivativeRequestSchema,
|
||||
createMediaAiEditRequestSchema,
|
||||
listProductImageTasksQuerySchema,
|
||||
imageSuitePlanInputSchema,
|
||||
createImageSuiteSchema,
|
||||
type CreateMediaAccessUrlRequest,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
@@ -38,6 +40,7 @@ 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';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@@ -50,8 +53,47 @@ export class MediaController {
|
||||
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
|
||||
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
|
||||
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
|
||||
@Inject(ImageSuiteService) private readonly suites: ImageSuiteService,
|
||||
) {}
|
||||
|
||||
@Post('products/:productId/ai/suite-plans')
|
||||
public async planSuite(@Param('productId') productId: 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.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(imageSuitePlanInputSchema, body), 'plan');
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites')
|
||||
public async generateSuite(@Param('productId') productId: 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.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(createImageSuiteSchema, body), 'generate');
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@Get('products/:productId/ai/image-suites')
|
||||
public async listSuites(@Param('productId') productId: string, @Query() query: unknown, @CurrentWorkspace() workspace: Workspace) {
|
||||
return this.suites.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query));
|
||||
}
|
||||
|
||||
@Get('products/:productId/ai/image-suites/:suiteId')
|
||||
public async readSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.suites.read(workspace.id, uuid('product_id', productId), uuid('suite_id', suiteId)) };
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites/:suiteId/cancel')
|
||||
public async cancelSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'cancel')).body;
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites/:suiteId/retry')
|
||||
public async retrySuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'retry')).body;
|
||||
}
|
||||
|
||||
@Get('products/:productId/image-derivatives')
|
||||
public async listProductImageDerivatives(
|
||||
@Param('productId') productId: string,
|
||||
|
||||
@@ -7,10 +7,11 @@ 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';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
@@ -48,6 +48,7 @@ type AssetRow = {
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly s3: S3Client;
|
||||
private readonly legacyMinioS3: S3Client | null;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
public constructor(
|
||||
@@ -58,13 +59,29 @@ export class MediaService {
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
region: config.OBJECT_STORAGE_REGION,
|
||||
forcePathStyle: config.objectStorageForcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.objectStorageAccessKey,
|
||||
secretAccessKey: config.objectStorageSecretKey,
|
||||
},
|
||||
});
|
||||
// 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。
|
||||
this.legacyMinioS3 =
|
||||
config.NODE_ENV !== 'production' &&
|
||||
config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' &&
|
||||
config.MINIO_ROOT_USER &&
|
||||
config.MINIO_ROOT_PASSWORD
|
||||
? new S3Client({
|
||||
endpoint: config.LEGACY_MINIO_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.MINIO_ROOT_USER,
|
||||
secretAccessKey: config.MINIO_ROOT_PASSWORD,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
@@ -273,11 +290,12 @@ export class MediaService {
|
||||
) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT 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`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
@@ -288,16 +306,10 @@ export class MediaService {
|
||||
'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 },
|
||||
const url = await this.signedReadUrl(
|
||||
asset.rows[0],
|
||||
expiresAt,
|
||||
purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
|
||||
);
|
||||
return { url, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
@@ -306,8 +318,8 @@ export class MediaService {
|
||||
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const uploads = await tx.query<AssetRow & { bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
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
|
||||
@@ -326,11 +338,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString()
|
||||
: asset.created_at,
|
||||
preview_url: await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }),
|
||||
{ expiresIn: 600 },
|
||||
),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -387,15 +395,12 @@ export class MediaService {
|
||||
[input.url, assetId, userId],
|
||||
);
|
||||
const asset = await this.requireAsset(tx, assetId);
|
||||
const stored = await tx.query<{ bucket: string; object_key: string }>(
|
||||
'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
||||
'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
[assetId],
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const previewUrl = await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: stored.rows[0].bucket,
|
||||
Key: stored.rows[0].object_key,
|
||||
}), { expiresIn: 600 });
|
||||
const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt);
|
||||
return {
|
||||
status: 201,
|
||||
body: { data: { ...asset, source_url: input.url,
|
||||
@@ -408,11 +413,12 @@ export class MediaService {
|
||||
public async productImports(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const imports = await tx.query<AssetRow & {
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
source_url: string;
|
||||
}>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
`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, i.source_url
|
||||
FROM (
|
||||
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
|
||||
@@ -433,9 +439,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString() : asset.created_at,
|
||||
source_url: asset.source_url,
|
||||
preview_url: await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: asset.bucket, Key: asset.object_key,
|
||||
}), { expiresIn: 600 }),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -560,6 +564,55 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */
|
||||
private async signedReadUrl(
|
||||
asset: { storage_provider: string; bucket: string; object_key: string },
|
||||
expiresAt: Date,
|
||||
downloadName?: string,
|
||||
): Promise<string> {
|
||||
if (
|
||||
asset.storage_provider === 'qiniu_kodo' &&
|
||||
asset.bucket === this.config.OBJECT_STORAGE_BUCKET &&
|
||||
this.config.qiniuDownloadOrigin
|
||||
) {
|
||||
const origin = new URL(this.config.qiniuDownloadOrigin).origin;
|
||||
const path = asset.object_key.split('/').map(encodeURIComponent).join('/');
|
||||
const deadline = Math.floor(expiresAt.getTime() / 1000);
|
||||
const attachment = downloadName
|
||||
? `attname=${encodeURIComponent(downloadName)}&`
|
||||
: '';
|
||||
const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`;
|
||||
const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey)
|
||||
.update(unsignedUrl)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`;
|
||||
}
|
||||
return getSignedUrl(
|
||||
this.storageForPreview(asset),
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: asset.object_key,
|
||||
...(downloadName ? {
|
||||
ResponseContentDisposition: `attachment; filename="${downloadName}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) },
|
||||
);
|
||||
}
|
||||
|
||||
private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client {
|
||||
if (
|
||||
asset.storage_provider === 'local_minio' &&
|
||||
asset.bucket === 'atlas-assets' &&
|
||||
this.legacyMinioS3
|
||||
) {
|
||||
return this.legacyMinioS3;
|
||||
}
|
||||
return this.s3;
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (!this.bucketReady)
|
||||
this.bucketReady = (async () => {
|
||||
|
||||
@@ -16,13 +16,17 @@ type AiEditTaskRow = {
|
||||
failure_detail: string | null;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
kind?: ProductImageTask['kind'];
|
||||
total?: number;
|
||||
completed?: number;
|
||||
output_asset_ids?: string[];
|
||||
};
|
||||
|
||||
@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 }>(
|
||||
@@ -40,14 +44,33 @@ export class ProductImageTaskService {
|
||||
}
|
||||
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,
|
||||
`SELECT e.* FROM (
|
||||
SELECT e.id, e.product_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
|
||||
e.created_at, e.completed_at, 'single_ai_edit' AS kind, 1 AS total,
|
||||
(e.status = 'succeeded')::int AS completed,
|
||||
CASE WHEN e.output_asset_id IS NULL THEN '{}'::uuid[] ELSE ARRAY[e.output_asset_id] END AS output_asset_ids
|
||||
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.suite_id IS NULL
|
||||
UNION ALL
|
||||
SELECT s.id, s.product_id, s.status, NULL::text, NULL::uuid, s.error_code,
|
||||
failure.result ->> 'provider_error_raw', failure.result ->> 'failure_source',
|
||||
failure.result ->> 'failure_stage', failure.result ->> 'failure_detail',
|
||||
s.created_at, s.completed_at, 'ai_suite', jsonb_array_length(s.shots),
|
||||
progress.completed::int, progress.outputs
|
||||
FROM atlas.product_image_suites s
|
||||
LEFT JOIN LATERAL (SELECT count(*) FILTER (WHERE status = 'succeeded') AS completed,
|
||||
coalesce(array_agg(output_asset_id ORDER BY suite_position) FILTER (WHERE output_asset_id IS NOT NULL), '{}'::uuid[]) AS outputs
|
||||
FROM atlas.media_ai_edits WHERE suite_id = s.id) progress ON true
|
||||
LEFT JOIN LATERAL (SELECT o.result FROM atlas.media_ai_edits child JOIN atlas.operations o ON o.id = child.id
|
||||
AND o.workspace_id = child.workspace_id WHERE child.suite_id = s.id AND child.status = 'failed'
|
||||
ORDER BY child.suite_position LIMIT 1) failure ON true
|
||||
WHERE s.purpose = 'generate'
|
||||
) e
|
||||
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,
|
||||
@@ -57,17 +80,17 @@ export class ProductImageTaskService {
|
||||
const last = rows.at(-1);
|
||||
const data: ProductImageTask[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: 'single_ai_edit' as const,
|
||||
kind: row.kind ?? 'single_ai_edit',
|
||||
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] : [],
|
||||
total: row.total ?? 1,
|
||||
completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0),
|
||||
output_asset_ids: row.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,
|
||||
failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null,
|
||||
failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null,
|
||||
failure_detail: ['failed', 'partial'].includes(row.status) ? row.failure_detail ?? null : null,
|
||||
provider_error_raw: ['failed', 'partial'].includes(row.status) ? row.provider_error_raw ?? null : null,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user