feat(product-edit):商品编辑页初版

This commit is contained in:
R524809
2026-09-09 15:11:58 +08:00
parent 319887726e
commit 130a005231
19 changed files with 1541 additions and 21 deletions
+2
View File
@@ -11,6 +11,7 @@ import { WatermarksModule } from './watermarks/watermarks.module.js';
import { MediaModule } from './media/media.module.js';
import { CollectionModule } from './collection/collection.module.js';
import { ExtensionModule } from './extension/extension.module.js';
import { ProductEditorModule } from './product-editor/product-editor.module.js';
@Controller()
class HealthController {
@@ -49,6 +50,7 @@ class HealthController {
MediaModule,
CollectionModule,
ExtensionModule,
ProductEditorModule,
],
controllers: [HealthController],
})
@@ -4,6 +4,7 @@ import type { CreateCollectionItemRequest, ListCollectionItemsQuery, Materialize
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { throwRevisionConflict } from '../common/revision.js';
import { DATABASE } from '../database/database.module.js';
type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null };
@@ -37,14 +38,14 @@ export class CollectionService {
const item = await this.requireItem(tx, id);
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>('SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY created_at, id', [id, source.rows[0]?.source_snapshot_revision ?? 1]);
const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>(`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1]);
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
});
}
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) {
return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => {
const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]);
if (!result.rowCount) await this.versionConflict(tx, id, expected);
if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected);
return { status: 200, body: { data: await this.requireItem(tx, id) } };
});
}
@@ -57,7 +58,6 @@ export class CollectionService {
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { 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, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); }
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision }]); }
}
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
+50
View File
@@ -0,0 +1,50 @@
import type { DatabaseTransaction } from '@atlas/database';
import { ApiError } from './api-error.js';
/**
* Shared optimistic-lock conflict response. Versioned writes still keep their
* domain-specific SET clauses, but they must use a conditional `revision =
* expected` update and this helper when no row was affected.
*/
const resources = {
collection_item: {
table: 'atlas.collection_items',
label: '采集记录',
active: ' AND deleted_at IS NULL',
},
product: { table: 'atlas.products', label: '商品', active: '' },
product_platform_profile: {
table: 'atlas.product_platform_profiles',
label: '商品发布配置',
active: '',
},
} as const;
export async function throwRevisionConflict(
transaction: DatabaseTransaction,
resource: keyof typeof resources,
id: string,
expected: number,
): Promise<never> {
const target = resources[resource];
const current = await transaction.query<{ revision: number }>(
`SELECT revision FROM ${target.table} WHERE id = $1${target.active}`,
[id],
);
if (!current.rows[0])
throw new ApiError(404, 'RESOURCE_NOT_FOUND', `${target.label}不存在`);
if (current.rows[0].revision !== expected) {
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}已被更新`, [
{
field: 'If-Match',
code: 'VERSION_CONFLICT',
message: `当前版本为 ${current.rows[0].revision}`,
current_revision: current.rows[0].revision,
},
]);
}
// A conditional write with a matching version affecting no rows is a
// resource-state error (for example, a concurrent soft delete), not a
// successful retry.
throw new ApiError(409, 'VERSION_CONFLICT', `${target.label}状态已变化`);
}
+1 -1
View File
@@ -223,7 +223,7 @@ export class ExtensionService {
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
throw new Error('采集来源快照版本更新失败');
await tx.query(
`UPDATE atlas.products SET source_title = $1, updated_at = now(), version = version + 1 WHERE id = $2`,
`UPDATE atlas.products SET source_title = $1, updated_at = now(), revision = revision + 1 WHERE id = $2`,
[payload.common.title?.value ?? null, productId],
);
// The historical task rows must remain auditable after replacing the
@@ -0,0 +1,92 @@
import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common';
import { z } from 'zod';
import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import { ProductEditorService } from './product-editor.service.js';
type Response = { status: (value: number) => Response };
@Controller()
export class ProductEditorController {
public constructor(
@Inject(ProductEditorService)
private readonly editor: ProductEditorService,
) {}
@Post('collection-items/:collectionItemId/editor-contexts')
async enter(
@Param('collectionItemId') collectionItemId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.editor.enter(
workspace.id,
request.auth.userId,
requiredUuid('collection_item_id', collectionItemId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
);
response.status(result.status);
return result.body;
}
@Get('products/:productId/editor')
async read(
@Param('productId') productId: string,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.read(
workspace.id,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
),
};
}
@Put('products/:productId/editor-draft')
async save(
@Param('productId') productId: string,
@Body() body: unknown,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@Headers('idempotency-key') key: string | undefined,
@Headers('if-match') ifMatch: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
const parsed = saveProductEditorDraftRequestSchema.safeParse(body);
if (!parsed.success) {
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', parsed.error.issues.map((issue) => ({
field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message,
})));
}
return (await this.editor.save(
workspace.id,
request.auth.userId,
requiredUuid('product_id', productId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
parseEditorRevision(ifMatch),
parsed.data,
)).body;
}
}
function requiredUuid(field: string, value: string | undefined) {
if (value && z.uuid().safeParse(value).success) return value;
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
]);
}
function parseEditorRevision(value: string | undefined) {
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match');
return { product: Number(match[1]), profile: Number(match[2]) };
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { ProductEditorController } from './product-editor.controller.js';
import { ProductEditorService } from './product-editor.service.js';
@Module({
controllers: [ProductEditorController],
providers: [IdempotencyService, ProductEditorService],
})
export class ProductEditorModule {}
@@ -0,0 +1,550 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
ProductEditorContext,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} 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';
type ProductRow = {
id: string;
collection_item_id: string;
revision: number;
source_title: string | null;
brand: string | null;
model: string | null;
barcode: string | null;
purchase_url: string | null;
supply_note: string | null;
};
type ProfileRow = {
id: string;
revision: number;
platform_id: string;
market_code: string | null;
fulfillment: string;
title: string | null;
description: string | null;
tags: unknown;
platform_product_category_id: string | null;
platform_type_id: string | null;
category_source: string | null;
target_store_id: string;
store_name: string;
currency_code: string;
};
@Injectable()
export class ProductEditorService {
public constructor(
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(DATABASE) private readonly database: Database,
) {}
async enter(
workspaceId: string,
userId: string,
collectionItemId: string,
platformId: string,
key: string,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'POST',
path: `/api/collection-items/${collectionItemId}/editor-contexts`,
key,
request: { platform_id: platformId },
},
async (transaction) => {
await this.requireTargetPlatform(transaction, platformId);
const product = await this.requireProductByCollectionItem(
transaction,
collectionItemId,
);
const existing = await this.findProfile(
transaction,
product.id,
platformId,
);
if (existing) {
await this.ensureListings(
transaction,
product.id,
existing,
);
return {
status: 200,
body: {
data: this.toContext(collectionItemId, product.id, existing),
},
};
}
const store = await this.requireDefaultStore(
transaction,
platformId,
);
const created = await transaction.query<{ id: string }>(
`INSERT INTO atlas.product_platform_profiles
(workspace_id, product_id, platform_id, target_store_id, market_code)
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3,
nullif(p.capabilities->>'default_market_code', '')
FROM atlas.platforms p WHERE p.id = $2
ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING
RETURNING id`,
[product.id, platformId, store.id],
);
const profile =
created.rows[0]
? await this.requireProfile(transaction, created.rows[0].id)
: await this.findProfile(transaction, product.id, platformId);
if (!profile) throw new Error('商品编辑上下文创建后无法读取发布配置');
await this.ensureListings(transaction, product.id, profile);
return {
status: created.rows[0] ? 201 : 200,
body: {
data: this.toContext(collectionItemId, product.id, profile),
},
};
},
);
}
async read(
workspaceId: string,
productId: string,
platformId: string,
): Promise<ProductEditorSnapshot> {
return this.database.workspaceTransaction(workspaceId, async (transaction) => {
await this.requireTargetPlatform(transaction, platformId);
const product = await this.requireProduct(transaction, productId);
const profile = await this.findProfile(transaction, productId, platformId);
if (!profile)
throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器');
const variants = await transaction.query<{
id: string; source_variant_key: string | null; source_sku: string | null;
display_name: string | null; sort_order: number; purchase_cost_cny: string | null; listing_id: string;
offer_id: string | null; status: string; selling_price: string | null;
list_price: string | null; currency_code: string | 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, l.status, l.selling_price::text,
l.list_price::text, l.currency_code
FROM atlas.product_variants v
JOIN atlas.product_variant_platform_listings l
ON l.product_variant_id = v.id AND l.profile_id = $1
WHERE v.product_id = $2 AND v.deleted_at IS NULL
ORDER BY v.sort_order, v.id`,
[profile.id, productId],
);
const attributes = await transaction.query<{
id: string; scope: 'common' | 'variant'; product_variant_id: string | null;
platform_attribute_id: string; complex_id: string; values: unknown;
language_code: string | null; source: string; confirmation_status: string;
}>(
`SELECT id, scope, product_variant_id, platform_attribute_id, complex_id,
values, language_code, source, confirmation_status
FROM atlas.product_attributes WHERE profile_id = $1
ORDER BY created_at, id`,
[profile.id],
);
const unmapped = await transaction.query<{
id: string; product_variant_id: string | null;
original_platform_category_id: string | null;
original_platform_attribute_id: string; original_complex_id: string;
original_name: string; values: unknown; language_code: string | null;
reason: string; status: string;
}>(
`SELECT id, product_variant_id, original_platform_category_id,
original_platform_attribute_id, original_complex_id, original_name,
values, language_code, reason, status
FROM atlas.product_unmapped_attributes
WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`,
[profile.id],
);
return {
product: {
id: product.id,
revision: Number(product.revision),
common: {
source_title: product.source_title,
brand: product.brand,
model: product.model,
barcode: product.barcode,
purchase_url: product.purchase_url,
supply_note: product.supply_note,
},
},
profile: {
id: profile.id,
revision: Number(profile.revision),
platform_id: profile.platform_id,
market_code: profile.market_code,
fulfillment: profile.fulfillment,
target_store: {
id: profile.target_store_id,
name: profile.store_name,
currency_code: profile.currency_code,
},
title: profile.title,
description: profile.description,
tags: Array.isArray(profile.tags)
? profile.tags.filter((value): value is string => typeof value === 'string')
: [],
category: {
id: profile.platform_product_category_id,
platform_type_id: profile.platform_type_id,
source: profile.category_source,
},
},
variants: variants.rows.map((variant) => ({
id: variant.id,
source_variant_key: variant.source_variant_key,
source_sku: variant.source_sku,
display_name: variant.display_name,
sort_order: variant.sort_order,
purchase_cost_cny: variant.purchase_cost_cny,
listing: {
id: variant.listing_id,
offer_id: variant.offer_id,
status: variant.status,
selling_price: variant.selling_price,
list_price: variant.list_price,
currency_code: variant.currency_code,
},
})),
attributes: attributes.rows.map((attribute) => ({
id: attribute.id,
scope: attribute.scope,
variant_id: attribute.product_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,
})),
unmapped_attributes: unmapped.rows.map((attribute) => ({
id: attribute.id,
variant_id: attribute.product_variant_id,
original_platform_category_id: attribute.original_platform_category_id,
original_platform_attribute_id: attribute.original_platform_attribute_id,
original_complex_id: attribute.original_complex_id,
original_name: attribute.original_name,
values: attribute.values,
language_code: attribute.language_code,
reason: attribute.reason,
status: attribute.status,
})),
warnings: unmapped.rows.length
? [{ code: 'UNMAPPED_ATTRIBUTES_PENDING', message: '存在因类目变化保留的未映射属性' }]
: [],
};
});
}
async save(
workspaceId: string,
userId: string,
productId: string,
platformId: string,
key: string,
expected: { product: number; profile: number },
input: SaveProductEditorDraftRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
method: 'PUT',
path: `/api/products/${productId}/editor-draft`,
key,
request: { if_match: expected, platform_id: platformId, ...input },
},
async (transaction) => {
await this.requireTargetPlatform(transaction, platformId);
const product = await this.requireProduct(transaction, productId);
const profile = await this.findProfile(transaction, productId, platformId);
if (!profile) throw new ApiError(409, 'EDITOR_CONTEXT_REQUIRED', '请先从采集箱进入商品编辑器');
await this.requireEditableStore(transaction, input.profile.target_store_id, platformId);
await this.ensureVariantIdsBelongToProduct(transaction, productId, input);
await this.assertOfferIdsAvailable(transaction, productId, platformId, input);
const updatedProfile = await transaction.query<{ revision: number }>(
`UPDATE atlas.product_platform_profiles
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
tags = $5::jsonb, revision = revision + 1, updated_at = now()
WHERE id = $6 AND product_id = $7 AND platform_id = $8 AND revision = $9
RETURNING revision`,
[input.profile.target_store_id, input.profile.fulfillment, input.profile.title,
input.profile.description, JSON.stringify(input.profile.tags), profile.id,
productId, platformId, expected.profile],
);
if (!updatedProfile.rows[0]) await this.throwProfileRevisionConflict(transaction, profile.id, expected.profile);
const updatedProduct = await transaction.query<{ revision: number }>(
`UPDATE atlas.products
SET source_title = $1, brand = $2, model = $3, barcode = $4,
purchase_url = $5, supply_note = $6, revision = revision + 1, updated_at = now()
WHERE id = $7 AND revision = $8 RETURNING revision`,
[input.common.source_title, input.common.brand, input.common.model,
input.common.barcode, input.common.purchase_url, input.common.supply_note,
productId, expected.product],
);
if (!updatedProduct.rows[0]) await this.throwProductRevisionConflict(transaction, productId, expected.product);
await transaction.query(
`UPDATE atlas.collection_items SET source_title = $1, revision = revision + 1, updated_at = now()
WHERE id = $2`,
[input.common.source_title, product.collection_item_id],
);
const createdVariants: Array<{ client_key: string; id: string }> = [];
const retainedIds: string[] = [];
for (let index = 0; index < input.variants.length; index += 1) {
const variant = input.variants[index];
if (variant.id) {
retainedIds.push(variant.id);
await transaction.query(
`UPDATE atlas.product_variants
SET display_name = $1, source_sku = $2, purchase_cost_cny = $3,
sort_order = $4, updated_at = now()
WHERE id = $5 AND product_id = $6 AND deleted_at IS NULL`,
[variant.display_name, variant.source_sku, variant.purchase_cost_cny,
index, variant.id, productId],
);
await transaction.query(
`UPDATE atlas.product_variant_platform_listings
SET offer_id = $1, store_id = $2, revision = revision + 1, updated_at = now()
WHERE profile_id = $3 AND product_variant_id = $4 AND status <> 'imported'`,
[variant.offer_id, input.profile.target_store_id, profile.id, variant.id],
);
continue;
}
const copied = variant.copy_from_variant_id
? await transaction.query<{ specification_values: unknown }>(
`SELECT specification_values FROM atlas.product_variants
WHERE id = $1 AND product_id = $2 AND deleted_at IS NULL`,
[variant.copy_from_variant_id, productId],
)
: null;
const inserted = await transaction.query<{ id: string }>(
`INSERT INTO atlas.product_variants
(workspace_id, product_id, display_name, source_sku, purchase_cost_cny,
specification_values, sort_order)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
$5::jsonb, $6) RETURNING id`,
[productId, variant.display_name, variant.source_sku, variant.purchase_cost_cny,
JSON.stringify(copied?.rows[0]?.specification_values ?? []), index],
);
const id = inserted.rows[0].id;
retainedIds.push(id);
createdVariants.push({ client_key: variant.client_key!, id });
await transaction.query(
`INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, currency_code)
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code
FROM atlas.stores s WHERE s.id = $5`,
[productId, profile.id, platformId, id, input.profile.target_store_id, variant.offer_id],
);
}
// A removed, never-imported SKU must release its draft offer_id;
// otherwise a replacement SKU in the same store would hit the
// durable uniqueness rule despite the user having deleted it.
await transaction.query(
`UPDATE atlas.product_variant_platform_listings l
SET offer_id = NULL, revision = revision + 1, updated_at = now()
FROM atlas.product_variants v
WHERE l.profile_id = $1 AND l.product_variant_id = v.id
AND v.product_id = $2 AND v.deleted_at IS NULL
AND NOT (v.id = ANY($3::uuid[])) AND l.status <> 'imported'`,
[profile.id, productId, retainedIds],
);
await transaction.query(
`UPDATE atlas.product_variants SET deleted_at = now(), updated_at = now()
WHERE product_id = $1 AND deleted_at IS NULL AND NOT (id = ANY($2::uuid[]))`,
[productId, retainedIds],
);
return {
status: 200,
body: {
data: {
product_revision: Number(updatedProduct.rows[0].revision),
profile_revision: Number(updatedProfile.rows[0].revision),
created_variants: createdVariants,
warnings: [],
},
},
};
},
);
}
private async ensureListings(
transaction: DatabaseTransaction,
productId: string,
profile: ProfileRow,
) {
await transaction.query(
`INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, currency_code)
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, v.id, $4, s.currency_code
FROM atlas.product_variants v
JOIN atlas.stores s ON s.id = $4
WHERE v.product_id = $1 AND v.deleted_at IS NULL
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
[productId, profile.id, profile.platform_id, profile.target_store_id],
);
}
private async requireTargetPlatform(transaction: DatabaseTransaction, platformId: string) {
const platform = await transaction.query(
`SELECT id FROM atlas.platforms
WHERE id = $1 AND status = 'active'
AND coalesce((capabilities->>'can_be_target')::boolean, false)
AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`,
[platformId],
);
if (!platform.rows[0])
throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于商品编辑');
}
private async requireDefaultStore(transaction: DatabaseTransaction, platformId: string) {
const store = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.stores
WHERE platform_id = $1 AND deleted_at IS NULL AND is_default
AND status = 'active' AND credential_status = 'valid'
LIMIT 1`,
[platformId],
);
if (!store.rows[0])
throw new ApiError(422, 'DEFAULT_STORE_REQUIRED', '请先添加并验证当前平台的默认店铺');
return store.rows[0];
}
private async requireEditableStore(transaction: DatabaseTransaction, storeId: string, platformId: string) {
const store = await transaction.query(
`SELECT id FROM atlas.stores
WHERE id = $1 AND platform_id = $2 AND deleted_at IS NULL
AND status = 'active' AND credential_status = 'valid'`,
[storeId, platformId],
);
if (!store.rows[0]) throw new ApiError(422, 'STORE_UNAVAILABLE', '目标店铺不存在、未验证或不属于当前平台');
}
private async ensureVariantIdsBelongToProduct(
transaction: DatabaseTransaction,
productId: string,
input: SaveProductEditorDraftRequest,
) {
const ids = input.variants.flatMap((variant) => [variant.id, variant.copy_from_variant_id].filter((id): id is string => !!id));
if (!ids.length) return;
const owned = await transaction.query<{ id: string }>(
`SELECT id FROM atlas.product_variants
WHERE product_id = $1 AND deleted_at IS NULL AND id = ANY($2::uuid[])`,
[productId, ids],
);
if (new Set(owned.rows.map((row) => row.id)).size !== new Set(ids).size) {
throw new ApiError(422, 'VARIANT_PRODUCT_MISMATCH', 'SKU 不属于当前商品');
}
}
private async assertOfferIdsAvailable(
transaction: DatabaseTransaction,
productId: string,
platformId: string,
input: SaveProductEditorDraftRequest,
) {
const offers = input.variants
.map((variant) => variant.offer_id)
.filter((offer): offer is string => !!offer);
if (new Set(offers).size !== offers.length) {
throw new ApiError(422, 'OFFER_ID_DUPLICATE', '同一商品的 SKU 货号不能重复');
}
if (!offers.length) return;
const conflicts = await transaction.query<{ offer_id: string }>(
`SELECT offer_id FROM atlas.product_variant_platform_listings
WHERE platform_id = $1 AND store_id = $2 AND product_id <> $3
AND offer_id = ANY($4::text[])`,
[platformId, input.profile.target_store_id, productId, offers],
);
if (conflicts.rows[0]) {
throw new ApiError(422, 'OFFER_ID_ALREADY_EXISTS', '目标店铺中已存在相同货号', [{
field: 'variants', code: 'OFFER_ID_ALREADY_EXISTS',
message: `货号 ${conflicts.rows[0].offer_id} 已被其他商品使用`,
}]);
}
}
private async throwProductRevisionConflict(transaction: DatabaseTransaction, productId: string, expected: number): Promise<never> {
const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.products WHERE id = $1', [productId]);
if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
throw new ApiError(409, 'VERSION_CONFLICT', '商品已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前商品版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]);
}
private async throwProfileRevisionConflict(transaction: DatabaseTransaction, profileId: string, expected: number): Promise<never> {
const row = await transaction.query<{ revision: number }>('SELECT revision FROM atlas.product_platform_profiles WHERE id = $1', [profileId]);
if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品发布配置不存在');
throw new ApiError(409, 'VERSION_CONFLICT', '商品发布配置已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前发布配置版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision, expected_revision: expected }]);
}
private async requireProductByCollectionItem(transaction: DatabaseTransaction, collectionItemId: string) {
const product = await transaction.query<ProductRow>(
`SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand,
p.model, p.barcode, p.purchase_url, p.supply_note
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
WHERE c.id = $1 AND c.deleted_at IS NULL`,
[collectionItemId],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在');
return product.rows[0];
}
private async requireProduct(transaction: DatabaseTransaction, productId: string) {
const product = await transaction.query<ProductRow>(
`SELECT p.id, p.collection_item_id, p.revision, p.source_title, p.brand,
p.model, p.barcode, p.purchase_url, p.supply_note
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
WHERE p.id = $1 AND c.deleted_at IS NULL`,
[productId],
);
if (!product.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
return product.rows[0];
}
private async findProfile(transaction: DatabaseTransaction, productId: string, platformId: string) {
const profile = await transaction.query<ProfileRow>(`${profileSelect}
WHERE p.product_id = $1 AND p.platform_id = $2`, [productId, platformId]);
return profile.rows[0] ?? null;
}
private async requireProfile(transaction: DatabaseTransaction, profileId: string) {
const profile = await transaction.query<ProfileRow>(`${profileSelect} WHERE p.id = $1`, [profileId]);
if (!profile.rows[0]) throw new Error('商品发布配置不存在');
return profile.rows[0];
}
private toContext(collectionItemId: string, productId: string, profile: ProfileRow): ProductEditorContext {
return {
collection_item_id: collectionItemId,
product_id: productId,
profile_id: profile.id,
target_store: {
id: profile.target_store_id,
name: profile.store_name,
currency_code: profile.currency_code,
},
editor_path: `/products/${productId}/editor`,
};
}
}
const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id,
p.platform_type_id, p.category_source, p.target_store_id, s.name AS store_name,
s.currency_code
FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id`;
+291
View File
@@ -0,0 +1,291 @@
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 = '';
let platformId = '';
let first: { collectionItemId: string; productId: string; profileId: string };
async function request(path: string, init: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
return {
response,
body: response.status === 204 ? undefined : ((await response.json()) as any),
};
}
function headers(extra: Record<string, string> = {}) {
return { Authorization: `Bearer ${token}`, ...extra };
}
async function createCollectionItem(title: string) {
const created = await request('/api/collection-items', {
method: 'POST',
headers: headers({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ title }),
});
assert.equal(created.response.status, 201);
return {
collectionItemId: created.body.data.collection_item.id as string,
productId: created.body.data.product_id as string,
};
}
describe('商品编辑 M3.0 / M3.1 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 registration = await request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: `editor-${randomUUID()}@example.test`,
password: 'correct-horse-battery-staple-123',
password_confirmation: 'correct-horse-battery-staple-123',
}),
});
assert.equal(registration.response.status, 201);
userId = registration.body.data.user.id;
workspaceId = registration.body.data.workspace.id;
token = registration.body.data.access_token;
const platforms = await request('/api/platforms', { headers: headers() });
platformId = platforms.body.data[0].id;
});
afterAll(async () => {
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.product_unmapped_attributes WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.product_attributes WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]);
await tx.query('DELETE FROM atlas.product_platform_profiles 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 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]);
await app.close();
});
it('仅在存在已验证默认店铺时创建平台编辑上下文,并返回稳定读模型', async () => {
const created = await createCollectionItem('编辑器测试商品 A');
const missingStore = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
});
assert.equal(missingStore.response.status, 422);
assert.equal(missingStore.body.error.code, 'DEFAULT_STORE_REQUIRED');
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.stores
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, 'M3 测试店铺', $2, 'RUB', true, 'active', 'valid')`,
[platformId, `editor-${randomUUID()}`],
);
});
const entered = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
});
assert.equal(entered.response.status, 201);
first = {
collectionItemId: created.collectionItemId,
productId: created.productId,
profileId: entered.body.data.profile_id,
};
const enteredAgain = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
});
assert.equal(enteredAgain.response.status, 200);
assert.equal(enteredAgain.body.data.profile_id, first.profileId);
const editor = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(editor.response.status, 200);
assert.equal(editor.body.data.product.id, first.productId);
assert.equal(editor.body.data.profile.id, first.profileId);
assert.equal(editor.body.data.variants.length, 1);
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed');
});
it('数据库拒绝跨商品 SKU 与 profile 的关联,并保留未映射属性草稿', async () => {
const second = await createCollectionItem('编辑器测试商品 B');
const secondContext = await request(`/api/collection-items/${second.collectionItemId}/editor-contexts`, {
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
});
assert.equal(secondContext.response.status, 201);
const link = await database.workspaceTransaction(workspaceId, async (tx) => {
const secondVariant = await tx.query<{ id: string }>(
'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL',
[second.productId],
);
const firstProfile = await tx.query<{ target_store_id: string }>(
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
[first.profileId],
);
return {
variantId: secondVariant.rows[0].id,
storeId: firstProfile.rows[0].target_store_id,
};
});
await assert.rejects(
database.workspaceTransaction(workspaceId, async (tx) =>
tx.query(
`INSERT INTO atlas.product_variant_platform_listings
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)`,
[second.productId, first.profileId, platformId, link.variantId, link.storeId],
),
),
{ code: '23503' },
);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.product_unmapped_attributes
(workspace_id, product_id, profile_id, platform_id, original_platform_attribute_id,
original_name, values, reason)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'legacy-material',
'旧类目材质', '["棉"]'::jsonb, 'category_changed')`,
[first.productId, first.profileId, platformId],
);
});
const editor = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(editor.response.status, 200);
assert.equal(editor.body.data.unmapped_attributes.length, 1);
assert.equal(editor.body.data.unmapped_attributes[0].original_name, '旧类目材质');
assert.equal(editor.body.data.warnings[0].code, 'UNMAPPED_ATTRIBUTES_PENDING');
});
it('原子保存草稿会更新双版本、创建 SKU,并拒绝过期版本覆盖', async () => {
const before = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
const existing = before.body.data.variants[0];
const payload = {
common: {
source_title: '已由编辑器确认的商品名', brand: 'Atlas', model: null,
barcode: null, purchase_url: null, supply_note: 'M3.1 集成测试',
},
profile: {
target_store_id: before.body.data.profile.target_store.id,
fulfillment: 'rfbs', title: 'Ozon 测试标题', description: null,
tags: ['测试', '草稿'],
},
variants: [
{
id: existing.id, client_key: null, copy_from_variant_id: null,
display_name: '默认 SKU(已编辑)', source_sku: existing.source_sku,
purchase_cost_cny: '88.50', offer_id: 'm3-variant-a',
},
{
id: null, client_key: 'new-variant-b', copy_from_variant_id: existing.id,
display_name: '新增 SKU', source_sku: null,
purchase_cost_cny: '99.00', offer_id: 'm3-variant-b',
},
],
};
const key = randomUUID();
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': key,
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.equal(saved.response.status, 200);
assert.equal(saved.body.data.created_variants.length, 1);
assert.equal(saved.body.data.product_revision, before.body.data.product.revision + 1);
assert.equal(saved.body.data.profile_revision, before.body.data.profile.revision + 1);
const replay = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': key,
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.deepEqual(replay.body, saved.body);
const after = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(after.body.data.product.common.source_title, '已由编辑器确认的商品名');
assert.equal(after.body.data.profile.title, 'Ozon 测试标题');
assert.equal(after.body.data.variants.length, 2);
assert.equal(after.body.data.variants[0].listing.offer_id, 'm3-variant-a');
const stale = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.equal(stale.response.status, 409);
assert.equal(stale.body.error.code, 'VERSION_CONFLICT');
const removeSecond = { ...payload, variants: [payload.variants[0]] };
const removed = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
}),
body: JSON.stringify(removeSecond),
});
assert.equal(removed.response.status, 200);
const replacement = {
...removeSecond,
variants: [
removeSecond.variants[0],
{
id: null, client_key: 'replacement-variant-b', copy_from_variant_id: existing.id,
display_name: '替代 SKU', source_sku: null,
purchase_cost_cny: '99.00', offer_id: 'm3-variant-b',
},
],
};
const reusedOffer = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`,
}),
body: JSON.stringify(replacement),
});
assert.equal(reusedOffer.response.status, 200);
});
});