feat(edit): 商品编辑页开发
This commit is contained in:
@@ -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
Reference in New Issue
Block a user