feat(edit): 商品编辑页开发

This commit is contained in:
R524809
2026-09-10 17:45:46 +08:00
parent 130a005231
commit 946af649dd
22 changed files with 7034 additions and 518 deletions
+137 -32
View File
@@ -98,6 +98,7 @@ export class ExtensionService {
product.rows[0].id,
1,
payload,
false,
);
return {
status: 201,
@@ -191,6 +192,19 @@ export class ExtensionService {
payload: CollectedProductPayload,
) {
const { status, sourcePrice } = sourceState(payload);
// Once a platform profile exists, product_variants are no longer a
// disposable collection projection: they may carry user edits and are
// referenced by platform listings. A re-collection refreshes the
// source evidence/candidates, but must not overwrite that draft or
// sever a SKU's publishing history.
const profileCount = await tx.query<{ count: string }>(
`SELECT count(*)::text AS count
FROM atlas.product_platform_profiles
WHERE product_id = $1`,
[productId],
);
const preserveEditorDraft =
Number(profileCount.rows[0]?.count ?? 0) > 0;
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
await tx.query(
@@ -222,25 +236,14 @@ export class ExtensionService {
const snapshotRevision = Number(item.rows[0]?.source_snapshot_revision);
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1)
throw new Error('采集来源快照版本更新失败');
await tx.query(
`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
// source snapshot. Clear only their optional variant pointer before
// deleting old variants. The original composite FK used ON DELETE SET
// NULL and PostgreSQL tried to null both key columns, including the
// required workspace_id.
await tx.query(
`UPDATE atlas.collection_asset_transfers
SET source_variant_id = NULL, updated_at = now()
WHERE product_id = $1 AND source_variant_id IS NOT NULL`,
[productId],
);
await tx.query(
'DELETE FROM atlas.product_variants WHERE product_id = $1',
[productId],
);
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.products
SET source_title = $1, updated_at = now(), revision = revision + 1
WHERE id = $2`,
[payload.common.title?.value ?? null, productId],
);
}
await this.writeSourceSnapshotRows(
tx,
workspaceId,
@@ -248,6 +251,7 @@ export class ExtensionService {
productId,
snapshotRevision,
payload,
preserveEditorDraft,
);
return snapshotRevision;
}
@@ -259,6 +263,7 @@ export class ExtensionService {
productId: string,
snapshotRevision: number,
payload: CollectedProductPayload,
preserveEditorDraft: boolean,
) {
const verifiedVariants = payload.variants.filter(
(variant) => variant.sourceVariantKey.trim().length > 0,
@@ -272,23 +277,99 @@ export class ExtensionService {
displayName: '默认 SKU',
},
];
const sourceKeys = variantRows
.map((variant) => variant.sourceVariantKey)
.filter((key): key is string => !!key);
const existing = await tx.query<{
id: string;
source_variant_key: string | null;
deleted_at: Date | string | null;
}>(
`SELECT id, source_variant_key, deleted_at
FROM atlas.product_variants
WHERE product_id = $1
AND (source_variant_key = ANY($2::text[]) OR source_variant_key IS NULL)
ORDER BY sort_order, id`,
[productId, sourceKeys],
);
const existingBySourceKey = new Map(
existing.rows
.filter((variant) => variant.source_variant_key)
.map((variant) => [variant.source_variant_key!, variant]),
);
// A capture without a stable source key has exactly one synthetic
// default SKU. Reuse an existing unkeyed row when possible; this keeps
// its platform mapping rather than attempting a duplicate insert.
const existingDefault = existing.rows.find(
(variant) => variant.source_variant_key === null,
);
const variants = new Map<string, string>();
const retainedVariantIds: string[] = [];
for (let index = 0; index < variantRows.length; index += 1) {
const variant = variantRows[index];
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants (workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[
workspaceId,
const matched = variant.sourceVariantKey
? existingBySourceKey.get(variant.sourceVariantKey)
: existingDefault;
let variantId: string;
if (matched) {
variantId = matched.id;
// A user may have deliberately removed an Atlas SKU after
// editing began. Keep that decision; its source evidence is
// still present in normalized_payload, but it is not silently
// revived into the editable/publishable SKU set.
if (!preserveEditorDraft) {
await tx.query(
`UPDATE atlas.product_variants
SET source_sku = $1, display_name = $2, sort_order = $3,
deleted_at = NULL, updated_at = now()
WHERE id = $4`,
[
variant.sourceSku ?? null,
variant.displayName,
index,
variantId,
],
);
}
} else {
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.product_variants
(workspace_id, product_id, source_variant_key, source_sku, display_name, sort_order)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[
workspaceId,
productId,
variant.sourceVariantKey,
variant.sourceSku ?? null,
variant.displayName,
index,
],
);
variantId = inserted.rows[0].id;
await this.ensureProfileListingsForVariant(
tx,
productId,
variant.sourceVariantKey,
variant.sourceSku ?? null,
variant.displayName,
index,
],
);
variantId,
);
}
retainedVariantIds.push(variantId);
if (variant.sourceVariantKey)
variants.set(variant.sourceVariantKey, inserted.rows[0].id);
variants.set(variant.sourceVariantKey, variantId);
}
// Before an editor profile exists, variants are still just the latest
// source projection, so a missing source SKU can be safely hidden. Do
// not physically delete it: the partial source-key unique index does
// not exclude soft-deleted rows, and a later refresh may legitimately
// rediscover the same stable key. After editing begins, omission from a
// source refresh must never act as a user-requested SKU deletion.
if (!preserveEditorDraft) {
await tx.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, retainedVariantIds],
);
}
for (const asset of payload.assets) {
await tx.query(
@@ -323,6 +404,28 @@ export class ExtensionService {
);
}
private async ensureProfileListingsForVariant(
tx: { query: Database['query'] },
productId: string,
variantId: string,
) {
// A source refresh may discover a new real SKU after a user has
// already entered one or more platform editors. Every existing profile
// needs a fresh, explicitly not-listed mapping for that SKU; imported
// mappings for other SKU rows are untouched.
await tx.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, p.id, p.platform_id,
$2, p.target_store_id, s.currency_code
FROM atlas.product_platform_profiles p
JOIN atlas.stores s ON s.id = p.target_store_id
WHERE p.product_id = $1
ON CONFLICT (workspace_id, profile_id, product_variant_id) DO NOTHING`,
[productId, variantId],
);
}
private async materialResponse(
tx: { query: Database['query'] },
collectionItemId: string,
@@ -360,7 +463,9 @@ export class ExtensionService {
editor_entry_path: `/collection-test/${collectionItemId}`,
materialization: {
total: Number(count.rows[0]?.total ?? 0),
pending_selection: Number(count.rows[0]?.pending_selection ?? 0),
pending_selection: Number(
count.rows[0]?.pending_selection ?? 0,
),
queued: Number(count.rows[0]?.queued ?? 0),
succeeded: Number(count.rows[0]?.succeeded ?? 0),
failed: Number(count.rows[0]?.failed ?? 0),
@@ -1,6 +1,21 @@
import { Body, Controller, Get, Headers, Inject, Param, Post, Put, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { z } from 'zod';
import { saveProductEditorDraftRequestSchema, type Workspace } from '@atlas/contracts';
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';
@@ -50,6 +65,137 @@ export class ProductEditorController {
};
}
@Get('product-platform-profiles/:profileId/category-options')
async categoryOptions(
@Param('profileId') profileId: string,
@Query('q') query: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.categoryOptions(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
query,
),
};
}
@Post('product-platform-profiles/:profileId/category-syncs')
async categorySync(
@Param('profileId') profileId: 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.requestCategoryTreeSync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
);
response.status(result.status);
return result.body;
}
@Get('product-platform-profiles/:profileId/attribute-schema')
async attributeSchema(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.editor.attributeSchema(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
optionalUuid('category_id', categoryId),
),
};
}
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
async attributeSchemaSync(
@Param('profileId') profileId: string,
@Query('category_id') categoryId: string | undefined,
@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.requestAttributeSchemaSync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
);
response.status(result.status);
return result.body;
}
@Get(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
)
async attributeDictionary(
@Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string,
@Query('q') query: string | undefined,
@Query('category_id') categoryId: string | undefined,
@Headers('x-atlas-platform-id') platformId: string | undefined,
@CurrentWorkspace() workspace: Workspace,
) {
if (!dictionaryId.trim() || dictionaryId.length > 200)
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
return {
data: await this.editor.attributeDictionary(
workspace.id,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
dictionaryId,
query,
optionalUuid('category_id', categoryId),
),
};
}
@Post(
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
)
async attributeDictionarySync(
@Param('profileId') profileId: string,
@Param('dictionaryId') dictionaryId: string,
@Query('category_id') categoryId: string | undefined,
@Query('q') query: string | undefined,
@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,
) {
if (!dictionaryId.trim() || dictionaryId.length > 200)
throw new ApiError(400, 'VALIDATION_FAILED', '字典标识不合法');
const result = await this.editor.requestAttributeDictionarySync(
workspace.id,
request.auth.userId,
requiredUuid('profile_id', profileId),
requiredUuid('X-Atlas-Platform-Id', platformId),
requiredUuid('Idempotency-Key', key),
requiredUuid('category_id', categoryId),
dictionaryId,
query ?? '',
);
response.status(result.status);
return result.body;
}
@Put('products/:productId/editor-draft')
async save(
@Param('productId') productId: string,
@@ -62,19 +208,28 @@ export class ProductEditorController {
) {
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,
})));
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;
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;
}
}
@@ -85,8 +240,18 @@ function requiredUuid(field: string, value: string | undefined) {
]);
}
function optionalUuid(field: string, value: string | undefined) {
if (value === undefined || value === '') return undefined;
return requiredUuid(field, value);
}
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');
if (!match)
throw new ApiError(
400,
'IF_MATCH_REQUIRED',
'请求缺少或格式错误的 If-Match',
);
return { product: Number(match[1]), profile: Number(match[2]) };
}
File diff suppressed because it is too large Load Diff