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
+2
View File
@@ -13,6 +13,7 @@ import { CollectionModule } from './collection/collection.module.js';
import { ExtensionModule } from './extension/extension.module.js';
import { ProductEditorModule } from './product-editor/product-editor.module.js';
import { PlatformCatalogAdminModule } from './platform-catalog-admin/platform-catalog-admin.module.js';
import { OnlineProductsModule } from './online-products/online-products.module.js';
@Controller()
class HealthController {
@@ -53,6 +54,7 @@ class HealthController {
ExtensionModule,
ProductEditorModule,
PlatformCatalogAdminModule,
OnlineProductsModule,
],
controllers: [HealthController],
})
+16 -3
View File
@@ -321,12 +321,14 @@ export class ExtensionService {
await tx.query(
`UPDATE atlas.product_variants
SET source_sku = $1, display_name = $2, sort_order = $3,
purchase_cost_cny = COALESCE(ROUND($4::numeric, 2), purchase_cost_cny),
deleted_at = NULL, updated_at = now()
WHERE id = $4`,
WHERE id = $5`,
[
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
variantId,
],
);
@@ -334,8 +336,8 @@ export class ExtensionService {
} else {
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order, purchase_cost_cny)
VALUES ($1, $2, $3, $4, $5, $6, ROUND($7::numeric, 2)) RETURNING id`,
[
workspaceId,
productId,
@@ -343,6 +345,7 @@ export class ExtensionService {
variant.sourceSku ?? null,
variant.displayName,
index,
sourcePurchaseCost(payload.source.platform, variant),
],
);
variantId = inserted.rows[0].id;
@@ -486,3 +489,13 @@ function sourceState(payload: CollectedProductPayload) {
sourcePrice: payload.common.sourcePrice,
};
}
/** 仅将 1688 明确标为人民币的逐 SKU 来源价写入采购价。 */
function sourcePurchaseCost(
platform: CollectedProductPayload['source']['platform'],
variant: CollectedProductPayload['variants'][number] | { sourceVariantKey: null },
) {
return platform === '1688' && 'sourcePrice' in variant && variant.sourcePrice?.currencyCode === 'CNY'
? variant.sourcePrice.amount
: null;
}
@@ -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,
};
}
}
@@ -0,0 +1,191 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { z } from 'zod';
import {
listOnlineProductsQuerySchema,
listOnlineProductSyncRunsQuerySchema,
onlineProductSyncRequestSchema,
type Workspace,
} from '@atlas/contracts';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { OnlineProductsService } from './online-products.service.js';
type HttpResponse = { status: (status: number) => HttpResponse };
@ApiTags('Online products')
@ApiBearerAuth('access-token')
@Controller('platform-listings')
export class OnlineProductsController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Get()
@ApiOperation({ summary: '按店铺和 SKU 查询 Ozon 商品镜像' })
@ApiOkResponse({ description: '商品列表及最近同步状态' })
public list(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Query() query: unknown,
) {
return this.service.list(
workspace.id,
platformId(header),
parse(listOnlineProductsQuerySchema, query),
);
}
@Get(':listingId')
@ApiOperation({ summary: '读取一个在线 SKU 的业务摘要' })
public async get(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('listingId') listingId: string,
) {
return {
data: await this.service.get(
workspace.id,
platformId(header),
uuid('listing_id', listingId),
),
};
}
}
@ApiTags('Online product sync')
@ApiBearerAuth('access-token')
@Controller('stores/:storeId/platform-product-sync-runs')
export class StoreOnlineProductSyncController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Post()
@ApiOperation({ summary: '手动发起该店铺的全量 Ozon 商品同步' })
@ApiAcceptedResponse({ description: '已创建异步同步任务' })
public async create(
@Headers('x-atlas-platform-id') header: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Param('storeId') storeId: string,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: HttpResponse,
) {
parse(onlineProductSyncRequestSchema, body ?? {});
const result = await this.service.createSync(
workspace.id,
request.auth.userId,
platformId(header),
uuid('store_id', storeId),
requireKey(key),
);
response.status(result.status);
return result.body;
}
@Get()
@ApiOperation({ summary: '查询店铺同步历史' })
public list(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('storeId') storeId: string,
@Query() query: unknown,
) {
return this.service.listRuns(
workspace.id,
platformId(header),
uuid('store_id', storeId),
parse(listOnlineProductSyncRunsQuerySchema, query),
);
}
}
@ApiTags('Online product sync')
@ApiBearerAuth('access-token')
@Controller('platform-product-sync-runs')
export class OnlineProductSyncRunsController {
public constructor(
@Inject(OnlineProductsService)
private readonly service: OnlineProductsService,
) {}
@Get(':runId')
@ApiOperation({ summary: '查询一次在线商品同步的进度和结果' })
public async get(
@Headers('x-atlas-platform-id') header: string | undefined,
@CurrentWorkspace() workspace: Workspace,
@Param('runId') runId: string,
) {
return {
data: await this.service.getRun(
workspace.id,
platformId(header),
uuid('sync_run_id', runId),
),
};
}
}
function platformId(value: string | undefined): string {
if (!value)
throw new ApiError(
400,
'PLATFORM_CONTEXT_REQUIRED',
'请求缺少 X-Atlas-Platform-Id',
);
return uuid('X-Atlas-Platform-Id', value);
}
function requireKey(value: string | undefined): string {
if (!value)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'请求缺少 Idempotency-Key',
);
return uuid('Idempotency-Key', value);
}
function uuid(field: string, value: string): string {
if (z.uuid().safeParse(value).success) return value;
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (result.success) return result.data;
throw new ApiError(
422,
'VALIDATION_FAILED',
'请求参数不符合要求',
result.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: 'INVALID_FIELD',
message: issue.message,
})),
);
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
OnlineProductsController,
OnlineProductSyncRunsController,
StoreOnlineProductSyncController,
} from './online-products.controller.js';
import { OnlineProductsService } from './online-products.service.js';
@Module({
controllers: [
OnlineProductsController,
StoreOnlineProductSyncController,
OnlineProductSyncRunsController,
],
providers: [OnlineProductsService, IdempotencyService],
})
export class OnlineProductsModule {}
@@ -0,0 +1,415 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
ListOnlineProductsQuery,
ListOnlineProductSyncRunsQuery,
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
} from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
type StoreRow = {
id: string;
name: string;
platform_id: string;
status: string;
credential_status: string;
};
type ListingRow = Omit<
OnlineProduct,
| 'image'
| 'stock_summary'
| 'last_synced_at'
| 'missing_since'
| 'archived_at'
> & {
image_summary: OnlineProduct['image'];
stock_summary: OnlineProduct['stock_summary'];
last_synced_at: Date | string;
missing_since: Date | string | null;
archived_at: Date | string | null;
updated_at: Date | string;
};
type RunRow = Omit<
OnlineProductSyncRun,
'created_at' | 'started_at' | 'completed_at' | 'error'
> & {
created_at: Date | string;
started_at: Date | string | null;
completed_at: Date | string | null;
error_payload: { code?: string; message?: string } | null;
};
const listingSelect = `SELECT l.id, l.store_id, l.platform_id, l.remote_product_id, l.offer_id,
l.remote_sku, l.name, l.currency_code, round(l.selling_price, 2)::numeric(18,2)::text AS selling_price,
round(l.list_price, 2)::numeric(18,2)::text AS list_price, l.visible, l.remote_state, l.moderation_status,
l.validation_status, l.stock_summary, l.image_summary, l.error_payload AS errors,
l.visibility_details, l.source, l.last_synced_at, l.missing_since, l.archived_at, l.updated_at,
CASE WHEN origin.id IS NULL THEN NULL ELSE jsonb_build_object(
'collection_item_id', product.collection_item_id, 'product_id', origin.product_id,
'variant_id', origin.product_variant_id) END AS atlas_origin
FROM atlas.platform_listings l
LEFT JOIN atlas.product_variant_platform_listings origin ON origin.id = l.atlas_variant_listing_id
LEFT JOIN atlas.products product ON product.id = origin.product_id`;
const runSelect = `SELECT id, store_id, status, trigger_source, cursor_started, cursor_finished,
listed_count, upserted_count, failed_count, created_at, started_at, completed_at, error_payload
FROM atlas.platform_product_sync_runs`;
@Injectable()
export class OnlineProductsService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
public list(
workspaceId: string,
platformId: string,
query: ListOnlineProductsQuery,
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.resolveStore(
tx,
platformId,
query.store_id,
);
const params: unknown[] = [store.id, platformId];
const filters = ['l.store_id = $1', 'l.platform_id = $2'];
if (!query.include_archived) filters.push('l.archived_at IS NULL');
if (query.q) {
params.push(`%${query.q.replace(/[\\%_]/g, '\\$&')}%`);
filters.push(
`(l.name ILIKE $${params.length} ESCAPE '\\' OR l.offer_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_product_id ILIKE $${params.length} ESCAPE '\\' OR l.remote_sku ILIKE $${params.length} ESCAPE '\\')`,
);
}
if (query.visibility !== 'all') {
filters.push(
query.visibility === 'unknown'
? 'l.visible IS NULL'
: `l.visible = ${query.visibility === 'visible' ? 'true' : 'false'}`,
);
}
if (query.source) {
params.push(query.source);
filters.push(`l.source = $${params.length}`);
}
if (query.moderation_status) {
params.push(query.moderation_status);
filters.push(`l.moderation_status = $${params.length}`);
}
if (query.cursor) {
const cursor = decodeCursor(query.cursor);
params.push(cursor.at, cursor.id);
filters.push(
`(l.updated_at, l.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`,
);
}
params.push(query.limit + 1);
const result = await tx.query<ListingRow>(
`${listingSelect} WHERE ${filters.join(' AND ')}
ORDER BY l.updated_at DESC, l.id DESC LIMIT $${params.length}`,
params,
);
const hasMore = result.rows.length > query.limit;
const rows = result.rows.slice(0, query.limit);
const last = rows.at(-1);
const sync = await tx.query<{
last_successful_sync_at: Date | string | null;
last_sync_status: OnlineProductsMeta['last_sync_status'];
}>(
`SELECT (SELECT completed_at FROM atlas.platform_product_sync_runs
WHERE store_id = $1 AND status = 'succeeded' ORDER BY completed_at DESC LIMIT 1) AS last_successful_sync_at,
(SELECT status FROM atlas.platform_product_sync_runs
WHERE store_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1) AS last_sync_status`,
[store.id],
);
return {
data: rows.map(toListing),
meta: {
next_cursor:
hasMore && last
? encodeCursor(last.updated_at, last.id)
: null,
has_more: hasMore,
store: { id: store.id, name: store.name },
last_successful_sync_at: iso(
sync.rows[0]?.last_successful_sync_at ?? null,
),
last_sync_status: sync.rows[0]?.last_sync_status ?? null,
} satisfies OnlineProductsMeta,
};
});
}
public get(workspaceId: string, platformId: string, listingId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.assertPlatform(tx, platformId);
const result = await tx.query<ListingRow>(
`${listingSelect} WHERE l.id = $1 AND l.platform_id = $2`,
[listingId, platformId],
);
if (!result.rows[0])
throw new ApiError(404, 'LISTING_NOT_FOUND', '在线商品不存在');
return toListing(result.rows[0]);
});
}
public async createSync(
workspaceId: string,
userId: string,
platformId: string,
storeId: string,
key: string,
) {
try {
return await this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/stores/${storeId}/platform-product-sync-runs`,
key,
request: {
reason: 'manual_refresh',
platform_id: platformId,
},
},
async (tx) => {
const store = await this.resolveStore(
tx,
platformId,
storeId,
);
const platform = await tx.query<{ code: string }>(
`SELECT code FROM atlas.platforms WHERE id = $1`,
[platformId],
);
if (platform.rows[0]?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'PLATFORM_UNAVAILABLE',
'当前平台暂不支持商品同步',
);
if (
store.status !== 'active' ||
store.credential_status !== 'valid'
)
throw new ApiError(
422,
'STORE_CREDENTIAL_UNAVAILABLE',
'店铺凭据暂不可用于同步',
);
const secret = await tx.query<{ id: string }>(
`SELECT id FROM atlas.store_secrets WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
[store.id],
);
if (!secret.rows[0])
throw new ApiError(
422,
'STORE_CREDENTIAL_UNAVAILABLE',
'店铺凭据暂不可用于同步',
);
const active = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') FOR UPDATE`,
[store.id],
);
if (active.rows[0])
throw new ApiError(
409,
'SYNC_ALREADY_RUNNING',
'该店铺已有正在进行的商品同步',
[{ sync_run_id: active.rows[0].id }],
);
const created = await tx.query<RunRow>(
`INSERT INTO atlas.platform_product_sync_runs (workspace_id, store_id, platform_id, trigger_source)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, 'manual') RETURNING id, store_id,
status, trigger_source, cursor_started, cursor_finished, listed_count, upserted_count,
failed_count, created_at, started_at, completed_at, error_payload`,
[store.id, platformId],
);
const run = toRun(created.rows[0]);
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, 'platform_product_sync_run', $1,
'platform_product_sync.requested', $2, $1::uuid::text)`,
[
run.id,
{
sync_run_id: run.id,
store_id: store.id,
platform_id: platformId,
secret_id: secret.rows[0].id,
},
],
);
return { status: 202, body: { data: { sync_run: run } } };
},
);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'constraint' in error &&
error.constraint === 'ux_platform_product_sync_runs_active'
) {
const active = await this.database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query<{ id: string }>(
`SELECT id FROM atlas.platform_product_sync_runs WHERE store_id = $1 AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`,
[storeId],
),
);
throw new ApiError(
409,
'SYNC_ALREADY_RUNNING',
'该店铺已有正在进行的商品同步',
active.rows[0] ? [{ sync_run_id: active.rows[0].id }] : [],
);
}
throw error;
}
}
public getRun(workspaceId: string, platformId: string, runId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.assertPlatform(tx, platformId);
const result = await tx.query<RunRow>(
`${runSelect} WHERE id = $1 AND platform_id = $2`,
[runId, platformId],
);
if (!result.rows[0])
throw new ApiError(404, 'SYNC_RUN_NOT_FOUND', '同步记录不存在');
return toRun(result.rows[0]);
});
}
public listRuns(
workspaceId: string,
platformId: string,
storeId: string,
query: ListOnlineProductSyncRunsQuery,
) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
await this.resolveStore(tx, platformId, storeId);
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
const result = await tx.query<RunRow>(
`${runSelect} WHERE store_id = $1 AND platform_id = $2
AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))
ORDER BY created_at DESC, id DESC LIMIT $5`,
[
storeId,
platformId,
cursor?.at ?? null,
cursor?.id ?? null,
query.limit + 1,
],
);
const hasMore = result.rows.length > query.limit;
const rows = result.rows.slice(0, query.limit);
const last = rows.at(-1);
return {
data: rows.map(toRun),
meta: {
next_cursor:
hasMore && last
? encodeCursor(last.created_at, last.id)
: null,
has_more: hasMore,
},
};
});
}
private async assertPlatform(tx: DatabaseTransaction, platformId: string) {
const platform = await tx.query<{ id: string }>(
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active'`,
[platformId],
);
if (!platform.rows[0])
throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用');
}
private async resolveStore(
tx: DatabaseTransaction,
platformId: string,
storeId?: string,
): Promise<StoreRow> {
await this.assertPlatform(tx, platformId);
const result = await tx.query<StoreRow>(
`SELECT id, name, platform_id, status, credential_status FROM atlas.stores
WHERE platform_id = $1 AND deleted_at IS NULL AND ${storeId ? 'id = $2' : 'is_default = true'}`,
storeId ? [platformId, storeId] : [platformId],
);
if (!result.rows[0])
throw new ApiError(
storeId ? 404 : 422,
storeId ? 'STORE_NOT_FOUND' : 'DEFAULT_STORE_REQUIRED',
storeId ? '店铺不存在' : '请先设置当前平台的默认店铺',
);
return result.rows[0];
}
}
function toListing(row: ListingRow): OnlineProduct {
const { image_summary, updated_at: _updatedAt, ...rest } = row;
return {
...rest,
stock_summary: row.stock_summary ?? { total: 0, has_data: false },
image: image_summary ?? { url: null, width_px: null, height_px: null },
errors: row.errors ?? [],
visibility_details: row.visibility_details ?? {},
last_synced_at: iso(row.last_synced_at)!,
missing_since: iso(row.missing_since),
archived_at: iso(row.archived_at),
};
}
function toRun(row: RunRow): OnlineProductSyncRun {
const { error_payload, ...rest } = row;
return {
...rest,
created_at: iso(row.created_at)!,
started_at: iso(row.started_at),
completed_at: iso(row.completed_at),
error: error_payload
? {
code: error_payload.code ?? 'SYNC_FAILED',
message: error_payload.message ?? '商品同步失败',
}
: null,
};
}
function iso(value: Date | string | null): string | null {
return value ? new Date(value).toISOString() : null;
}
function encodeCursor(at: Date | string, id: string): string {
return Buffer.from(JSON.stringify({ at: iso(at), id })).toString(
'base64url',
);
}
function decodeCursor(value: string): { at: string; id: string } {
try {
const decoded = JSON.parse(
Buffer.from(value, 'base64url').toString('utf8'),
) as { at?: unknown; id?: unknown };
if (
typeof decoded.at === 'string' &&
!Number.isNaN(Date.parse(decoded.at)) &&
typeof decoded.id === 'string' &&
/^[0-9a-f-]{36}$/i.test(decoded.id)
)
return { at: decoded.at, id: decoded.id };
} catch {
/* 非法游标统一转成字段校验错误。 */
}
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
@@ -237,7 +237,7 @@ export class ProductEditorService {
'missing' | 'ambiguous' | 'confirmed' | null;
}>(
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
l.title, l.description, l.tags, l.status, l.selling_price::text,
l.list_price::text, l.currency_code, v.specification_values,
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
@@ -1395,6 +1395,21 @@ export class ProductEditorService {
)
: { rows: [] };
const importedIds = new Set(imported.rows.map((item) => item.asset_id));
const processed = assetIds.length
? await transaction.query<{ asset_id: string }>(
`SELECT output_asset_id AS asset_id
FROM atlas.media_aspect_ratio_derivatives
WHERE product_id = $1 AND status = 'succeeded'
AND output_asset_id = ANY($2::uuid[])
UNION
SELECT output_asset_id AS asset_id
FROM atlas.media_watermark_derivatives
WHERE product_id = $1 AND status = 'succeeded'
AND output_asset_id = ANY($2::uuid[])`,
[input.product.id, assetIds],
)
: { rows: [] };
const processedIds = new Set(processed.rows.map((item) => item.asset_id));
const candidateVariants = new Map<string, Set<string | null>>();
for (const candidate of candidates.rows) {
const variants =
@@ -1435,6 +1450,7 @@ export class ProductEditorService {
continue;
if (uploadedIds.has(assetId)) continue;
if (importedIds.has(assetId)) continue;
if (processedIds.has(assetId)) continue;
const allowedFor = candidateVariants.get(assetId);
if (
!allowedFor?.has(null) &&
@@ -1443,7 +1459,7 @@ export class ProductEditorService {
throw new ApiError(
422,
'LISTING_MEDIA_NOT_SELECTABLE',
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片',
'只能选择当前商品可用的采集、上传、导入或图片处理结果',
);
}
}
@@ -28,6 +28,12 @@ export class WatermarksController {
return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query));
}
@Get('watermark-templates/default')
@ApiOperation({ summary: '读取当前工作空间的默认水印模板' })
public async getDefault(@CurrentWorkspace() workspace: Workspace) {
return { data: await this.watermarks.getDefault(workspace.id) };
}
@Get('watermark-templates/:templateId')
@ApiOperation({ summary: '读取水印模板当前版本' })
public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) {
@@ -51,6 +57,15 @@ export class WatermarksController {
idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body;
}
@Put('watermark-templates/:templateId/default')
@ApiOkResponse({ description: '已设为当前工作空间的默认水印' })
public async setDefault(@Param('templateId') templateId: string,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
return (await this.watermarks.setDefault(workspace.id, request.auth.userId,
uuid('template_id', templateId), idempotencyKey(key))).body;
}
@Delete('watermark-templates/:templateId')
@HttpCode(204)
@ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' })
+172 -11
View File
@@ -1,4 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import {
WATERMARK_BORDER_RADIUS_MAX_PX,
WATERMARK_IMAGE_SIZE_MAX_PX,
WATERMARK_IMAGE_SIZE_MIN_PX,
} from '@atlas/contracts';
import type {
CreateWatermarkTemplateRequest,
ListWatermarkTemplatesQuery,
@@ -13,6 +18,7 @@ type TemplateRow = {
id: string;
name: string;
kind: 'text' | 'image';
is_default: boolean;
current_version: number;
created_at: Date | string;
updated_at: Date | string;
@@ -23,7 +29,11 @@ type TemplateRow = {
font_size_px: number | null;
opacity: string;
scale_ratio: string | null;
image_size_px: number | null;
border_radius_px: number;
position: string;
position_x: string;
position_y: string;
};
type TemplateConfig = Record<string, unknown>;
@@ -81,6 +91,59 @@ export class WatermarksService {
);
}
public async getDefault(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const rows = await tx.query<TemplateRow>(
`${templateSelect} WHERE template.is_default = true
AND template.status = 'active' AND template.deleted_at IS NULL`,
);
return rows.rows[0] ? toTemplate(rows.rows[0]) : null;
});
}
public async setDefault(
workspaceId: string,
userId: string,
templateId: string,
idempotencyKey: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'PUT',
path: `/api/watermark-templates/${templateId}/default`,
key: idempotencyKey,
request: {},
},
async (tx) => {
// 同一工作空间的切换操作串行化,避免两个请求同时争用唯一默认索引。
await tx.query(
`SELECT id FROM atlas.workspaces
WHERE id = current_setting('app.workspace_id')::uuid FOR UPDATE`,
);
const current = await this.lockTemplate(tx, templateId);
if (!current.is_default) {
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = false
WHERE is_default = true AND status = 'active' AND deleted_at IS NULL`,
);
await tx.query(
`UPDATE atlas.watermark_templates
SET is_default = true
WHERE id = $1`,
[templateId],
);
}
return {
status: 200,
body: { data: await this.requireTemplate(tx, templateId) },
};
},
);
}
public async create(
workspaceId: string,
userId: string,
@@ -97,7 +160,8 @@ export class WatermarksService {
request: input,
},
async (tx) => {
await this.validateConfig(tx, input.kind, input.config);
const config = normalizeImageConfig(input.kind, normalizePlacement(input.config), input.config);
await this.validateConfig(tx, input.kind, config);
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
@@ -108,7 +172,7 @@ export class WatermarksService {
inserted.rows[0].id,
1,
input.kind,
input.config,
config,
userId,
);
return {
@@ -158,10 +222,10 @@ export class WatermarksService {
);
}
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
const nextConfig = normalizeConfig(nextKind, {
const nextConfig = normalizeConfig(nextKind, normalizeImageConfig(nextKind, normalizePlacement({
...configFromRow(current),
...(input.config ?? {}),
});
}, input.config), input.config));
await this.validateConfig(tx, nextKind, nextConfig);
const renderingChanged =
input.config !== undefined || nextKind !== current.kind;
@@ -217,7 +281,7 @@ export class WatermarksService {
[templateId],
);
await tx.query(
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(),
`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), is_default = false,
revision = revision + 1, updated_at = now() WHERE id = $1`,
[templateId],
);
@@ -312,7 +376,9 @@ export class WatermarksService {
) {
if (
typeof config.opacity !== 'string' ||
typeof config.position !== 'string'
typeof config.position !== 'string' ||
!validCoordinate(config.position_x) ||
!validCoordinate(config.position_y)
) {
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
}
@@ -344,7 +410,9 @@ export class WatermarksService {
const assetId = config.image_asset_id;
if (
typeof assetId !== 'string' ||
typeof config.scale_ratio !== 'string'
typeof config.scale_ratio !== 'string' ||
!validImageSize(config.image_size_px) ||
!validBorderRadius(config.border_radius_px)
) {
throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
}
@@ -371,8 +439,8 @@ export class WatermarksService {
) {
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
(workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position_x, position_y, position, created_by_user_id, image_size_px, border_radius_px)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[
templateId,
version,
@@ -383,17 +451,23 @@ export class WatermarksService {
config.font_size_px ?? null,
config.opacity,
config.scale_ratio ?? null,
config.position_x,
config.position_y,
config.position,
userId,
config.image_size_px ?? null,
config.border_radius_px ?? 0,
],
);
}
}
const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version,
const templateSelect = `SELECT template.id, template.name, template.kind, template.is_default, template.current_version,
template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id,
version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio,
version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
version.image_size_px, version.border_radius_px,
version.position, version.position_x::text AS position_x, version.position_y::text AS position_y
FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version
ON version.template_id = template.id AND version.version = template.current_version`;
function toTemplate(row: TemplateRow) {
@@ -401,6 +475,7 @@ function toTemplate(row: TemplateRow) {
id: row.id,
name: row.name,
kind: row.kind,
is_default: row.is_default,
current_version: row.current_version,
config: configFromRow(row),
created_at: iso(row.created_at),
@@ -416,12 +491,18 @@ function configFromRow(row: TemplateRow): TemplateConfig {
font_size_px: row.font_size_px,
opacity: row.opacity,
position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
}
: {
image_asset_id: row.image_asset_id,
scale_ratio: row.scale_ratio,
image_size_px: row.image_size_px,
border_radius_px: row.border_radius_px,
opacity: row.opacity,
position: row.position,
position_x: Number(row.position_x),
position_y: Number(row.position_y),
};
}
function normalizeConfig(
@@ -435,14 +516,94 @@ function normalizeConfig(
font_size_px: config.font_size_px,
opacity: config.opacity,
position: config.position,
position_x: config.position_x,
position_y: config.position_y,
}
: {
image_asset_id: config.image_asset_id,
scale_ratio: config.scale_ratio,
image_size_px: config.image_size_px,
border_radius_px: config.border_radius_px,
opacity: config.opacity,
position: config.position,
position_x: config.position_x,
position_y: config.position_y,
};
}
const legacyPositions = [
'top_left', 'top_center', 'top_right',
'middle_left', 'center', 'middle_right',
'bottom_left', 'bottom_center', 'bottom_right',
] as const;
function validCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
}
function validImageSize(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= WATERMARK_IMAGE_SIZE_MIN_PX
&& Number(value) <= WATERMARK_IMAGE_SIZE_MAX_PX;
}
function validBorderRadius(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0
&& Number(value) <= WATERMARK_BORDER_RADIUS_MAX_PX;
}
function legacyImageSize(value: unknown): number | undefined {
const ratio = Number(value);
if (!Number.isFinite(ratio)) return undefined;
return Math.min(WATERMARK_IMAGE_SIZE_MAX_PX,
Math.max(WATERMARK_IMAGE_SIZE_MIN_PX, Math.round(ratio * 144)));
}
function normalizeImageConfig(
kind: 'text' | 'image', config: TemplateConfig, patch?: Record<string, unknown>,
): TemplateConfig {
if (kind !== 'image') return config;
const legacyOnly = patch?.scale_ratio !== undefined && patch.image_size_px === undefined;
const size = legacyOnly
? legacyImageSize(patch?.scale_ratio)
: config.image_size_px ?? legacyImageSize(config.scale_ratio);
const scaleRatio = legacyOnly || !validImageSize(size)
? config.scale_ratio
: String(Number(Math.max(0.1, size / 144).toFixed(4)));
return {
...config,
image_size_px: size,
border_radius_px: config.border_radius_px ?? 0,
// 旧比例字段只供旧客户端读取;像素尺寸决定新预览与后续渲染。
scale_ratio: scaleRatio,
};
}
function legacyCoordinates(position: unknown) {
const index = legacyPositions.indexOf(position as typeof legacyPositions[number]);
if (index < 0) return null;
return { x: (index % 3) / 2, y: Math.floor(index / 3) / 2 };
}
function normalizePlacement(config: TemplateConfig, patch?: Record<string, unknown>): TemplateConfig {
const legacyOnly = patch?.position !== undefined
&& patch.position_x === undefined && patch.position_y === undefined;
const legacy = legacyCoordinates(config.position);
const x = legacyOnly ? legacy?.x : config.position_x ?? legacy?.x;
const y = legacyOnly ? legacy?.y : config.position_y ?? legacy?.y;
if (!validCoordinate(x) || !validCoordinate(y)) return { ...config, position_x: x, position_y: y };
const positionX = Math.round(x * 10_000) / 10_000;
const positionY = Math.round(y * 10_000) / 10_000;
const col = Math.min(2, Math.floor(positionX * 3));
const row = Math.min(2, Math.floor(positionY * 3));
return {
...config,
position_x: positionX,
position_y: positionY,
// 旧字段仅供兼容展示;精确坐标始终决定实际水印位置。
position: legacyPositions[row * 3 + col],
};
}
function iso(value: Date | string) {
return value instanceof Date ? value.toISOString() : value;
}
+64 -1
View File
@@ -179,6 +179,69 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
assert.equal(first.body.data.materialization.pending_selection, 1);
assert.equal(first.body.data.materialization.queued, 0);
const source1688 = {
source: {
platform: '1688', itemKey: '1688-price-image-test',
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
},
common: { attributes: [], categories: [] },
variantAxes: [],
variants: [{
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
displayName: '六代目卡卡西25cm', selections: [],
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
}],
assets: [{
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
relation: 'axis_value', variantSourceKey: '6100454176961',
sortOrder: 0, evidenceRef: '1688:sku-map',
}],
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
};
const captured1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify(source1688),
});
assert.equal(captured1688.response.status, 201);
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
FROM atlas.product_variants v
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
[captured1688.body.data.product_id],
),
);
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
const refreshed1688 = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
...source1688,
variants: [{
...source1688.variants[0],
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
}],
}),
});
assert.equal(refreshed1688.response.status, 200);
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ purchase_cost_cny: string }>(
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
[captured1688.body.data.product_id],
),
);
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${webAccessToken}` },
});
@@ -328,7 +391,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
variant.source_variant_key === '123456789',
);
assert.equal(originalVariant.display_name, '人工确认 SKU');
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
assert.equal(originalVariant.purchase_cost_cny, '88.80');
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
(variant: { source_variant_key: string }) =>
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('单张 AI 编辑任务', () => {
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('SELECT id FROM atlas.media_assets'))
return { rows: [{ id: sourceId }, { id: referenceId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new MediaAiEditService(
{} as ConstructorParameters<typeof MediaAiEditService>[0],
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
.rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledTimes(2);
});
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
});
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: editId, status: 'failed', source_asset_id: sourceId,
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{} as ConstructorParameters<typeof MediaAiEditService>[2],
);
const result = await service.read(workspaceId, editId);
expect(result).toMatchObject({
failure_source: 'atlas', failure_stage: 'result_processing',
failure_detail: 'Pcore 结果图片无效',
});
});
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
const query = vi.fn(async (_sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
return { rows: [{
id: editId, status: 'succeeded', source_asset_id: sourceId,
output_asset_id: referenceId, error_code: null,
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
}] };
});
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
const service = new MediaAiEditService(
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
{} as ConstructorParameters<typeof MediaAiEditService>[1],
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
);
const items = await service.list(workspaceId, productId);
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('一键 3:4 派生任务', () => {
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
const result = await service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
);
expect(result).toMatchObject({
status: 202,
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
});
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('缺少或跨工作空间的源素材不会创建任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await expect(service.create(
workspaceId,
'user-id',
sourceAssetId,
'idempotency-key',
{ preset: '3:4', fit: 'contain' },
)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('将当前工作空间的商品关联到新派生任务', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new AspectRatioDerivativeService(
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ preset: '3:4', fit: 'contain', product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
});
});
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import {
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
} from '@atlas/contracts';
@@ -55,6 +56,35 @@ function png(width: number, height: number) {
}
describe('商品图片上传策略', () => {
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const query = vi.fn(async () => ({
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
}));
const workspaceTransaction = vi.fn(async (
_workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }));
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
const preview = await service.accessUrl('workspace-id', assetId);
const download = await service.accessUrl('workspace-id', assetId, 'download');
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
expect(new URL(download.url).searchParams.get('response-content-disposition'))
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
});
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [],
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
let token = '';
describe('水印派生 API 集成测试', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const response = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `watermark-derivative-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(response.status, 201);
const body = await response.json() as { data: {
user: { id: string }; workspace: { id: string }; access_token: string;
} };
userId = body.data.user.id;
workspaceId = body.data.workspace.id;
token = body.data.access_token;
});
afterAll(async () => {
if (workspaceId) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
});
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
}
await app?.close();
});
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
const item = await tx.query<{ id: string }>(
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
VALUES ($1, 'manual', $2) RETURNING id`,
[workspaceId, `watermark-history-${randomUUID()}`],
);
const product = await tx.query<{ id: string }>(
`INSERT INTO atlas.products (workspace_id, collection_item_id)
VALUES ($1, $2) RETURNING id`,
[workspaceId, item.rows[0].id],
);
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 120, 100) RETURNING id`,
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
const template = await tx.query<{ id: string }>(
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
VALUES ($1, $2, 'text') RETURNING id`,
[workspaceId, `集成测试水印 ${randomUUID()}`],
);
await tx.query(
`INSERT INTO atlas.watermark_template_versions
(workspace_id, template_id, version, kind, text_content, font_code,
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
'bottom_right', 1, 1, $3)`,
[workspaceId, template.rows[0].id, userId],
);
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
});
const key = randomUUID();
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
method: 'POST', headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
},
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
});
const response = await create();
assert.equal(response.status, 202);
const body = await response.json() as { data: {
id: string; watermark_template_id: string; watermark_template_version: number;
} };
assert.equal(body.data.watermark_template_id, templateId);
assert.equal(body.data.watermark_template_version, 1);
const replay = await create();
assert.equal(replay.status, 202);
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(read.status, 200);
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
tx.query<{ product_id: string }>(
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
[body.data.id],
),
);
assert.equal(stored.rows[0]?.product_id, productId);
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (const kind of ['watermark', 'ratio']) {
const asset = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
$3, 1200, 1600) RETURNING id`,
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
);
ids.push(asset.rows[0].id);
}
await tx.query(
`INSERT INTO atlas.media_watermark_derivatives
(workspace_id, product_id, source_asset_id, watermark_template_id,
watermark_template_version, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
[workspaceId, productId, assetId, templateId, ids[0]],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, productId, assetId, ids[1]],
);
return ids;
});
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(history.status, 200);
const records = (await history.json() as { data: Array<{
kind: string; output_asset: { id: string; preview_url: string };
}> }).data;
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
assert.ok(records.every((record) => record.output_asset.preview_url));
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarkDerivativeService(
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
);
return { service, run };
}
describe('水印派生任务', () => {
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
return { rows: [] };
});
const { service, run } = serviceWithQuery(query);
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId });
expect(result).toMatchObject({ status: 202, body: { data: {
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
} } });
expect(run).toHaveBeenCalledOnce();
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
});
it('拒绝不可用模板且不创建派生任务', async () => {
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
const { service } = serviceWithQuery(query);
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
});
it('将当前工作空间的商品关联到水印结果', async () => {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
return { rows: [{ id: derivativeId }] };
}
return { rows: [] };
});
const { service } = serviceWithQuery(query);
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
{ watermark_template_id: templateId, product_id: productId });
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
});
});
+258
View File
@@ -0,0 +1,258 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let database: Database;
let baseUrl: string;
let token = '';
let workspaceId = '';
let userId = '';
let platformId = '';
let storeId = '';
let listingId = '';
let runId = '';
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return { response, body: (await response.json()) as Record<string, any> };
}
function auth(extra: Record<string, string> = {}) {
return {
Authorization: `Bearer ${token}`,
'X-Atlas-Platform-Id': platformId,
...extra,
};
}
describe('在线商品 HTTP 契约', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
const created = await createApplication();
app = created.app;
database = app.get<Database>(DATABASE);
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `online-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registered.response.status, 201);
token = registered.body.data.access_token;
workspaceId = registered.body.data.workspace.id;
userId = registered.body.data.user.id;
const platforms = await request('/api/platforms', {
headers: { Authorization: `Bearer ${token}` },
});
platformId = platforms.body.data.find(
(platform: { code: string }) => platform.code === 'OZON',
).id;
storeId = randomUUID();
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
[storeId, workspaceId, platformId, `online-http-${storeId}`],
);
const run = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_sync_runs
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
[workspaceId, storeId, platformId],
);
runId = run.rows[0].id;
const listing = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
currency_code, selling_price, visible, moderation_status, stock_summary,
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
[workspaceId, storeId, platformId, runId],
);
listingId = listing.rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_listings
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
missing_since, archived_at, raw_payload)
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
now(), now() - interval '8 days', now(), '{}')`,
[workspaceId, storeId, platformId, runId],
);
});
});
afterAll(async () => {
if (workspaceId && database) {
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.stores WHERE workspace_id = $1',
[workspaceId],
);
});
await database.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
[userId],
);
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
workspaceId,
]);
await database.query('DELETE FROM atlas.users WHERE id = $1', [
userId,
]);
}
if (app) await app.close();
});
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
const listed = await request(
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
{ headers: auth() },
);
assert.equal(listed.response.status, 200);
assert.equal(listed.body.data.length, 1);
assert.equal(listed.body.data[0].visible, true);
assert.equal(listed.body.data[0].selling_price, '99.00');
assert.deepEqual(listed.body.data[0].stock_summary, {
total: 0,
has_data: true,
});
assert.equal(listed.body.meta.store.id, storeId);
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
assert.equal('raw_payload' in listed.body.data[0], false);
const detail = await request(`/api/platform-listings/${listingId}`, {
headers: auth(),
});
assert.equal(detail.response.status, 200);
assert.equal(detail.body.data.offer_id, 'test-offer');
const runs = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{ headers: auth() },
);
assert.equal(runs.response.status, 200);
assert.equal(runs.body.data[0].id, runId);
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
headers: auth(),
});
assert.equal(run.response.status, 200);
assert.equal(run.body.data.status, 'succeeded');
});
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
const unknown = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
{ headers: auth() },
);
assert.equal(unknown.response.status, 200);
assert.equal(unknown.body.data.length, 0);
const archived = await request(
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
{ headers: auth() },
);
assert.equal(archived.response.status, 200);
assert.equal(archived.body.data.length, 1);
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
const missingHeader = await request('/api/platform-listings', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(missingHeader.response.status, 400);
assert.equal(
missingHeader.body.error.code,
'PLATFORM_CONTEXT_REQUIRED',
);
const noCredentials = await request(
`/api/stores/${storeId}/platform-product-sync-runs`,
{
method: 'POST',
headers: auth({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ reason: 'manual_refresh' }),
},
);
assert.equal(noCredentials.response.status, 422);
assert.equal(
noCredentials.body.error.code,
'STORE_CREDENTIAL_UNAVAILABLE',
);
});
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.store_secrets
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
encryption_key_version, fingerprint, status)
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
),
);
const key = randomUUID();
const post = (idempotencyKey: string) =>
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
method: 'POST',
headers: auth({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify({ reason: 'manual_refresh' }),
});
const created = await post(key);
assert.equal(created.response.status, 202);
assert.equal(created.body.data.sync_run.status, 'queued');
const repeated = await post(key);
assert.equal(repeated.response.status, 202);
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
const concurrent = await post(randomUUID());
assert.equal(concurrent.response.status, 409);
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
const events = await database.workspaceTransaction(workspaceId, (tx) =>
tx.query<{ count: string }>(
`SELECT count(*)::text AS count FROM atlas.outbox_events
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
[created.body.data.sync_run.id],
),
);
assert.equal(events.rows[0].count, '1');
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductEditorService } from '../src/product-editor/product-editor.service.js';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
function mediaTransaction(processed: boolean) {
const query = vi.fn(async (sql: string, values?: unknown[]) => {
if (sql.includes('FROM atlas.product_variant_platform_listings'))
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
if (sql.includes('FROM atlas.media_assets'))
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
expect(values).toEqual([productId, [assetId]]);
return { rows: processed ? [{ asset_id: assetId }] : [] };
}
return { rows: [] };
});
return { query };
}
describe('SKU 图片处理结果的保存资格', () => {
const input = {
product: { id: productId, collection_item_id: 'collection-id', source_snapshot_revision: 1 },
profile: { id: 'profile-id' },
variants: [{ variantId, assetIds: [assetId] }],
};
const service = new ProductEditorService(
{} as ConstructorParameters<typeof ProductEditorService>[0],
{} as ConstructorParameters<typeof ProductEditorService>[1],
) as unknown as {
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
};
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
const tx = mediaTransaction(true);
await service.persistListingMedia(tx, input);
expect(tx.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.product_variant_listing_media'))).toBe(true);
});
it('其他商品或未成功的处理结果仍不能加入 SKU', async () => {
const tx = mediaTransaction(false);
await expect(service.persistListingMedia(tx, input)).rejects.toMatchObject({
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
});
});
});
+77
View File
@@ -132,6 +132,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
[workspaceId],
@@ -1985,6 +1989,79 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
});
it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => {
const before = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string;
const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => {
const output = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600)
RETURNING id`,
[workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)],
);
await tx.query(
`INSERT INTO atlas.media_aspect_ratio_derivatives
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
[workspaceId, first.productId, sourceAssetId, output.rows[0].id],
);
return output.rows[0].id;
});
const snapshot = before.body.data;
const payload = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
common: { ...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec },
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment,
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
platform_product_category_id: snapshot.profile.category.id,
category_source: snapshot.profile.category.source,
},
attributes: snapshot.attributes.map((attribute: any) => ({
scope: attribute.scope, variant_id: attribute.variant_id,
platform_attribute_id: attribute.platform_attribute_id,
complex_id: attribute.complex_id, values: attribute.values,
language_code: attribute.language_code, source: attribute.source,
confirmation_status: attribute.confirmation_status,
})),
variants: snapshot.variants.map((item: any) => ({
id: item.id, client_key: null, copy_from_variant_id: null,
display_name: item.display_name, source_sku: item.source_sku,
specification_values: item.specification_values,
purchase_cost_cny: item.purchase_cost_cny,
tags: item.tags, description: item.description,
physical_spec: item.physical_spec, package_spec: item.package_spec,
offer_id: item.listing.offer_id,
media_asset_ids: item.id === snapshot.variants[0].id
? [outputAssetId] : item.media.map((media: any) => media.asset_id),
})),
};
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.equal(saved.response.status, 200);
const after = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId);
});
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
const created = await createCollectionItem('独立保存描述测试商品');
const entered = await request(
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageDerivativeHistoryService } from '../src/media/product-image-derivative-history.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片处理记录', () => {
it('只读取当前商品成功的 3:4 与水印结果,并重新签发预览地址', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values).toEqual([productId]);
if (sql.includes('FROM atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain("WHERE product_id = $1 AND status = 'succeeded'");
expect(sql).toContain('LIMIT 100');
return { rows: [
{
id: 'watermark-task', kind: 'watermark', source_asset_id: 'source-1',
output_asset_id: 'output-1', created_at: new Date('2026-09-27T08:00:00Z'),
content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T08:01:00Z'),
},
{
id: 'ratio-task', kind: 'aspect_ratio_3_4', source_asset_id: 'source-2',
output_asset_id: 'output-2', created_at: new Date('2026-09-27T07:00:00Z'),
content_type: 'image/jpeg', byte_size: '1100', width_px: 1200, height_px: 1600,
asset_created_at: new Date('2026-09-27T07:01:00Z'),
},
] };
});
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const accessUrl = vi.fn(async (_workspace: string, assetId: string) => ({
url: `https://example.test/${assetId}`, expires_at: '2026-09-27T08:10:00Z',
}));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{ accessUrl } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
const records = await service.list(workspaceId, productId);
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(records).toMatchObject([
{ kind: 'watermark', output_asset: { id: 'output-1', preview_url: 'https://example.test/output-1' } },
{ kind: 'aspect_ratio_3_4', output_asset: { id: 'output-2', preview_url: 'https://example.test/output-2' } },
]);
expect(accessUrl).toHaveBeenCalledTimes(2);
});
it('商品不在当前工作空间时返回 404,不查询图片记录', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageDerivativeHistoryService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
{} as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
);
await expect(service.list(workspaceId, productId)).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
describe('商品图片任务列表', () => {
it('按工作空间和商品读取任务,结果只包含可公开的任务字段', async () => {
const query = vi.fn(async (sql: string, values: unknown[]) => {
expect(values[0]).toBe(productId);
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(values).toEqual([productId, 3]);
return { rows: [{
id: 'edit-id', status: 'running', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: null,
created_at: new Date('2026-09-26T03:00:00Z'), completed_at: null,
}, {
id: 'completed-id', status: 'succeeded', provider_task_id: 'private-provider-id',
output_asset_id: 'asset-id', error_code: null,
created_at: new Date('2026-09-26T02:00:00Z'), completed_at: new Date('2026-09-26T02:05:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 2 });
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
expect(result.meta).toEqual({ has_more: false, next_cursor: null });
expect(result.data).toEqual([{
id: 'edit-id', kind: 'single_ai_edit', status: 'running', phase: 'waiting_provider',
total: 1, completed: 0, output_asset_ids: [], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T03:00:00.000Z', completed_at: null,
}, {
id: 'completed-id', kind: 'single_ai_edit', status: 'succeeded', phase: null,
total: 1, completed: 1, output_asset_ids: ['asset-id'], error_code: null, provider_error_raw: null,
failure_source: null, failure_stage: null, failure_detail: null,
created_at: '2026-09-26T02:00:00.000Z', completed_at: '2026-09-26T02:05:00.000Z',
}]);
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('失败任务返回 Pcore 原始错误,不暴露供应商任务 ID', async () => {
const raw = '{"message":"请求无法用于生成图像","type":"image_task_error","code":"failed"}';
const query = vi.fn(async (sql: string) => {
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
expect(sql).toContain('o.workspace_id = e.workspace_id');
return { rows: [{
id: 'failed-id', status: 'failed', provider_task_id: 'private-provider-id',
output_asset_id: null, error_code: 'AI_IMAGE_PROVIDER_FAILED', provider_error_raw: raw,
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
created_at: new Date('2026-09-27T03:00:00Z'), completed_at: new Date('2026-09-27T03:01:00Z'),
}] };
});
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const result = await service.list(workspaceId, productId, { limit: 20 });
expect(result.data[0].provider_error_raw).toBe(raw);
expect(result.data[0]).toMatchObject({
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
});
expect(JSON.stringify(result)).not.toContain('private-provider-id');
});
it('商品不属于当前工作空间时返回 404,不查询任务', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
await expect(service.list(workspaceId, productId, { limit: 20 })).rejects.toMatchObject({ status: 404 });
expect(query).toHaveBeenCalledOnce();
});
it('超过页大小时返回游标,并将游标作为下一页的稳定排序条件', async () => {
const id = '68db226f-3fcb-4579-ab9e-4932b1d84067';
const rows = [0, 1, 2].map((index) => ({
id, status: 'succeeded', provider_task_id: null, output_asset_id: null,
error_code: null, created_at: new Date(`2026-09-26T0${3 - index}:00:00Z`),
completed_at: null,
}));
const query = vi.fn(async (sql: string, _values?: unknown[]) => ({ rows: sql.includes('atlas.products') ? [{ id: productId }] : rows }));
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
const service = new ProductImageTaskService(
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
);
const first = await service.list(workspaceId, productId, { limit: 2 });
expect(first.meta.has_more).toBe(true);
expect(first.meta.next_cursor).toEqual(expect.any(String));
await service.list(workspaceId, productId, { limit: 2, cursor: first.meta.next_cursor! });
const nextQuery = query.mock.calls.at(-1)!;
expect(nextQuery[0]).toContain('e.created_at, e.id)');
expect(nextQuery[1]).toEqual([productId, 0, '2026-09-26T02:00:00.000Z', id, 3]);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const templateRow = (isDefault: boolean) => ({
id: templateId,
name: '品牌水印',
kind: 'text' as const,
is_default: isDefault,
current_version: 1,
created_at: new Date('2026-09-25T00:00:00Z'),
updated_at: new Date('2026-09-25T00:00:00Z'),
revision: 1,
text_content: 'Atlas',
image_asset_id: null,
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.2600',
scale_ratio: null,
image_size_px: null,
border_radius_px: 0,
position: 'center',
position_x: '0.5000',
position_y: '0.5000',
});
describe('工作空间默认水印', () => {
it('切换默认后返回新状态,重复设置不会再写入', async () => {
let isDefault = false;
const statements: string[] = [];
const query = vi.fn(async (sql: string) => {
statements.push(sql);
if (sql.includes('SELECT id FROM atlas.workspaces')) return { rows: [{ id: 'workspace' }] };
if (sql.includes('SELECT template.id')) return { rows: [templateRow(isDefault)] };
if (sql.includes('SET is_default = false')) return { rows: [] };
if (sql.includes('SET is_default = true')) {
isDefault = true;
return { rows: [] };
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
const first = await service.setDefault('workspace', 'user', templateId, 'key-1');
expect(first).toMatchObject({ status: 200, body: { data: { id: templateId, is_default: true } } });
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
expect(statements[0]).toContain('FOR UPDATE');
const second = await service.setDefault('workspace', 'user', templateId, 'key-2');
expect(second).toMatchObject({ status: 200, body: { data: { is_default: true } } });
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
expect(run).toHaveBeenCalledWith(expect.objectContaining({
method: 'PUT',
path: `/api/watermark-templates/${templateId}/default`,
}), expect.any(Function));
});
it('未设置默认时返回 null', async () => {
const query = vi.fn(async () => ({ rows: [] }));
const workspaceTransaction = vi.fn(async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{} as ConstructorParameters<typeof WatermarksService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<typeof WatermarksService>[1],
);
expect(await service.getDefault('workspace')).toBeNull();
expect(query.mock.calls).toHaveLength(1);
expect(workspaceTransaction).toHaveBeenCalledWith('workspace', expect.any(Function));
});
it('删除默认模板时同时清除默认标记', async () => {
const query = vi.fn(async (sql: string) => {
if (sql.includes('SELECT template.id')) return { rows: [templateRow(true)] };
return { rows: [] };
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
expect(await service.remove('workspace', 'user', templateId, 'key')).toEqual({ status: 204, body: null });
expect(query.mock.calls.some(([sql]) => sql.includes("status = 'archived'")
&& sql.includes('is_default = false'))).toBe(true);
});
});
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest';
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const assetId = '54799515-028d-4320-80a2-f53fd13fb588';
const imageRow = (size: number, radius: number, version = 1) => ({
id: templateId,
name: '图片水印',
kind: 'image' as const,
current_version: version,
created_at: new Date('2026-09-26T00:00:00Z'),
updated_at: new Date('2026-09-26T00:00:00Z'),
revision: version,
text_content: null,
image_asset_id: assetId,
font_code: null,
font_size_px: null,
opacity: '0.2600',
scale_ratio: '0.5556',
image_size_px: size,
border_radius_px: radius,
position: 'bottom_right',
position_x: '1.0000',
position_y: '1.0000',
});
const serviceWith = (query: ReturnType<typeof vi.fn>) => {
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
return new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
};
describe('图片水印像素尺寸和圆角', () => {
it('创建契约接收像素尺寸并约束尺寸和圆角范围,同时兼容旧比例', () => {
const base = {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, opacity: '0.26', position_x: 1, position_y: 1 },
};
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 12 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 10 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, scale_ratio: '0.25' },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse(base).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 9 },
}).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 201 },
}).success).toBe(false);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 101 },
}).success).toBe(false);
});
it('创建时保存绝对尺寸和圆角,并保留旧比例字段', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(Number(inserted[13]), Number(inserted[14]))] };
throw new Error(`未知 SQL: ${sql}`);
});
const result = await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, image_size_px: 80, border_radius_px: 12,
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.5556');
expect(inserted.slice(13, 15)).toEqual([80, 12]);
expect(result).toMatchObject({ body: { data: { config: {
image_size_px: 80, border_radius_px: 12,
} } } });
});
it('旧版比例输入换算为像素尺寸,圆角默认为零', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(36, 0)] };
throw new Error(`未知 SQL: ${sql}`);
});
await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, scale_ratio: '0.25',
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.25');
expect(inserted.slice(13, 15)).toEqual([36, 0]);
});
it('10px 尺寸原值保存,兼容比例保持旧版允许的最小值', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(10, 0)] };
throw new Error(`未知 SQL: ${sql}`);
});
await serviceWith(query).create('workspace', 'user', 'key', {
name: '图片水印', kind: 'image',
config: { image_asset_id: assetId, image_size_px: 10,
opacity: '0.26', position_x: 1, position_y: 1 },
});
expect(inserted[8]).toBe('0.1');
expect(inserted[13]).toBe(10);
});
it('只修改圆角时保留图片尺寸并创建新版本', async () => {
let inserted: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT template.id') && sql.includes('FOR UPDATE')) return { rows: [imageRow(80, 0)] };
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
if (sql.includes('UPDATE atlas.watermark_templates')) return { rows: [] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
inserted = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [imageRow(80, 16, 2)] };
throw new Error(`未知 SQL: ${sql}`);
});
const result = await serviceWith(query).update(
'workspace', 'user', templateId, 'key', 1, { config: { border_radius_px: 16 } },
);
expect(inserted[13]).toBe(80);
expect(inserted[14]).toBe(16);
expect(result).toMatchObject({ body: { data: { current_version: 2, config: {
image_size_px: 80, border_radius_px: 16,
} } } });
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
const templateRow = (x: number, y: number, position: string) => ({
id: templateId,
name: '测试水印',
kind: 'text' as const,
current_version: 1,
created_at: new Date('2026-09-25T00:00:00Z'),
updated_at: new Date('2026-09-25T00:00:00Z'),
revision: 1,
text_content: 'Atlas',
image_asset_id: null,
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.2600',
scale_ratio: null,
position,
position_x: x.toFixed(4),
position_y: y.toFixed(4),
});
describe('水印任意位置坐标', () => {
it('契约接收精确坐标、兼容旧位置,并拒绝创建时只给一个坐标', () => {
const base = {
name: '测试水印',
kind: 'text',
config: {
text_content: 'Atlas',
font_code: 'NotoSans',
font_size_px: 15,
opacity: '0.26',
},
};
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position_x: 0.3737, position_y: 0.6161 },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position: 'bottom_right' },
}).success).toBe(true);
expect(createWatermarkTemplateRequestSchema.safeParse({
...base, config: { ...base.config, position_x: 0.3737 },
}).success).toBe(false);
});
it('创建时保存精确坐标并在读取响应中返回,不吸附九宫格', async () => {
let insertedVersion: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
insertedVersion = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [templateRow(
Number(insertedVersion[9]), Number(insertedVersion[10]), String(insertedVersion[11]),
)] };
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
const result = await service.create('workspace', 'user', 'key', {
name: '测试水印', kind: 'text',
config: {
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
opacity: '0.26', position_x: 0.3737, position_y: 0.6161,
},
});
expect(insertedVersion.slice(9, 12)).toEqual([0.3737, 0.6161, 'center']);
expect(result).toMatchObject({
body: { data: { config: { position_x: 0.3737, position_y: 0.6161 } } },
});
});
it('旧版九宫格输入换算为精确坐标', async () => {
let insertedVersion: unknown[] = [];
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
insertedVersion = args ?? [];
return { rows: [] };
}
if (sql.includes('SELECT template.id')) return { rows: [templateRow(1, 0, 'top_right')] };
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
work({ query }));
const service = new WatermarksService(
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
{} as ConstructorParameters<typeof WatermarksService>[1],
);
await service.create('workspace', 'user', 'key', {
name: '测试水印', kind: 'text',
config: {
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
opacity: '0.26', position: 'top_right',
},
});
expect(insertedVersion.slice(9, 12)).toEqual([1, 0, 'top_right']);
});
});