feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -33,10 +33,24 @@ const environmentSchema = z.object({
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
OBJECT_STORAGE_REGION: z.string().min(1).default('us-east-1'),
|
||||
OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'),
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
|
||||
OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(),
|
||||
OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(),
|
||||
OZON_IMAGE_DOWNLOAD_ORIGIN: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.url().optional(),
|
||||
),
|
||||
QINIU_ACCESS_KEY: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.string().min(3).optional(),
|
||||
),
|
||||
QINIU_SECRET_KEY: z.preprocess(
|
||||
(value) => value === '' ? undefined : value,
|
||||
z.string().min(8).optional(),
|
||||
),
|
||||
LEGACY_MINIO_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
MINIO_ROOT_USER: z.string().min(3).optional(),
|
||||
MINIO_ROOT_PASSWORD: z.string().min(8).optional(),
|
||||
AUTH_JWT_SECRET: z.string().min(32),
|
||||
@@ -114,6 +128,9 @@ export type AppConfig = Omit<
|
||||
objectStorageAccessKey: string;
|
||||
objectStorageSecretKey: string;
|
||||
objectStorageForcePathStyle: boolean;
|
||||
qiniuDownloadOrigin: string | null;
|
||||
qiniuDownloadAccessKey: string;
|
||||
qiniuDownloadSecretKey: string;
|
||||
pricingAllowDraftRates: boolean;
|
||||
};
|
||||
|
||||
@@ -139,6 +156,22 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required',
|
||||
);
|
||||
}
|
||||
const qiniuDownloadOrigin = environment.OZON_IMAGE_DOWNLOAD_ORIGIN ?? null;
|
||||
if (environment.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo') {
|
||||
if (!qiniuDownloadOrigin) {
|
||||
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN is required for Qiniu image previews');
|
||||
}
|
||||
const origin = new URL(qiniuDownloadOrigin);
|
||||
if (
|
||||
origin.protocol !== 'https:' || origin.username || origin.password ||
|
||||
origin.search || origin.hash || origin.pathname !== '/'
|
||||
) {
|
||||
throw new Error('OZON_IMAGE_DOWNLOAD_ORIGIN must be an HTTPS origin');
|
||||
}
|
||||
}
|
||||
if (Boolean(environment.QINIU_ACCESS_KEY) !== Boolean(environment.QINIU_SECRET_KEY)) {
|
||||
throw new Error('QINIU_ACCESS_KEY and QINIU_SECRET_KEY must be configured together');
|
||||
}
|
||||
return {
|
||||
...environment,
|
||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||
@@ -148,6 +181,9 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||
objectStorageAccessKey,
|
||||
objectStorageSecretKey,
|
||||
qiniuDownloadOrigin,
|
||||
qiniuDownloadAccessKey: environment.QINIU_ACCESS_KEY ?? objectStorageAccessKey,
|
||||
qiniuDownloadSecretKey: environment.QINIU_SECRET_KEY ?? objectStorageSecretKey,
|
||||
objectStorageForcePathStyle:
|
||||
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||
// 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuitePlanErrorMessages,
|
||||
createMediaAiEditRequestSchema,
|
||||
type CreateImageSuiteRequest,
|
||||
type ImageSuite,
|
||||
type ImageSuiteInput,
|
||||
type ImageSuitePlanInput,
|
||||
type ImageSuiteShot,
|
||||
type ListProductImageTasksQuery,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
|
||||
type SuiteRow = Omit<
|
||||
ImageSuite,
|
||||
'items' | 'operation' | 'created_at' | 'completed_at'
|
||||
> & { created_at: Date | string; completed_at: Date | string | null };
|
||||
const iso = (value: Date | string) =>
|
||||
value instanceof Date ? value.toISOString() : value;
|
||||
|
||||
@Injectable()
|
||||
export class ImageSuiteService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(MediaAiEditService) private readonly edits: MediaAiEditService,
|
||||
) {}
|
||||
|
||||
public async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
productId: string,
|
||||
key: string,
|
||||
request:
|
||||
ImageSuiteInput | ImageSuitePlanInput | CreateImageSuiteRequest,
|
||||
purpose: 'plan' | 'generate',
|
||||
) {
|
||||
const path = `/api/products/${productId}/ai/${purpose === 'plan' ? 'suite-plans' : 'image-suites'}`;
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path, key, request },
|
||||
async (tx) => {
|
||||
const variant = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_variants WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL',
|
||||
[request.variant_id, productId],
|
||||
);
|
||||
if (!variant.rows[0])
|
||||
throw new ApiError(
|
||||
404,
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'目标 SKU 不存在或不属于此商品,请先保存新 SKU',
|
||||
);
|
||||
const assets = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = ANY($1::uuid[])
|
||||
AND content_type IN ('image/jpeg', 'image/png', 'image/webp')
|
||||
AND byte_size <= 20971520 AND width_px BETWEEN 1 AND 10000 AND height_px BETWEEN 1 AND 10000`,
|
||||
[request.reference_asset_ids],
|
||||
);
|
||||
if (assets.rows.length !== request.reference_asset_ids.length)
|
||||
throw new ApiError(
|
||||
404,
|
||||
'RESOURCE_NOT_FOUND',
|
||||
'参考素材不存在或不可处理',
|
||||
);
|
||||
const shots =
|
||||
purpose === 'generate'
|
||||
? createImageSuiteSchema.parse(request).shots
|
||||
: [];
|
||||
const { shots: _shots, ...input } =
|
||||
request as CreateImageSuiteRequest;
|
||||
const created = await tx.query<SuiteRow>(
|
||||
`INSERT INTO atlas.product_image_suites (workspace_id, product_id, variant_id, purpose, input, shots, created_by_user_id)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::jsonb, $5::jsonb, $6) RETURNING *`,
|
||||
[
|
||||
productId,
|
||||
input.variant_id,
|
||||
purpose,
|
||||
JSON.stringify(input),
|
||||
JSON.stringify(shots),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
const row = created.rows[0];
|
||||
for (const [
|
||||
position,
|
||||
assetId,
|
||||
] of input.reference_asset_ids.entries())
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_image_suite_references (workspace_id, suite_id, asset_id, position)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||||
[row.id, assetId, position],
|
||||
);
|
||||
await this.operation(
|
||||
tx,
|
||||
row.id,
|
||||
purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||
userId,
|
||||
);
|
||||
if (purpose === 'plan')
|
||||
await this.outbox(
|
||||
tx,
|
||||
'image_suite.plan.requested',
|
||||
row.id,
|
||||
{ suite_id: row.id },
|
||||
);
|
||||
else
|
||||
for (const [index, shot] of shots.entries()) {
|
||||
const supporting = input.reference_asset_ids.slice(1);
|
||||
// 默认方案轮换全部辅助底图,避免后面的参考角度始终未被使用。
|
||||
const selected = shot.reference_asset_ids ?? [
|
||||
input.reference_asset_ids[0],
|
||||
...Array.from(
|
||||
{ length: Math.min(2, supporting.length) },
|
||||
(_, offset) =>
|
||||
supporting[
|
||||
(index + offset) % supporting.length
|
||||
],
|
||||
),
|
||||
];
|
||||
const settings = createMediaAiEditRequestSchema.parse({
|
||||
product_id: productId,
|
||||
reference_asset_ids: selected.slice(1),
|
||||
mode: 'edit',
|
||||
ratio: input.ratio,
|
||||
target_language: input.language,
|
||||
background:
|
||||
shot.category === 'white' ? 'white' : 'auto',
|
||||
style_set: input.style_set,
|
||||
style_prompt: input.style_prompt,
|
||||
prompt: `${shot.theme}\n${shot.description}\n资料依据:${shot.evidence}`,
|
||||
suite_context: {
|
||||
type: shot.type,
|
||||
product_name: input.product_name,
|
||||
color: input.color,
|
||||
dimensions: input.dimensions,
|
||||
weight: input.weight,
|
||||
collected_info: input.collected_info,
|
||||
selling_points: input.selling_points,
|
||||
},
|
||||
});
|
||||
const id = randomUUID();
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_ai_edits (id, workspace_id, product_id, suite_id, suite_shot_id, suite_position,
|
||||
source_asset_id, reference_asset_ids, settings, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, $5, $6, $7::uuid[], $8::jsonb, $9)`,
|
||||
[
|
||||
id,
|
||||
productId,
|
||||
row.id,
|
||||
shot.id,
|
||||
index,
|
||||
selected[0],
|
||||
selected.slice(1),
|
||||
JSON.stringify(settings),
|
||||
userId,
|
||||
],
|
||||
);
|
||||
await this.operation(tx, id, 'media_ai_edit', userId);
|
||||
await this.outbox(tx, 'media.ai_edit.requested', id, {
|
||||
edit_id: id,
|
||||
});
|
||||
}
|
||||
return { status: 202, body: { data: this.resource(row) } };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async read(
|
||||
workspaceId: string,
|
||||
productId: string,
|
||||
id: string,
|
||||
): Promise<ImageSuite> {
|
||||
const { row, children } = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const row = await this.find(tx, productId, id);
|
||||
const children = await tx.query<{
|
||||
id: string;
|
||||
suite_shot_id: string;
|
||||
}>(
|
||||
'SELECT id, suite_shot_id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
);
|
||||
return { row, children: children.rows };
|
||||
},
|
||||
);
|
||||
return {
|
||||
...this.resource(row),
|
||||
items: await Promise.all(
|
||||
children.map(async (child) => ({
|
||||
...(await this.edits.read(workspaceId, child.id)),
|
||||
shot_id: child.suite_shot_id,
|
||||
edit_id: child.id,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
public async list(
|
||||
workspaceId: string,
|
||||
productId: string,
|
||||
query: ListProductImageTasksQuery,
|
||||
) {
|
||||
let cursor: { at: string; id: string } | null = null;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
cursor = JSON.parse(
|
||||
Buffer.from(query.cursor, 'base64url').toString(),
|
||||
);
|
||||
if (
|
||||
!cursor ||
|
||||
typeof cursor.at !== 'string' ||
|
||||
typeof cursor.id !== 'string' ||
|
||||
!Number.isFinite(Date.parse(cursor.at)) ||
|
||||
!/^[0-9a-f-]{36}$/i.test(cursor.id)
|
||||
)
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||
}
|
||||
}
|
||||
const rows = await this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) => {
|
||||
const found = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.products WHERE id = $1',
|
||||
[productId],
|
||||
);
|
||||
if (!found.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
||||
return (
|
||||
await tx.query<SuiteRow>(
|
||||
`SELECT * FROM atlas.product_image_suites WHERE product_id = $1
|
||||
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
|
||||
ORDER BY created_at DESC, id DESC LIMIT $4`,
|
||||
[
|
||||
productId,
|
||||
cursor?.at ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
)
|
||||
).rows;
|
||||
},
|
||||
);
|
||||
const page = rows.slice(0, query.limit),
|
||||
last = page.at(-1),
|
||||
hasMore = rows.length > query.limit;
|
||||
return {
|
||||
data: page.map((row) => this.resource(row)),
|
||||
meta: {
|
||||
has_more: hasMore,
|
||||
next_cursor:
|
||||
hasMore && last
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
at: iso(last.created_at),
|
||||
id: last.id,
|
||||
}),
|
||||
).toString('base64url')
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async action(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
productId: string,
|
||||
id: string,
|
||||
key: string,
|
||||
action: 'cancel' | 'retry',
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/products/${productId}/ai/image-suites/${id}/${action}`,
|
||||
key,
|
||||
request: {},
|
||||
},
|
||||
async (tx) => {
|
||||
// 与 Worker 一致先锁子任务再锁父任务,避免取消与结果回写形成相反锁序。
|
||||
const children = await tx.query<{ id: string; status: string }>(
|
||||
'SELECT id, status FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY id FOR UPDATE',
|
||||
[id],
|
||||
);
|
||||
const row = await this.find(tx, productId, id, true);
|
||||
if (action === 'cancel') {
|
||||
if (['queued', 'running'].includes(row.status)) {
|
||||
await tx.query(
|
||||
"UPDATE atlas.product_image_suites SET status = 'cancelled', completed_at = now() WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'cancelled', completed_at = now() WHERE suite_id = $1 AND status IN ('queued', 'running')",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'cancelled', completed_at = now() WHERE (id = $1 OR id = ANY($2::uuid[])) AND status IN ('queued', 'running')",
|
||||
[id, children.rows.map((child) => child.id)],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
row.purpose !== 'generate' ||
|
||||
['queued', 'running'].includes(row.status)
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'INVALID_TASK_STATE',
|
||||
'当前任务不能重试,请等待生成完成',
|
||||
);
|
||||
const failed = children.rows.filter((child) =>
|
||||
['failed', 'cancelled'].includes(child.status),
|
||||
);
|
||||
if (failed.length) {
|
||||
await tx.query(
|
||||
"UPDATE atlas.product_image_suites SET status = 'queued', error_code = NULL, completed_at = NULL WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'queued', error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||
[id],
|
||||
);
|
||||
for (const child of failed) {
|
||||
const attempt = await tx.query<{
|
||||
generation_attempt: number;
|
||||
}>(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'queued', provider_task_id = NULL, error_code = NULL, completed_at = NULL, started_at = NULL, created_at = now(), generation_attempt = generation_attempt + 1 WHERE id = $1 RETURNING generation_attempt",
|
||||
[child.id],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.operations SET status = 'queued', result = '{}'::jsonb, error_code = NULL, error_message = NULL, completed_at = NULL WHERE id = $1",
|
||||
[child.id],
|
||||
);
|
||||
await this.outbox(
|
||||
tx,
|
||||
'media.ai_edit.requested',
|
||||
child.id,
|
||||
{
|
||||
edit_id: child.id,
|
||||
attempt: attempt.rows[0].generation_attempt,
|
||||
},
|
||||
randomUUID(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: this.resource(await this.find(tx, productId, id)),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async find(
|
||||
tx: DatabaseTransaction,
|
||||
productId: string,
|
||||
id: string,
|
||||
lock = false,
|
||||
) {
|
||||
const result = await tx.query<SuiteRow>(
|
||||
`SELECT * FROM atlas.product_image_suites WHERE id = $1 AND product_id = $2 ${lock ? 'FOR UPDATE' : ''}`,
|
||||
[id, productId],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '套图任务不存在');
|
||||
return result.rows[0];
|
||||
}
|
||||
private resource(row: SuiteRow): ImageSuite {
|
||||
return {
|
||||
id: row.id,
|
||||
product_id: row.product_id,
|
||||
variant_id: row.variant_id,
|
||||
purpose: row.purpose,
|
||||
status: row.status,
|
||||
input: row.input,
|
||||
shots: row.shots as ImageSuiteShot[],
|
||||
items: [],
|
||||
error_code: row.error_code,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
operation: {
|
||||
id: row.id,
|
||||
kind:
|
||||
row.purpose === 'plan' ? 'image_suite_plan' : 'image_suite',
|
||||
status: row.status === 'partial' ? 'failed' : row.status,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
error: row.error_code
|
||||
? {
|
||||
code: row.error_code,
|
||||
message:
|
||||
row.purpose === 'plan'
|
||||
? (imageSuitePlanErrorMessages[
|
||||
row.error_code
|
||||
] ??
|
||||
imageSuitePlanErrorMessages.AI_SUITE_PLAN_FAILED)
|
||||
: '部分图片未完成,可重试失败项',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
private async operation(
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
kind: string,
|
||||
userId: string,
|
||||
) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.operations (id, workspace_id, kind, resource_type, resource_id, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $2, $1, $3)`,
|
||||
[id, kind, userId],
|
||||
);
|
||||
}
|
||||
private async outbox(
|
||||
tx: DatabaseTransaction,
|
||||
event: string,
|
||||
id: string,
|
||||
payload: Record<string, string | number>,
|
||||
dedup = id,
|
||||
) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'image_suite', $1, $2, $3::jsonb, $4)`,
|
||||
[id, event, JSON.stringify(payload), dedup],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
createWatermarkDerivativeRequestSchema,
|
||||
createMediaAiEditRequestSchema,
|
||||
listProductImageTasksQuerySchema,
|
||||
imageSuitePlanInputSchema,
|
||||
createImageSuiteSchema,
|
||||
type CreateMediaAccessUrlRequest,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
@@ -38,6 +40,7 @@ import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@@ -50,8 +53,47 @@ export class MediaController {
|
||||
@Inject(MediaAiEditService) private readonly aiEdits: MediaAiEditService,
|
||||
@Inject(ProductImageTaskService) private readonly productImageTasks: ProductImageTaskService,
|
||||
@Inject(ProductImageDerivativeHistoryService) private readonly derivativeHistory: ProductImageDerivativeHistoryService,
|
||||
@Inject(ImageSuiteService) private readonly suites: ImageSuiteService,
|
||||
) {}
|
||||
|
||||
@Post('products/:productId/ai/suite-plans')
|
||||
public async planSuite(@Param('productId') productId: string, @Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
|
||||
const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(imageSuitePlanInputSchema, body), 'plan');
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites')
|
||||
public async generateSuite(@Param('productId') productId: string, @Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: { status: (code: number) => void }) {
|
||||
const result = await this.suites.create(workspace.id, request.auth.userId, uuid('product_id', productId), idempotencyKey(key), parse(createImageSuiteSchema, body), 'generate');
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
@Get('products/:productId/ai/image-suites')
|
||||
public async listSuites(@Param('productId') productId: string, @Query() query: unknown, @CurrentWorkspace() workspace: Workspace) {
|
||||
return this.suites.list(workspace.id, uuid('product_id', productId), parse(listProductImageTasksQuerySchema, query));
|
||||
}
|
||||
|
||||
@Get('products/:productId/ai/image-suites/:suiteId')
|
||||
public async readSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string, @CurrentWorkspace() workspace: Workspace) {
|
||||
return { data: await this.suites.read(workspace.id, uuid('product_id', productId), uuid('suite_id', suiteId)) };
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites/:suiteId/cancel')
|
||||
public async cancelSuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'cancel')).body;
|
||||
}
|
||||
|
||||
@Post('products/:productId/ai/image-suites/:suiteId/retry')
|
||||
public async retrySuite(@Param('productId') productId: string, @Param('suiteId') suiteId: string,
|
||||
@Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
||||
return (await this.suites.action(workspace.id, request.auth.userId, uuid('product_id', productId), uuid('suite_id', suiteId), idempotencyKey(key), 'retry')).body;
|
||||
}
|
||||
|
||||
@Get('products/:productId/image-derivatives')
|
||||
public async listProductImageDerivatives(
|
||||
@Param('productId') productId: string,
|
||||
|
||||
@@ -7,10 +7,11 @@ import { WatermarkDerivativeService } from './watermark-derivative.service.js';
|
||||
import { MediaAiEditService } from './media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from './product-image-task.service.js';
|
||||
import { ProductImageDerivativeHistoryService } from './product-image-derivative-history.service.js';
|
||||
import { ImageSuiteService } from './image-suite.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService],
|
||||
providers: [IdempotencyService, MediaService, AspectRatioDerivativeService, WatermarkDerivativeService, MediaAiEditService, ProductImageTaskService, ProductImageDerivativeHistoryService, ImageSuiteService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
@@ -48,6 +48,7 @@ type AssetRow = {
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly s3: S3Client;
|
||||
private readonly legacyMinioS3: S3Client | null;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
public constructor(
|
||||
@@ -58,13 +59,29 @@ export class MediaService {
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
region: config.OBJECT_STORAGE_REGION,
|
||||
forcePathStyle: config.objectStorageForcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.objectStorageAccessKey,
|
||||
secretAccessKey: config.objectStorageSecretKey,
|
||||
},
|
||||
});
|
||||
// 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。
|
||||
this.legacyMinioS3 =
|
||||
config.NODE_ENV !== 'production' &&
|
||||
config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' &&
|
||||
config.MINIO_ROOT_USER &&
|
||||
config.MINIO_ROOT_PASSWORD
|
||||
? new S3Client({
|
||||
endpoint: config.LEGACY_MINIO_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.MINIO_ROOT_USER,
|
||||
secretAccessKey: config.MINIO_ROOT_PASSWORD,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
@@ -273,11 +290,12 @@ export class MediaService {
|
||||
) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
`SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
@@ -288,16 +306,10 @@ export class MediaService {
|
||||
'image/webp': 'webp',
|
||||
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
...(purpose === 'download' ? {
|
||||
ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
const url = await this.signedReadUrl(
|
||||
asset.rows[0],
|
||||
expiresAt,
|
||||
purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
|
||||
);
|
||||
return { url, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
@@ -306,8 +318,8 @@ export class MediaService {
|
||||
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const uploads = await tx.query<AssetRow & { bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
const uploads = await tx.query<AssetRow & { storage_provider: string; bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
|
||||
FROM atlas.media_assets a
|
||||
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
|
||||
@@ -326,11 +338,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString()
|
||||
: asset.created_at,
|
||||
preview_url: await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }),
|
||||
{ expiresIn: 600 },
|
||||
),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -387,15 +395,12 @@ export class MediaService {
|
||||
[input.url, assetId, userId],
|
||||
);
|
||||
const asset = await this.requireAsset(tx, assetId);
|
||||
const stored = await tx.query<{ bucket: string; object_key: string }>(
|
||||
'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
||||
'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
[assetId],
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const previewUrl = await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: stored.rows[0].bucket,
|
||||
Key: stored.rows[0].object_key,
|
||||
}), { expiresIn: 600 });
|
||||
const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt);
|
||||
return {
|
||||
status: 201,
|
||||
body: { data: { ...asset, source_url: input.url,
|
||||
@@ -408,11 +413,12 @@ export class MediaService {
|
||||
public async productImports(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const imports = await tx.query<AssetRow & {
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
source_url: string;
|
||||
}>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
a.created_at, a.bucket, a.object_key, i.source_url
|
||||
FROM (
|
||||
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
|
||||
@@ -433,9 +439,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString() : asset.created_at,
|
||||
source_url: asset.source_url,
|
||||
preview_url: await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: asset.bucket, Key: asset.object_key,
|
||||
}), { expiresIn: 600 }),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -560,6 +564,55 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */
|
||||
private async signedReadUrl(
|
||||
asset: { storage_provider: string; bucket: string; object_key: string },
|
||||
expiresAt: Date,
|
||||
downloadName?: string,
|
||||
): Promise<string> {
|
||||
if (
|
||||
asset.storage_provider === 'qiniu_kodo' &&
|
||||
asset.bucket === this.config.OBJECT_STORAGE_BUCKET &&
|
||||
this.config.qiniuDownloadOrigin
|
||||
) {
|
||||
const origin = new URL(this.config.qiniuDownloadOrigin).origin;
|
||||
const path = asset.object_key.split('/').map(encodeURIComponent).join('/');
|
||||
const deadline = Math.floor(expiresAt.getTime() / 1000);
|
||||
const attachment = downloadName
|
||||
? `attname=${encodeURIComponent(downloadName)}&`
|
||||
: '';
|
||||
const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`;
|
||||
const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey)
|
||||
.update(unsignedUrl)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`;
|
||||
}
|
||||
return getSignedUrl(
|
||||
this.storageForPreview(asset),
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: asset.object_key,
|
||||
...(downloadName ? {
|
||||
ResponseContentDisposition: `attachment; filename="${downloadName}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) },
|
||||
);
|
||||
}
|
||||
|
||||
private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client {
|
||||
if (
|
||||
asset.storage_provider === 'local_minio' &&
|
||||
asset.bucket === 'atlas-assets' &&
|
||||
this.legacyMinioS3
|
||||
) {
|
||||
return this.legacyMinioS3;
|
||||
}
|
||||
return this.s3;
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (!this.bucketReady)
|
||||
this.bucketReady = (async () => {
|
||||
|
||||
@@ -16,13 +16,17 @@ type AiEditTaskRow = {
|
||||
failure_detail: string | null;
|
||||
created_at: Date | string;
|
||||
completed_at: Date | string | null;
|
||||
kind?: ProductImageTask['kind'];
|
||||
total?: number;
|
||||
completed?: number;
|
||||
output_asset_ids?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductImageTaskService {
|
||||
public constructor(@Inject(DATABASE) private readonly database: Database) {}
|
||||
|
||||
/** 聚合当前商品的图片任务;新增套图任务时在此追加来源,接口结构保持稳定。 */
|
||||
/** 聚合单图与整套任务;套图子任务不重复出现在商品任务列表。 */
|
||||
public async list(workspaceId: string, productId: string, query: ListProductImageTasksQuery) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const product = await tx.query<{ id: string }>(
|
||||
@@ -40,14 +44,33 @@ export class ProductImageTaskService {
|
||||
}
|
||||
params.push(query.limit + 1);
|
||||
const edits = await tx.query<AiEditTaskRow>(
|
||||
`SELECT e.id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
|
||||
`SELECT e.* FROM (
|
||||
SELECT e.id, e.product_id, e.status, e.provider_task_id, e.output_asset_id, e.error_code,
|
||||
o.result ->> 'provider_error_raw' AS provider_error_raw,
|
||||
o.result ->> 'failure_source' AS failure_source,
|
||||
o.result ->> 'failure_stage' AS failure_stage,
|
||||
o.result ->> 'failure_detail' AS failure_detail,
|
||||
e.created_at, e.completed_at
|
||||
FROM atlas.media_ai_edits e
|
||||
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||
e.created_at, e.completed_at, 'single_ai_edit' AS kind, 1 AS total,
|
||||
(e.status = 'succeeded')::int AS completed,
|
||||
CASE WHEN e.output_asset_id IS NULL THEN '{}'::uuid[] ELSE ARRAY[e.output_asset_id] END AS output_asset_ids
|
||||
FROM atlas.media_ai_edits e
|
||||
LEFT JOIN atlas.operations o ON o.id = e.id AND o.workspace_id = e.workspace_id
|
||||
WHERE e.suite_id IS NULL
|
||||
UNION ALL
|
||||
SELECT s.id, s.product_id, s.status, NULL::text, NULL::uuid, s.error_code,
|
||||
failure.result ->> 'provider_error_raw', failure.result ->> 'failure_source',
|
||||
failure.result ->> 'failure_stage', failure.result ->> 'failure_detail',
|
||||
s.created_at, s.completed_at, 'ai_suite', jsonb_array_length(s.shots),
|
||||
progress.completed::int, progress.outputs
|
||||
FROM atlas.product_image_suites s
|
||||
LEFT JOIN LATERAL (SELECT count(*) FILTER (WHERE status = 'succeeded') AS completed,
|
||||
coalesce(array_agg(output_asset_id ORDER BY suite_position) FILTER (WHERE output_asset_id IS NOT NULL), '{}'::uuid[]) AS outputs
|
||||
FROM atlas.media_ai_edits WHERE suite_id = s.id) progress ON true
|
||||
LEFT JOIN LATERAL (SELECT o.result FROM atlas.media_ai_edits child JOIN atlas.operations o ON o.id = child.id
|
||||
AND o.workspace_id = child.workspace_id WHERE child.suite_id = s.id AND child.status = 'failed'
|
||||
ORDER BY child.suite_position LIMIT 1) failure ON true
|
||||
WHERE s.purpose = 'generate'
|
||||
) e
|
||||
WHERE e.product_id = $1 ${cursorFilter}
|
||||
ORDER BY (e.status IN ('queued', 'running')) DESC, e.created_at DESC, e.id DESC
|
||||
LIMIT $${params.length}`, params,
|
||||
@@ -57,17 +80,17 @@ export class ProductImageTaskService {
|
||||
const last = rows.at(-1);
|
||||
const data: ProductImageTask[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: 'single_ai_edit' as const,
|
||||
kind: row.kind ?? 'single_ai_edit',
|
||||
status: row.status,
|
||||
phase: row.status === 'running' && row.provider_task_id ? 'waiting_provider' as const : null,
|
||||
total: 1,
|
||||
completed: row.status === 'succeeded' ? 1 : 0,
|
||||
output_asset_ids: row.output_asset_id ? [row.output_asset_id] : [],
|
||||
total: row.total ?? 1,
|
||||
completed: row.completed ?? (row.status === 'succeeded' ? 1 : 0),
|
||||
output_asset_ids: row.output_asset_ids ?? (row.output_asset_id ? [row.output_asset_id] : []),
|
||||
error_code: row.error_code,
|
||||
failure_source: row.status === 'failed' ? row.failure_source ?? null : null,
|
||||
failure_stage: row.status === 'failed' ? row.failure_stage ?? null : null,
|
||||
failure_detail: row.status === 'failed' ? row.failure_detail ?? null : null,
|
||||
provider_error_raw: row.status === 'failed' ? row.provider_error_raw ?? null : null,
|
||||
failure_source: ['failed', 'partial'].includes(row.status) ? row.failure_source ?? null : null,
|
||||
failure_stage: ['failed', 'partial'].includes(row.status) ? row.failure_stage ?? null : null,
|
||||
failure_detail: ['failed', 'partial'].includes(row.status) ? row.failure_detail ?? null : null,
|
||||
provider_error_raw: ['failed', 'partial'].includes(row.status) ? row.provider_error_raw ?? null : null,
|
||||
created_at: iso(row.created_at),
|
||||
completed_at: row.completed_at ? iso(row.completed_at) : null,
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
OzonPublishItemSnapshot,
|
||||
@@ -323,6 +322,9 @@ export class ProductEditorPublishOperations {
|
||||
values = values
|
||||
.filter((v) => typeof v === 'string' && v.trim())
|
||||
.map((v) => v.trim());
|
||||
// Ozon 主题标签用空格分隔后放在同一个属性值中,单值限制不代表只允许一个标签。
|
||||
if (definition.editor_field === 'tags' && values.length)
|
||||
values = [values.join(' ')];
|
||||
if (values.length > definition.max_value_count)
|
||||
throw new ApiError(
|
||||
422,
|
||||
@@ -385,13 +387,8 @@ export class ProductEditorPublishOperations {
|
||||
category.rows[0].external_category_id,
|
||||
),
|
||||
type_id: numberId(category.rows[0].external_type_id),
|
||||
price: listing.selling_price,
|
||||
...(listing.list_price &&
|
||||
new Decimal(listing.list_price).greaterThan(
|
||||
listing.selling_price,
|
||||
)
|
||||
? { old_price: listing.list_price! }
|
||||
: {}),
|
||||
// 首次上架以划线价预留折扣空间,后续实际售价由用户在 Ozon 调整。
|
||||
price: listing.list_price!,
|
||||
currency_code: profile.currency_code,
|
||||
depth: Number(listing.depth),
|
||||
width: Number(listing.width),
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
ProductAttributeSchema,
|
||||
ProductEditorValidation,
|
||||
} from '@atlas/contracts';
|
||||
import { ozonHashtagProblem } from '@atlas/contracts';
|
||||
import { Decimal } from 'decimal.js';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { ProductRow, ProfileRow } from './product-editor.models.js';
|
||||
@@ -27,9 +29,11 @@ type VariantValidationRow = {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
title: string | null;
|
||||
tags: unknown;
|
||||
listing_status: string;
|
||||
offer_id: string | null;
|
||||
selling_price: string | null;
|
||||
list_price: string | null;
|
||||
pricing_valid: boolean;
|
||||
package_length_mm: string | null;
|
||||
package_width_mm: string | null;
|
||||
@@ -302,6 +306,19 @@ export class ProductEditorValidationOperations {
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] = [
|
||||
...dictionaryIssues,
|
||||
];
|
||||
// 只校验本次 SKU 实际使用的标签,独立覆盖时不受未使用的通用文案阻断。
|
||||
const ownTags = stringValues(variant.tags);
|
||||
const tagsProblem = ozonHashtagProblem(
|
||||
ownTags.length ? ownTags : stringValues(profile.tags),
|
||||
);
|
||||
if (tagsProblem)
|
||||
issues.push({
|
||||
code: 'OZON_HASHTAG_INVALID',
|
||||
field: ownTags.length
|
||||
? `variants.${variant.id}.tags`
|
||||
: 'profile.tags',
|
||||
message: tagsProblem,
|
||||
});
|
||||
if (profile.title_mode === 'variant' && !variant.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
@@ -344,11 +361,27 @@ export class ProductEditorValidationOperations {
|
||||
field: `variants.${variant.id}.pricing_selection`,
|
||||
message: '请按当前采购价和包装确认报价',
|
||||
});
|
||||
if (!variant.selling_price || Number(variant.selling_price) <= 0)
|
||||
const sellingPrice = variant.selling_price
|
||||
? new Decimal(variant.selling_price)
|
||||
: null;
|
||||
const listPrice = variant.list_price ? new Decimal(variant.list_price) : null;
|
||||
if (!sellingPrice?.isFinite() || sellingPrice.lte(0))
|
||||
issues.push({
|
||||
code: 'PRICE_REQUIRED',
|
||||
field: `variants.${variant.id}.selling_price`,
|
||||
message: '请先确认售价',
|
||||
message: '请先确认实际售价',
|
||||
});
|
||||
if (!listPrice?.isFinite() || listPrice.lte(0))
|
||||
issues.push({
|
||||
code: 'LIST_PRICE_REQUIRED',
|
||||
field: `variants.${variant.id}.list_price`,
|
||||
message: '请重新试算并确认划线价,首次上架将按划线价提交',
|
||||
});
|
||||
else if (sellingPrice?.isFinite() && listPrice.lt(sellingPrice))
|
||||
issues.push({
|
||||
code: 'LIST_PRICE_INVALID',
|
||||
field: `variants.${variant.id}.list_price`,
|
||||
message: '划线价不能低于实际售价,请重新确认报价',
|
||||
});
|
||||
if (
|
||||
variant.package_spec_status !== 'confirmed' ||
|
||||
@@ -416,8 +449,8 @@ export class ProductEditorValidationOperations {
|
||||
variantIds?: string[],
|
||||
) {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.title, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text, listing.pricing_valid,
|
||||
`SELECT v.id, v.display_name, listing.title, listing.tags, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text, listing.list_price::text, listing.pricing_valid,
|
||||
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
|
||||
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
|
||||
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
|
||||
@@ -431,8 +464,8 @@ export class ProductEditorValidationOperations {
|
||||
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
|
||||
WHERE v.product_id = $2 AND v.deleted_at IS NULL
|
||||
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
|
||||
GROUP BY v.id, v.display_name, listing.title, listing.status, listing.offer_id,
|
||||
listing.selling_price, listing.pricing_valid, product.default_package_length_mm,
|
||||
GROUP BY v.id, v.display_name, listing.title, listing.tags, listing.status, listing.offer_id,
|
||||
listing.selling_price, listing.list_price, listing.pricing_valid, product.default_package_length_mm,
|
||||
product.default_package_width_mm, product.default_package_height_mm,
|
||||
product.default_shipping_weight_g, product.package_spec_status
|
||||
ORDER BY v.sort_order, v.id`,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
createApplicationDatabase,
|
||||
type Database,
|
||||
type DatabaseTransaction,
|
||||
} from '@atlas/database';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuiteInputSchema,
|
||||
type ImageSuite,
|
||||
} from '@atlas/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
||||
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
|
||||
|
||||
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
|
||||
describe.skipIf(!process.env.DATABASE_URL)(
|
||||
'套图数据库隔离、幂等与状态汇总',
|
||||
() => {
|
||||
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
|
||||
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||
const rollback = Symbol('rollback');
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const workspaceId = randomUUID(),
|
||||
otherWorkspace = randomUUID(),
|
||||
userId = randomUUID();
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
|
||||
[userId, `${userId}@example.test`],
|
||||
);
|
||||
for (const id of [workspaceId, otherWorkspace])
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
|
||||
[id],
|
||||
);
|
||||
const inWorkspace = async (id: string) =>
|
||||
tx.query(
|
||||
"SELECT set_config('app.workspace_id', $1, true)",
|
||||
[id],
|
||||
);
|
||||
await inWorkspace(workspaceId);
|
||||
const productId = randomUUID(),
|
||||
variantId = randomUUID(),
|
||||
itemId = randomUUID(),
|
||||
assetId = randomUUID(),
|
||||
outputId = randomUUID();
|
||||
await tx.query(
|
||||
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
|
||||
[itemId, workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
|
||||
[productId, workspaceId, itemId],
|
||||
);
|
||||
await tx.query(
|
||||
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
|
||||
[variantId, workspaceId, productId],
|
||||
);
|
||||
const asset = async (id: string, ws: string) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
|
||||
[id, ws],
|
||||
);
|
||||
await asset(assetId, workspaceId);
|
||||
await asset(outputId, workspaceId);
|
||||
await inWorkspace(otherWorkspace);
|
||||
const foreignAsset = randomUUID();
|
||||
await asset(foreignAsset, otherWorkspace);
|
||||
await inWorkspace(workspaceId);
|
||||
let queryQueue: Promise<void> = Promise.resolve();
|
||||
const scoped = {
|
||||
workspaceTransaction: <T>(
|
||||
ws: string,
|
||||
work: (
|
||||
transaction: DatabaseTransaction,
|
||||
) => Promise<T>,
|
||||
) => {
|
||||
const result = queryQueue.then(async () => {
|
||||
await inWorkspace(ws);
|
||||
return work(tx);
|
||||
});
|
||||
queryQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
} as Database;
|
||||
const idempotency = new IdempotencyService(scoped, {
|
||||
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
|
||||
} as never);
|
||||
const edits = new MediaAiEditService(scoped, idempotency, {
|
||||
accessUrl: async () => ({
|
||||
url: 'https://example.test/preview',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
}),
|
||||
} as never);
|
||||
const suites = new ImageSuiteService(
|
||||
scoped,
|
||||
idempotency,
|
||||
edits,
|
||||
);
|
||||
const input = imageSuiteInputSchema.parse({
|
||||
variant_id: variantId,
|
||||
reference_asset_ids: [assetId],
|
||||
product_name: '台灯',
|
||||
});
|
||||
const shots = ['white', 'scene', 'selling'].map(
|
||||
(category, i) => ({
|
||||
id: randomUUID(),
|
||||
category,
|
||||
type: ['white_background', 'lifestyle', 'detail'][
|
||||
i
|
||||
],
|
||||
theme: `方案${i}`,
|
||||
description: '依据真实底图展示商品',
|
||||
}),
|
||||
);
|
||||
const request = createImageSuiteSchema.parse({
|
||||
...input,
|
||||
shots,
|
||||
});
|
||||
const key = randomUUID();
|
||||
const created = await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
request,
|
||||
'generate',
|
||||
);
|
||||
const id = (created.body as { data: ImageSuite }).data.id;
|
||||
expect(
|
||||
(
|
||||
await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
request,
|
||||
'generate',
|
||||
)
|
||||
).body,
|
||||
).toEqual(created.body);
|
||||
await expect(
|
||||
suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
key,
|
||||
{ ...request, product_name: '不同请求' },
|
||||
'generate',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
|
||||
});
|
||||
const children = (
|
||||
await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
)
|
||||
).rows;
|
||||
expect(children).toHaveLength(3);
|
||||
expect(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
|
||||
[children.map((child) => child.id)],
|
||||
)
|
||||
).rows,
|
||||
).toHaveLength(3);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
|
||||
[children[0].id, outputId],
|
||||
);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
|
||||
[children[1].id],
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('running');
|
||||
await suites.action(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
id,
|
||||
randomUUID(),
|
||||
'cancel',
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('cancelled');
|
||||
await suites.action(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
id,
|
||||
randomUUID(),
|
||||
'retry',
|
||||
);
|
||||
const retried = await suites.read(
|
||||
workspaceId,
|
||||
productId,
|
||||
id,
|
||||
);
|
||||
expect(retried.items.map((item) => item.status)).toEqual([
|
||||
'succeeded',
|
||||
'queued',
|
||||
'queued',
|
||||
]);
|
||||
expect(retried.items[0].output_asset?.id).toBe(outputId);
|
||||
const attempts = (
|
||||
await tx.query<{ generation_attempt: number }>(
|
||||
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
||||
[id],
|
||||
)
|
||||
).rows;
|
||||
expect(
|
||||
attempts.map((item) => item.generation_attempt),
|
||||
).toEqual([1, 2, 2]);
|
||||
await tx.query(
|
||||
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
|
||||
[children.slice(1).map((child) => child.id)],
|
||||
);
|
||||
expect(
|
||||
(await suites.read(workspaceId, productId, id)).status,
|
||||
).toBe('partial');
|
||||
const tasks = await new ProductImageTaskService(
|
||||
scoped,
|
||||
).list(workspaceId, productId, { limit: 20 });
|
||||
expect(tasks.data).toMatchObject([
|
||||
{
|
||||
id,
|
||||
kind: 'ai_suite',
|
||||
status: 'partial',
|
||||
total: 3,
|
||||
completed: 1,
|
||||
output_asset_ids: [outputId],
|
||||
},
|
||||
]);
|
||||
expect(tasks.data).toHaveLength(1);
|
||||
await expect(
|
||||
suites.read(otherWorkspace, productId, id),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
await expect(
|
||||
suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
randomUUID(),
|
||||
{ ...input, reference_asset_ids: [foreignAsset] },
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
const forbidden = async (
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
code: string,
|
||||
) => {
|
||||
await tx.query('SAVEPOINT forbidden_write');
|
||||
try {
|
||||
await tx.query(sql, params);
|
||||
throw new Error('本应拒绝的写入成功');
|
||||
} catch (error) {
|
||||
expect((error as { code: string }).code).toBe(code);
|
||||
} finally {
|
||||
await tx.query(
|
||||
'ROLLBACK TO SAVEPOINT forbidden_write',
|
||||
);
|
||||
}
|
||||
};
|
||||
await forbidden(
|
||||
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
|
||||
[id],
|
||||
'23514',
|
||||
);
|
||||
await forbidden(
|
||||
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
|
||||
[children[0].id],
|
||||
'23514',
|
||||
);
|
||||
await forbidden(
|
||||
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
|
||||
[children[0].id, foreignAsset],
|
||||
'23514',
|
||||
);
|
||||
const foreignReferenceParent = await suites.create(
|
||||
workspaceId,
|
||||
userId,
|
||||
productId,
|
||||
randomUUID(),
|
||||
input,
|
||||
'plan',
|
||||
);
|
||||
const foreignParentId = (
|
||||
foreignReferenceParent.body as { data: ImageSuite }
|
||||
).data.id;
|
||||
await forbidden(
|
||||
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
|
||||
[foreignParentId, foreignAsset],
|
||||
'23514',
|
||||
);
|
||||
await inWorkspace(otherWorkspace);
|
||||
expect(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
|
||||
[id],
|
||||
)
|
||||
).rows,
|
||||
).toHaveLength(0);
|
||||
throw rollback;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== rollback) throw error;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createImageSuiteSchema,
|
||||
imageSuiteInputSchema,
|
||||
imageSuitePlanInputSchema,
|
||||
} from '@atlas/contracts';
|
||||
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
||||
|
||||
const input = () =>
|
||||
imageSuiteInputSchema.parse({
|
||||
variant_id: randomUUID(),
|
||||
reference_asset_ids: [randomUUID()],
|
||||
product_name: '台灯',
|
||||
});
|
||||
const shot = () => ({
|
||||
id: randomUUID(),
|
||||
category: 'white',
|
||||
type: 'white_background',
|
||||
theme: '白底主图',
|
||||
description: '展示商品完整外观',
|
||||
});
|
||||
|
||||
describe('套图共享契约与资源边界', () => {
|
||||
it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => {
|
||||
const valid = input();
|
||||
expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4);
|
||||
expect(
|
||||
imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 })
|
||||
.image_count,
|
||||
).toBe(6);
|
||||
for (const image_count of [0, 21, 1.2])
|
||||
expect(
|
||||
imageSuitePlanInputSchema.safeParse({ ...valid, image_count })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
image_count: 4,
|
||||
shots: [shot()],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => {
|
||||
const request = imageSuitePlanInputSchema.parse({
|
||||
...input(),
|
||||
image_count: 6,
|
||||
});
|
||||
const suiteId = randomUUID(),
|
||||
productId = randomUUID();
|
||||
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||
return { rows: [{ id: request.variant_id }] };
|
||||
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||
return {
|
||||
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||
};
|
||||
if (sql.startsWith('INSERT INTO atlas.product_image_suites'))
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: suiteId,
|
||||
product_id: productId,
|
||||
variant_id: request.variant_id,
|
||||
purpose: 'plan',
|
||||
status: 'queued',
|
||||
input: JSON.parse(values[3] as string),
|
||||
shots: [],
|
||||
created_at: new Date(),
|
||||
completed_at: null,
|
||||
error_code: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return { rows: [] };
|
||||
});
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{
|
||||
run: async (
|
||||
_context: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
const result = await service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
productId,
|
||||
randomUUID(),
|
||||
request,
|
||||
'plan',
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 202,
|
||||
body: {
|
||||
data: { purpose: 'plan', input: { image_count: 6 }, shots: [] },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
query.mock.calls.some(([sql]) =>
|
||||
sql.includes('INSERT INTO atlas.media_ai_edits'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
query.mock.calls.find(([sql]) =>
|
||||
sql.includes('INSERT INTO atlas.outbox_events'),
|
||||
)?.[1],
|
||||
).toContain('image_suite.plan.requested');
|
||||
});
|
||||
it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => {
|
||||
const valid = input();
|
||||
expect(
|
||||
imageSuiteInputSchema.safeParse({
|
||||
...valid,
|
||||
reference_asset_ids: Array.from({ length: 9 }, randomUUID),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
imageSuiteInputSchema.safeParse({
|
||||
...valid,
|
||||
reference_asset_ids: [
|
||||
...valid.reference_asset_ids,
|
||||
...valid.reference_asset_ids,
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: Array.from({ length: 6 }, shot),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), category: 'other', type: 'dimensions' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => {
|
||||
const valid = input();
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [{ ...shot(), category: 'scene' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createImageSuiteSchema.safeParse({
|
||||
...valid,
|
||||
shots: [shot()],
|
||||
ratio: '4:3',
|
||||
language: 'zh',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', 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 ImageSuiteService(
|
||||
{} as never,
|
||||
{ run } as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
input(),
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => {
|
||||
const query = vi.fn(async (sql: string) => ({
|
||||
rows: sql.includes('atlas.product_variants')
|
||||
? [{ id: 'variant' }]
|
||||
: [],
|
||||
}));
|
||||
const run = vi.fn(
|
||||
async (
|
||||
_input: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
);
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{ run } as never,
|
||||
{} as never,
|
||||
);
|
||||
await expect(
|
||||
service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
input(),
|
||||
'plan',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => {
|
||||
const request = createImageSuiteSchema.parse({
|
||||
...input(),
|
||||
reference_asset_ids: Array.from({ length: 8 }, randomUUID),
|
||||
shots: Array.from({ length: 8 }, (_, index) => ({
|
||||
...shot(),
|
||||
category: ['white', 'scene', 'selling', 'other'][index % 4],
|
||||
type: ['white_background', 'lifestyle', 'detail', 'custom'][
|
||||
index % 4
|
||||
],
|
||||
})),
|
||||
});
|
||||
const used: string[][] = [];
|
||||
const query = vi.fn(async (sql: string, values: unknown[] = []) => {
|
||||
if (sql.startsWith('SELECT id FROM atlas.product_variants'))
|
||||
return { rows: [{ id: request.variant_id }] };
|
||||
if (sql.startsWith('SELECT id FROM atlas.media_assets'))
|
||||
return {
|
||||
rows: request.reference_asset_ids.map((id) => ({ id })),
|
||||
};
|
||||
if (sql.includes('INSERT INTO atlas.product_image_suites'))
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
product_id: randomUUID(),
|
||||
variant_id: request.variant_id,
|
||||
purpose: 'generate',
|
||||
status: 'queued',
|
||||
input: request,
|
||||
shots: request.shots,
|
||||
created_at: new Date(),
|
||||
completed_at: null,
|
||||
error_code: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (sql.includes('INSERT INTO atlas.media_ai_edits'))
|
||||
used.push([values[5] as string, ...(values[6] as string[])]);
|
||||
return { rows: [] };
|
||||
});
|
||||
const service = new ImageSuiteService(
|
||||
{} as never,
|
||||
{
|
||||
run: async (
|
||||
_request: unknown,
|
||||
work: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => work({ query }),
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
await service.create(
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
request,
|
||||
'generate',
|
||||
);
|
||||
expect(used).toHaveLength(8);
|
||||
expect(
|
||||
used.every(
|
||||
(refs) =>
|
||||
refs.length <= 3 &&
|
||||
refs[0] === request.reference_asset_ids[0],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(new Set(used.flat())).toEqual(
|
||||
new Set(request.reference_asset_ids),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,10 +47,11 @@ describe('网络商品图片导入', () => {
|
||||
],
|
||||
};
|
||||
}
|
||||
if (sql.includes('SELECT bucket, object_key')) {
|
||||
if (sql.includes('SELECT storage_provider, bucket, object_key')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
storage_provider: 'local_minio',
|
||||
bucket: 'test',
|
||||
object_key: 'product-imports/existing.png',
|
||||
},
|
||||
@@ -74,6 +75,7 @@ describe('网络商品图片导入', () => {
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
|
||||
@@ -18,6 +18,7 @@ function serviceWithObject(bytes: Buffer) {
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
@@ -70,6 +71,7 @@ describe('商品图片上传策略', () => {
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
@@ -80,11 +82,88 @@ describe('商品图片上传策略', () => {
|
||||
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(preview.url).searchParams.get('X-Amz-Credential')).toContain('/cn-east-1/');
|
||||
expect(new URL(download.url).searchParams.get('response-content-disposition'))
|
||||
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
|
||||
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('开发环境为旧 MinIO 素材签发本地预览地址', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{
|
||||
storage_provider: 'local_minio',
|
||||
bucket: 'atlas-assets',
|
||||
object_key: 'collections/test/image',
|
||||
content_type: 'image/jpeg',
|
||||
}],
|
||||
}));
|
||||
const database = {
|
||||
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
|
||||
};
|
||||
const service = new MediaService(
|
||||
{} as ConstructorParameters<typeof MediaService>[0],
|
||||
database as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
NODE_ENV: 'development',
|
||||
OBJECT_STORAGE_PROVIDER: 'qiniu_kodo',
|
||||
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
|
||||
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
LEGACY_MINIO_ENDPOINT: 'http://localhost:9000',
|
||||
MINIO_ROOT_USER: 'local-test',
|
||||
MINIO_ROOT_PASSWORD: 'local-secret',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const preview = await service.accessUrl('workspace-id', 'asset-id');
|
||||
expect(new URL(preview.url).host).toBe('localhost:9000');
|
||||
expect(new URL(preview.url).pathname).toBe('/atlas-assets/collections/test/image');
|
||||
});
|
||||
|
||||
it('七牛素材使用绑定域名签发预览和附件下载链接', async () => {
|
||||
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{
|
||||
storage_provider: 'qiniu_kodo',
|
||||
bucket: 'atlas-dev-assets',
|
||||
object_key: 'collections/workspace/image',
|
||||
content_type: 'image/jpeg',
|
||||
}],
|
||||
}));
|
||||
const database = {
|
||||
workspaceTransaction: async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }),
|
||||
};
|
||||
const service = new MediaService(
|
||||
{} as ConstructorParameters<typeof MediaService>[0],
|
||||
database as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'https://s3.cn-east-1.qiniucs.com',
|
||||
OBJECT_STORAGE_REGION: 'cn-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'atlas-dev-assets',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 's3-key',
|
||||
objectStorageSecretKey: 's3-secret',
|
||||
qiniuDownloadOrigin: 'https://img-dev.atlas.joeyone.cn',
|
||||
qiniuDownloadAccessKey: 'download-key',
|
||||
qiniuDownloadSecretKey: 'download-secret',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
const preview = await service.accessUrl('workspace-id', assetId);
|
||||
const download = await service.accessUrl('workspace-id', assetId, 'download');
|
||||
const previewUrl = new URL(preview.url);
|
||||
const downloadUrl = new URL(download.url);
|
||||
expect(previewUrl.origin).toBe('https://img-dev.atlas.joeyone.cn');
|
||||
expect(previewUrl.pathname).toBe('/collections/workspace/image');
|
||||
expect(previewUrl.searchParams.has('X-Amz-Signature')).toBe(false);
|
||||
expect(previewUrl.searchParams.has('attname')).toBe(false);
|
||||
expect(previewUrl.searchParams.get('token')).toMatch(/^download-key:/);
|
||||
expect(preview.url.split('&').at(-1)).toMatch(/^token=/);
|
||||
expect(downloadUrl.searchParams.get('attname')).toBe(`atlas-image-${assetId}.jpg`);
|
||||
expect(download.url.split('&').at(-1)).toMatch(/^token=/);
|
||||
});
|
||||
|
||||
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
||||
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||
rows: [],
|
||||
@@ -100,6 +179,7 @@ describe('商品图片上传策略', () => {
|
||||
{} as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
@@ -208,6 +288,7 @@ describe('商品图片上传策略', () => {
|
||||
>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_REGION: 'us-east-1',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
|
||||
@@ -72,6 +72,15 @@ const commission: CommissionRule = {
|
||||
version: 'test',
|
||||
};
|
||||
describe('十进制定价引擎', () => {
|
||||
it('默认方案折扣率为 50% 时,划线价是实际售价的两倍', () => {
|
||||
const i = input();
|
||||
i.pricing.list_price_discount_rate = '0.5';
|
||||
for (const currency of ['CNY', 'RUB']) {
|
||||
const option = calculatePricing(i, currency, '12.3', [rule], [commission])
|
||||
.options[0];
|
||||
expect(Number(option.list_price)).toBe(Number(option.selling_price) * 2);
|
||||
}
|
||||
});
|
||||
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
|
||||
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
|
||||
.options[0];
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { ProductPublishJob } from '@atlas/contracts';
|
||||
import { ozonHashtagProblem } from '@atlas/contracts';
|
||||
import type { S3Client } from '@aws-sdk/client-s3';
|
||||
import { beforeAll, afterAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
@@ -204,7 +205,7 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`,
|
||||
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`,
|
||||
[platform, category],
|
||||
);
|
||||
await tx.query(
|
||||
@@ -217,6 +218,167 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query(
|
||||
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
|
||||
[f.profile, JSON.stringify(['#boho', '#loft'])],
|
||||
);
|
||||
await tx.query(
|
||||
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
|
||||
[f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])],
|
||||
);
|
||||
});
|
||||
const created = await publish(f);
|
||||
assert.equal(created.status, 202, JSON.stringify(created));
|
||||
const job = created.body.data.publish_job as ProductPublishJob;
|
||||
await run(job, async (_client, _key, path, body) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
assert.equal(path, '/v3/product/import');
|
||||
const items = body.items as Array<{
|
||||
attributes: Array<{
|
||||
id: number;
|
||||
values: Array<{ value: string }>;
|
||||
}>;
|
||||
}>;
|
||||
assert.deepEqual(
|
||||
items.map(
|
||||
(item) =>
|
||||
item.attributes.find(
|
||||
(attribute) => attribute.id === 23171,
|
||||
)?.values,
|
||||
),
|
||||
[[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]],
|
||||
);
|
||||
return { result: { task_id: 23171 } };
|
||||
});
|
||||
});
|
||||
|
||||
it('主题标签支持 30 个标签,空标签不提交属性', async () => {
|
||||
for (const tags of [
|
||||
[],
|
||||
Array.from({ length: 30 }, (_, index) => `#tag${index}`),
|
||||
]) {
|
||||
const f = await fixture(1);
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
|
||||
[f.profile, JSON.stringify(tags)],
|
||||
),
|
||||
);
|
||||
const created = await publish(f);
|
||||
assert.equal(created.status, 202, JSON.stringify(created));
|
||||
const result = await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query<{
|
||||
snapshot: {
|
||||
attributes: Array<{
|
||||
id: number;
|
||||
values: Array<{ value: string }>;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
|
||||
[created.body.data.publish_job.id],
|
||||
),
|
||||
);
|
||||
const attribute = result.rows[0].snapshot.attributes.find(
|
||||
(item) => item.id === 23171,
|
||||
);
|
||||
assert.deepEqual(
|
||||
attribute?.values,
|
||||
tags.length ? [{ value: tags.join(' ') }] : undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => {
|
||||
assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null);
|
||||
assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null);
|
||||
for (const tag of [
|
||||
'стиль',
|
||||
'#стиль мода',
|
||||
'#стиль-мода',
|
||||
'#стиль/мода',
|
||||
'#стиль#мода',
|
||||
'#стиль😀',
|
||||
'#',
|
||||
'#' + 'я'.repeat(30),
|
||||
])
|
||||
assert.ok(ozonHashtagProblem([tag]), tag);
|
||||
assert.ok(
|
||||
ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')),
|
||||
);
|
||||
});
|
||||
|
||||
it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query(
|
||||
'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1',
|
||||
[f.profile, JSON.stringify(['спортивный стиль'])],
|
||||
);
|
||||
await tx.query(
|
||||
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
|
||||
[f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])],
|
||||
);
|
||||
});
|
||||
const preflight = await request(
|
||||
`/product-platform-profiles/${f.profile}/publish-preflights`,
|
||||
'POST',
|
||||
{ variant_ids: f.ids },
|
||||
);
|
||||
assert.equal(preflight.status, 200, JSON.stringify(preflight));
|
||||
assert.equal(preflight.body.data.status, 'blocked');
|
||||
const validation = preflight.body.data.validation;
|
||||
assert.equal(
|
||||
validation.variants.find(
|
||||
(v: { variant_id: string }) => v.variant_id === f.ids[0],
|
||||
).ready,
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
validation.variants
|
||||
.find((v: { variant_id: string }) => v.variant_id === f.ids[1])
|
||||
.issues.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'OZON_HASHTAG_INVALID' &&
|
||||
issue.field === 'profile.tags',
|
||||
),
|
||||
);
|
||||
const rejected = await publish(f);
|
||||
assert.equal(rejected.status, 422);
|
||||
assert.ok(
|
||||
rejected.body.error.details.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'OZON_HASHTAG_INVALID',
|
||||
),
|
||||
);
|
||||
const selected = await publish(f, [f.ids[0]]);
|
||||
assert.equal(selected.status, 202, JSON.stringify(selected));
|
||||
|
||||
const invalidSku = await fixture(1);
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2',
|
||||
[
|
||||
invalidSku.profile,
|
||||
invalidSku.ids[0],
|
||||
JSON.stringify(['#стиль-мода']),
|
||||
],
|
||||
),
|
||||
);
|
||||
const response = await publish(invalidSku);
|
||||
assert.equal(response.status, 422);
|
||||
assert.ok(
|
||||
response.body.error.details.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'OZON_HASHTAG_INVALID' &&
|
||||
issue.field === `variants.${invalidSku.ids[0]}.tags`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
|
||||
const f = await fixture();
|
||||
const key = randomUUID();
|
||||
@@ -241,12 +403,21 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
[job.id],
|
||||
)
|
||||
).rows,
|
||||
listings: (
|
||||
await tx.query(
|
||||
'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1',
|
||||
[f.profile],
|
||||
)
|
||||
).rows,
|
||||
}));
|
||||
assert.equal(facts.events.length, 1);
|
||||
assert.deepEqual(facts.events[0].event_payload, {
|
||||
publish_job_id: job.id,
|
||||
});
|
||||
assert.equal(facts.items[0].snapshot.price, '1999.1234');
|
||||
assert.equal(facts.items[0].snapshot.price, '2999.1234');
|
||||
assert.equal(facts.listings[0].selling_price, '1999.1234');
|
||||
assert.equal(facts.listings[0].list_price, '2999.1234');
|
||||
assert.ok(!('old_price' in facts.items[0].snapshot));
|
||||
assert.equal(facts.items[0].snapshot.depth, 240);
|
||||
assert.ok(!('vat' in facts.items[0].snapshot));
|
||||
assert.ok(!('images' in facts.items[0].snapshot));
|
||||
@@ -297,6 +468,51 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
'VERSION_CONFLICT',
|
||||
);
|
||||
});
|
||||
it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => {
|
||||
const equal = await fixture(1);
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1',
|
||||
[equal.profile],
|
||||
),
|
||||
);
|
||||
const created = await publish(equal);
|
||||
assert.equal(created.status, 202, JSON.stringify(created));
|
||||
await run(created.body.data.publish_job, async (_client, _key, path, body) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
assert.equal(path, '/v3/product/import');
|
||||
const item = (body.items as any[])[0];
|
||||
assert.equal(item.price, '1999.1234');
|
||||
assert.ok(!('old_price' in item));
|
||||
return { result: { task_id: 987654 } };
|
||||
});
|
||||
for (const listPrice of [null, '0']) {
|
||||
const f = await fixture(1);
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1',
|
||||
[f.profile, listPrice],
|
||||
),
|
||||
);
|
||||
const preflight = await request(
|
||||
`/product-platform-profiles/${f.profile}/publish-preflights`,
|
||||
'POST',
|
||||
{ variant_ids: f.ids },
|
||||
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' },
|
||||
);
|
||||
assert.equal(preflight.status, 200, JSON.stringify(preflight));
|
||||
assert.equal(preflight.body.data.validation.variants[0].ready, false);
|
||||
assert.ok(preflight.body.data.validation.variants[0].issues.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`,
|
||||
));
|
||||
const rejected = await publish(f);
|
||||
assert.equal(rejected.status, 422);
|
||||
assert.ok(rejected.body.error.details.some(
|
||||
(issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED',
|
||||
));
|
||||
}
|
||||
});
|
||||
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
@@ -322,7 +538,8 @@ describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
imports++;
|
||||
const items = body.items as any[];
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].price, '1999.1234');
|
||||
assert.equal(items[0].price, '2999.1234');
|
||||
assert.ok(!('old_price' in items[0]));
|
||||
assert.ok(!('vat' in items[0]));
|
||||
assert.ok(!('barcode' in items[0]));
|
||||
assert.ok(!('description' in items[0]));
|
||||
|
||||
Reference in New Issue
Block a user