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);
});
});
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
@@ -6,16 +7,18 @@ import type { CollectionItem } from '@atlas/contracts';
import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api';
import { usePlatform } from '@/services/platform';
import { productEditorApi } from '@/services/productEditor';
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
export default function CollectionPage() {
const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>();
const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const navigate = useNavigate(); const [items, setItems] = useState<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>();
const load = useCallback(async () => { if (!currentPlatform) return; setLoading(true); try { const result = await collectionApi.list(currentPlatform.id, { ...(q.trim() ? { q: q.trim() } : {}), ...(source ? { source_platform_code: source as 'OZON' } : {}) }); setItems(result.data); setMeta(result.meta); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, message, q, source]);
useEffect(() => { void load(); }, [load]);
const create = async (values: { title?: string; note?: string }) => { try { await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); await load(); } catch (error) { message.error(apiErrorMessage(error)); } };
const openEditor = async (itemId: string) => { if (!currentPlatform) return; try { const context = await productEditorApi.enter(itemId, currentPlatform.id); navigate(context.editor_path); } catch (error) { message.error(apiErrorMessage(error)); } };
const create = async (values: { title?: string; note?: string }) => { try { const created = await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); if (currentPlatform) await openEditor(created.collection_item.id); else await load(); } catch (error) { message.error(apiErrorMessage(error)); } };
const remove = (ids: string[], all = false) => modal.confirm({ title: all ? '清空采集箱' : '删除采集商品', content: all ? `将清空当前 ${meta.active_count} 条采集草稿,不会删除或下架任何平台商品。确定继续吗?` : '仅删除 Atlas 内的采集草稿,不会删除或下架任何平台商品。确定继续吗?', okText: all ? '清空' : '删除', okButtonProps: { danger: true }, onOk: async () => { try { const result = all ? await collectionApi.clear(meta.active_count) : await collectionApi.bulkDelete(ids); message.success(`已删除 ${result.deleted_count} 条采集草稿`); setSelected([]); await load(); } catch (error) { message.error(apiErrorMessage(error)); } } });
const columns: TableProps<CollectionItem>['columns'] = [
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => message.info('商品编辑将在下一任务提供')}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => void openEditor(item.id)}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
];
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
+170 -10
View File
@@ -1,16 +1,176 @@
import { useParams } from 'react-router-dom';
import PagePlaceholder from '@/components/PagePlaceholder';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { App as AntdApp, Alert, Button, Card, Descriptions, Divider, Form, Input, Select, Space, Spin, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot, SaveProductEditorDraftRequest } from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { productEditorApi } from '@/services/productEditor';
import { usePlatform } from '@/services/platform';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
type VariantValues = {
id: string | null;
client_key: string | null;
copy_from_variant_id: string | null;
display_name: string | null;
source_sku: string | null;
purchase_cost_cny: string | null;
offer_id: string | null;
};
type EditorFormValues = SaveProductEditorDraftRequest;
function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
return {
common: snapshot.product.common,
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment'],
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
},
variants: snapshot.variants.map((variant) => ({
id: variant.id,
client_key: null,
copy_from_variant_id: null,
display_name: variant.display_name,
source_sku: variant.source_sku,
purchase_cost_cny: variant.purchase_cost_cny,
offer_id: variant.listing.offer_id,
})),
};
}
function nullable(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function normalizedValues(values: EditorFormValues): EditorFormValues {
return {
...values,
common: {
source_title: nullable(values.common.source_title), brand: nullable(values.common.brand),
model: nullable(values.common.model), barcode: nullable(values.common.barcode),
purchase_url: nullable(values.common.purchase_url), supply_note: nullable(values.common.supply_note),
},
profile: {
...values.profile, title: nullable(values.profile.title), description: nullable(values.profile.description),
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
},
variants: values.variants.map((variant) => ({
...variant, display_name: nullable(variant.display_name), source_sku: nullable(variant.source_sku),
purchase_cost_cny: nullable(variant.purchase_cost_cny), offer_id: nullable(variant.offer_id),
})),
};
}
/** M3.1 正式编辑入口:产品、当前平台文案与 SKU 基础数据一次性原子保存。 */
const ProductEditPage = () => {
const { id } = useParams<{ id: string }>();
const { id: productId } = useParams<{ id: string }>();
const { currentPlatform } = usePlatform();
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const [form] = Form.useForm<EditorFormValues>();
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
const load = useCallback(async () => {
if (!productId || !currentPlatform) return;
setLoading(true);
try {
const next = await productEditorApi.read(productId, currentPlatform.id);
setSnapshot(next);
form.setFieldsValue(formValues(next));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoading(false);
}
}, [currentPlatform, form, message, productId]);
useEffect(() => { void load(); }, [load]);
const save = async (values: EditorFormValues) => {
if (!productId || !currentPlatform || !snapshot) return;
setSaving(true);
try {
await productEditorApi.save(productId, currentPlatform.id, {
product: snapshot.product.revision,
profile: snapshot.profile.revision,
}, normalizedValues(values));
message.success('草稿已保存');
await load();
} catch (error) {
if (apiErrorCode(error) === 'VERSION_CONFLICT') {
message.warning('商品已在其他位置更新,已重新加载最新内容');
await load();
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSaving(false);
}
};
if (!currentPlatform) return <Alert type="warning" message="正在读取平台上下文" />;
if (loading && !snapshot) return <Spin size="large" style={{ display: 'block', margin: '72px auto' }} />;
if (!snapshot) return <Alert type="error" message="无法读取商品编辑数据" action={<Button onClick={() => void load()}>重试</Button>} />;
return <Form form={form} layout="vertical" onFinish={save} requiredMark={false}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<div><Typography.Title level={3} style={{ margin: 0 }}>商品编辑</Typography.Title><Typography.Text type="secondary">当前平台:{currentPlatform.name} · 草稿保存不会发布商品</Typography.Text></div>
<Space><Button onClick={() => navigate('/collection')}>返回采集箱</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
</Space>
</Card>
{snapshot.warnings.map((warning) => <Alert key={warning.code} type="warning" message={warning.message} showIcon />)}
<Card title="商品基础信息">
<Form.Item name={['common', 'source_title']} label="商品名称" rules={[{ max: 1000 }]}><Input placeholder="商品名称" /></Form.Item>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item name={['common', 'brand']} label="品牌" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={['common', 'model']} label="型号" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={['common', 'barcode']} label="条形码" style={{ flex: 1 }}><Input /></Form.Item>
</Space>
<Form.Item name={['common', 'purchase_url']} label="采购链接" rules={[{ type: 'url', message: '请输入有效 URL' }]}><Input /></Form.Item>
<Form.Item name={['common', 'supply_note']} label="货源备注"><Input.TextArea rows={2} maxLength={4000} /></Form.Item>
</Card>
<Card title="当前平台资料">
<Descriptions size="small" column={2} style={{ marginBottom: 18 }}>
<Descriptions.Item label="目标店铺">{snapshot.profile.target_store.name}({snapshot.profile.target_store.currency_code})</Descriptions.Item>
<Descriptions.Item label="市场">{snapshot.profile.market_code ?? '未配置'}</Descriptions.Item>
</Descriptions>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item name={['profile', 'fulfillment']} label="履约方式" style={{ width: 180 }}><Select options={[{ value: 'unknown', label: '待确认' }, { value: 'rfbs', label: 'rFBS' }, { value: 'fbs', label: 'FBS' }, { value: 'fbo', label: 'FBO' }, { value: 'fbp', label: 'FBP' }]} /></Form.Item>
<Form.Item name={['profile', 'tags']} label="标签" style={{ flex: 1 }}><Select mode="tags" tokenSeparators={[',']} placeholder="输入后回车添加" /></Form.Item>
</Space>
<Form.Item name={['profile', 'title']} label="平台标题" rules={[{ max: 1000 }]}><Input /></Form.Item>
<Form.Item name={['profile', 'description']} label="平台描述" rules={[{ max: 100000 }]}><Input.TextArea rows={4} /></Form.Item>
<Form.Item name={['profile', 'target_store_id']} hidden><Input /></Form.Item>
</Card>
<Card title="SKU 与首次上架货号" extra={<Typography.Text type="secondary">保存草稿不提交 Ozon</Typography.Text>}>
<Form.List name="variants">
{(fields, { add, remove }) => <Space direction="vertical" style={{ width: '100%' }} size={12}>
{fields.map((field, index) => <Card key={field.key} size="small" title={`SKU ${index + 1}`} extra={fields.length > 1 ? <Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(field.name)}>删除</Button> : null}>
<Form.Item name={[field.name, 'id']} hidden><Input /></Form.Item>
<Form.Item name={[field.name, 'client_key']} hidden><Input /></Form.Item>
<Form.Item name={[field.name, 'copy_from_variant_id']} hidden><Input /></Form.Item>
<Space style={{ width: '100%' }} size={12} align="start">
<Form.Item name={[field.name, 'display_name']} label="SKU 名称" style={{ flex: 2 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'source_sku']} label="来源 SKU" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'offer_id']} label="Ozon 货号" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'purchase_cost_cny']} label="采购价(CNY)" style={{ flex: 1 }} rules={[{ pattern: /^\d+(\.\d{1,4})?$/, message: '请输入金额' }]}><Input /></Form.Item>
</Space>
</Card>)}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ id: null, client_key: crypto.randomUUID(), copy_from_variant_id: null, display_name: null, source_sku: null, purchase_cost_cny: null, offer_id: null } satisfies VariantValues)}>新增 SKU</Button>
</Space>}
</Form.List>
</Card>
<Divider style={{ margin: 0 }} />
<Space style={{ justifyContent: 'flex-end', width: '100%' }}><Button onClick={() => navigate('/collection')}>取消</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
</Space>
</Form>;
};
export default ProductEditPage;
+1
View File
@@ -63,6 +63,7 @@ export const router = createBrowserRouter([
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
{ path: 'products/:id', element: <ProductEditPage /> },
{ path: 'products/:id/editor', element: <ProductEditPage /> },
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
{ path: 'settings/password', element: <PasswordSettingsPage /> },
+30
View File
@@ -0,0 +1,30 @@
import type {
ProductEditorContext,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
SaveProductEditorDraftResponse,
} from '@atlas/contracts';
import { api } from './api';
export const productEditorApi = {
enter: (collectionItemId: string, platformId: string) =>
api.post<ProductEditorContext>(`/collection-items/${collectionItemId}/editor-contexts`, undefined, {
headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() },
}),
read: (productId: string, platformId: string) =>
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
save: (
productId: string,
platformId: string,
revisions: { product: number; profile: number },
payload: SaveProductEditorDraftRequest,
) => api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
headers: {
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': crypto.randomUUID(),
'If-Match': `"${revisions.product}:${revisions.profile}"`,
},
}),
};
+1 -1
View File
@@ -16,7 +16,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印,以及水印图片直传、校验和短时预览;图片渲染派生待实现。 |
| [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | M3.1 部分已实现 | 编辑上下文、只读快照和产品/平台文案/SKU 的原子草稿保存;类目/属性、媒体、定价、AI 与首次上架仍待实现。 |
| [`online-listings.md`](./online-listings.md) | 初版已设计 | 店铺独有的在线商品镜像、读取与异步同步;编辑/认领仍待讨论。 |
旧的 [`../product/api.md`](../product/api.md) 是商品域的早期草案:已确认的产品规则可作为背景,但不能当作最终 HTTP 契约。后续商品模块会迁入本目录并替换其中的路径和细节。
@@ -1,5 +1,7 @@
# 商品编辑与首次上架 API
> M3.0 已实现第 1 节的 `POST /editor-contexts` 和 `GET /editor` 基础读模型,以及对应数据库归属约束。M3.1 已实现第 2 节的产品基础字段、当前平台文案、SKU 与 `offer_id` 的原子草稿保存及双版本并发控制;规格、类目/属性切换、媒体选择、定价、AI 与发布仍是冻结契约,尚未实现。
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
## 1. 进入编辑与读取编辑器
+15
View File
@@ -161,6 +161,21 @@
- 1688、淘宝、天猫已加入受限 MAIN-world 商品状态桥:1688 仅投影公开商品的画廊、SKU、价格、参数等固定字段;淘宝/天猫仅投影 `__ICE_APP_CONTEXT__` 中的公开商品结果。桥不提供任意 window 读取、Cookie、请求头或通用 fetch。新采集器将这些结构化数据、JSON-LD、受限详情 DOM 与既有图片 profile 映射为 Atlas 新 `CollectedProductPayload`。
- `/collection-test/{collectionItemId}` 现单独展示商品描述、类目路径、全部特征表、规格轴、SKU 草稿、素材候选,并保留规范化载荷和原始证据,供解析核对。扩展解析器测试覆盖 JSON-LD 描述兜底、完整特征合并和图片白名单。
### 1.19 开发进度(2026-09-09):M3.0 商品编辑领域基础
- 新增迁移 `018_product_editor_foundation.sql`:采集期最小 `products` / `product_variants` 扩展为可编辑的产品聚合,并建立平台发布配置、SKU 平台 listing、平台属性及未映射属性草稿表。所有表启用并强制工作空间 RLS。
- `product_variant_platform_listings` 同时保存 `product_id`,以 profile→product 和 variant→product 两条复合外键强制同商品归属;数据库会拒绝同一工作空间中“商品 A profile + 商品 B SKU”的错误组合。属性和未映射属性也受 profile/variant 与 product 的复合外键约束。
- 已实现 `POST /api/collection-items/{id}/editor-contexts`:只有当前页头目标平台存在已验证默认门店时,才原子取得或创建产品的该平台 profile,并为现有 SKU 建立初始 `not_listed` listing。`GET /api/products/{id}/editor` 返回产品、profile、SKU listing、属性和待处理未映射属性的只读快照。
- `products.version` 已迁移为统一命名的 `revision`;新增共享版本冲突辅助。M3.1 的完整草稿写入仍必须以 `product.revision + profile.revision` 条件更新为边界,并覆盖并发保存。
- API 集成测试覆盖默认店铺前置、上下文幂等复用、读模型、跨商品外键拒绝和未映射属性读回。正式编辑 Web、草稿保存、类目切换、素材选择、定价与发布尚未开始。
### 1.20 开发进度(2026-09-09):M3.1 商品草稿保存与正式编辑入口
- 已实现 `PUT /api/products/{id}/editor-draft`:产品基础资料、当前平台标题/描述/标签/履约方式,以及完整 SKU 集合和未上架 listing 的 `offer_id` 在同一事务保存。新 SKU 以 `client_key` 回传稳定 ID;省略现有 SKU 会软删除 Atlas 草稿,绝不触发平台下架。
- 请求必须携带 `If-Match: "{product_revision}:{profile_revision}"` 与 `Idempotency-Key`。服务端先后执行两个带 revision 条件的更新;任一步冲突或 SKU/货号校验失败都会回滚整笔保存。店铺内货号重复会返回可展示的 `422`,重复请求键安全重放原响应。
- Web 的采集箱“编辑上架”和手动创建现会创建编辑上下文后进入 `/products/{id}/editor`。正式页可编辑产品基础资料、当前平台文案及 SKU/货号,遇到版本冲突会重新加载最新快照。
- 此批刻意不展示未具备平台 schema 或发布校验的数据:类目/属性切换与未映射属性恢复、规格继承、候选媒体物化/选图、定价确认、AI 和首次 Ozon 导入仍待后续纵向任务。
## 2. 已确认产品决策
| 主题 | 决策 |
@@ -37,7 +37,7 @@
### 1. SKU 与平台发布配置的同商品归属
**状态:已决策,待数据库约束设计。**
**状态:M3.0 已落地数据库约束(2026-09-09);发布任务、在线镜像等后续表必须复用该归属链。**
`product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。
@@ -45,7 +45,7 @@
### 2. 类目切换后未映射属性的持久化
**状态:已决策,待数据模型补齐。**
**状态:M3.0 已落地持久化表(2026-09-09);类目切换写入与恢复操作随编辑草稿保存实现。**
切换目标平台类目后,旧类目中无法映射的属性值必须作为 Atlas 草稿保留,供用户查看、复用或手动处理,不能只出现在一次 API response 的 warning 中。
@@ -58,7 +58,7 @@
### 3. 乐观锁的统一实施
**状态:已决策,待 Repository 规则和测试落地。**
**状态:M3.1 已使用产品/profile 双 `revision` 条件更新、事务回滚和集成测试落地;后续编辑资源不得绕过该规则。**
`revision` 不依赖数据库触发器自动递增。所有可编辑资源的更新必须由统一 Repository/数据访问层执行:
+1
View File
@@ -3,3 +3,4 @@ export * from './auth';
export * from './stores';
export * from './watermarks';
export * from './media';
export * from './product-editor';
+127
View File
@@ -0,0 +1,127 @@
import { z } from 'zod';
/**
* M3.0 editor entry and read-model contracts. Draft mutation, pricing, and
* publishing DTOs intentionally stay out of this first editor slice.
*/
export type ProductEditorContext = {
collection_item_id: string;
product_id: string;
profile_id: string;
target_store: { id: string; name: string; currency_code: string };
editor_path: string;
};
export type ProductEditorSnapshot = {
product: {
id: string;
revision: number;
common: {
source_title: string | null;
brand: string | null;
model: string | null;
barcode: string | null;
purchase_url: string | null;
supply_note: string | null;
};
};
profile: {
id: string;
revision: number;
platform_id: string;
market_code: string | null;
fulfillment: string;
target_store: { id: string; name: string; currency_code: string };
title: string | null;
description: string | null;
tags: string[];
category: {
id: string | null;
platform_type_id: string | null;
source: string | null;
};
};
variants: Array<{
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;
};
}>;
attributes: Array<{
id: string;
scope: 'common' | 'variant';
variant_id: string | null;
platform_attribute_id: string;
complex_id: string;
values: unknown;
language_code: string | null;
source: string;
confirmation_status: string;
}>;
unmapped_attributes: Array<{
id: string;
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;
}>;
warnings: Array<{ code: string; message: string }>;
};
const uuid = z.uuid();
const nullableText = (max: number) => z.string().trim().max(max).nullable();
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
export const saveProductEditorDraftRequestSchema = z.object({
common: z.object({
source_title: nullableText(1_000),
brand: nullableText(300),
model: nullableText(300),
barcode: nullableText(300),
purchase_url: z.url().nullable(),
supply_note: nullableText(4_000),
}).strict(),
profile: z.object({
target_store_id: uuid,
fulfillment: z.enum(['rfbs', 'fbs', 'fbo', 'fbp', 'unknown']),
title: nullableText(1_000),
description: nullableText(100_000),
tags: z.array(z.string().trim().min(1).max(100)).max(30),
}).strict(),
variants: z.array(z.object({
id: uuid.nullable(),
client_key: z.string().trim().min(1).max(100).nullable(),
copy_from_variant_id: uuid.nullable().optional(),
display_name: nullableText(1_000),
source_sku: nullableText(500),
purchase_cost_cny: z.string().regex(/^\d+(\.\d{1,4})?$/).nullable(),
offer_id: nullableText(500),
}).strict().superRefine((variant, context) => {
if (!variant.id && !variant.client_key) {
context.addIssue({ code: 'custom', path: ['client_key'], message: '新 SKU 必须提供 client_key' });
}
})).min(1).max(100),
}).strict();
export type SaveProductEditorDraftRequest = z.infer<typeof saveProductEditorDraftRequestSchema>;
export type SaveProductEditorDraftResponse = {
product_revision: number;
profile_revision: number;
created_variants: Array<{ client_key: string; id: string }>;
warnings: Array<{ code: string; message: string }>;
};
@@ -0,0 +1,186 @@
SET search_path TO atlas, public;
-- M3.0 turns the collection-time draft into an editable product aggregate.
-- Keep this migration deliberately narrower than publishing, pricing, AI, and
-- online-sync models: those capabilities need their own immutable snapshots.
ALTER TABLE products RENAME COLUMN version TO revision;
ALTER TABLE products
ADD COLUMN brand TEXT,
ADD COLUMN model TEXT,
ADD COLUMN barcode TEXT,
ADD COLUMN purchase_url TEXT,
ADD COLUMN supply_note TEXT,
ADD COLUMN default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0),
ADD COLUMN default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0),
ADD COLUMN default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0),
ADD COLUMN default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0),
ADD COLUMN default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0),
ADD COLUMN default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0),
ADD COLUMN default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0),
ADD COLUMN default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0),
ADD COLUMN physical_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (physical_spec_status IN ('missing', 'ambiguous', 'confirmed')),
ADD COLUMN package_spec_status TEXT NOT NULL DEFAULT 'missing' CHECK (package_spec_status IN ('missing', 'ambiguous', 'confirmed'));
ALTER TABLE product_variants
ADD COLUMN purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0),
ADD COLUMN specification_values JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0),
ADD COLUMN physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0),
ADD COLUMN physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0),
ADD COLUMN net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0),
ADD COLUMN package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0),
ADD COLUMN package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0),
ADD COLUMN package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0),
ADD COLUMN shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0),
ADD COLUMN physical_spec_status TEXT CHECK (physical_spec_status IS NULL OR physical_spec_status IN ('missing', 'ambiguous', 'confirmed')),
ADD COLUMN package_spec_status TEXT CHECK (package_spec_status IS NULL OR package_spec_status IN ('missing', 'ambiguous', 'confirmed'));
CREATE UNIQUE INDEX ux_product_variants_product_identity
ON product_variants (workspace_id, id, product_id);
CREATE UNIQUE INDEX ux_product_variants_source_key
ON product_variants (workspace_id, product_id, source_variant_key)
WHERE source_variant_key IS NOT NULL;
CREATE TABLE product_platform_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
platform_id UUID NOT NULL,
target_store_id UUID NOT NULL,
market_code TEXT,
fulfillment TEXT NOT NULL DEFAULT 'unknown' CHECK (fulfillment IN ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown')),
title TEXT,
description TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
platform_product_category_id UUID,
platform_type_id TEXT,
category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')),
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, product_id)
REFERENCES products(workspace_id, id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, target_store_id, platform_id)
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
FOREIGN KEY (platform_id, platform_product_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, id, product_id),
UNIQUE (workspace_id, product_id, platform_id),
UNIQUE (workspace_id, id, product_id, platform_id)
);
CREATE INDEX ix_product_platform_profiles_store
ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC);
-- product_id is intentionally repeated on this relation. The two composite
-- foreign keys make it impossible to join a profile for product A to a SKU of
-- product B, even when both records are otherwise valid in one workspace.
CREATE TABLE product_variant_platform_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
profile_id UUID NOT NULL,
platform_id UUID NOT NULL,
product_variant_id UUID NOT NULL,
store_id UUID NOT NULL,
offer_id TEXT,
currency_code CHAR(3) REFERENCES currencies(code),
selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0),
list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0),
status TEXT NOT NULL DEFAULT 'not_listed' CHECK (status IN ('not_listed', 'submitting', 'imported', 'failed')),
platform_product_id TEXT,
first_imported_at TIMESTAMPTZ,
last_error JSONB,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, profile_id, product_id, platform_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE RESTRICT,
FOREIGN KEY (workspace_id, store_id, platform_id)
REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT,
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, profile_id, product_variant_id),
CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price),
CHECK ((status <> 'imported') OR (first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL))
);
CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer
ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id)
WHERE offer_id IS NOT NULL;
CREATE INDEX ix_variant_platform_listings_profile_status
ON product_variant_platform_listings (workspace_id, profile_id, status);
CREATE TABLE product_attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
profile_id UUID NOT NULL,
product_variant_id UUID,
scope TEXT NOT NULL CHECK (scope IN ('common', 'variant')),
platform_attribute_id TEXT NOT NULL,
complex_id TEXT NOT NULL DEFAULT '0',
values JSONB NOT NULL DEFAULT '[]'::jsonb,
language_code TEXT,
source TEXT NOT NULL DEFAULT 'manual',
confirmation_status TEXT NOT NULL DEFAULT 'confirmed' CHECK (confirmation_status IN ('missing', 'ambiguous', 'confirmed')),
raw_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (workspace_id, profile_id, product_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE,
CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL))
);
CREATE UNIQUE INDEX ux_product_attributes_common
ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
WHERE scope = 'common';
CREATE UNIQUE INDEX ux_product_attributes_variant
ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, ''))
WHERE scope = 'variant';
-- A category change may remove an attribute from the active platform schema.
-- Keep the user's value as a first-class draft rather than returning a warning
-- that disappears on the next editor read.
CREATE TABLE product_unmapped_attributes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
product_id UUID NOT NULL,
profile_id UUID NOT NULL,
platform_id UUID NOT NULL,
product_variant_id UUID,
original_platform_category_id UUID,
original_platform_attribute_id TEXT NOT NULL,
original_complex_id TEXT NOT NULL DEFAULT '0',
original_name TEXT NOT NULL,
values JSONB NOT NULL DEFAULT '[]'::jsonb,
language_code TEXT,
reason TEXT NOT NULL CHECK (reason IN ('category_changed', 'attribute_unavailable')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'restored', 'discarded')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, profile_id, product_id, platform_id)
REFERENCES product_platform_profiles(workspace_id, id, product_id, platform_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, product_variant_id, product_id)
REFERENCES product_variants(workspace_id, id, product_id) ON DELETE CASCADE,
FOREIGN KEY (platform_id, original_platform_category_id)
REFERENCES platform_product_categories(platform_id, id) ON DELETE SET NULL,
CHECK (resolved_at IS NULL OR resolved_at >= created_at)
);
DO $$
DECLARE table_name TEXT;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'product_platform_profiles',
'product_variant_platform_listings',
'product_attributes',
'product_unmapped_attributes'
] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
END LOOP;
END $$;