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