feat(edit): 采集插件、编辑页SKU 属性动态展示、颜色展示开发
This commit is contained in:
@@ -15,6 +15,7 @@ import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
saveProductSourceDescriptionRequestSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
@@ -275,6 +276,43 @@ export class ProductEditorController {
|
||||
)
|
||||
).body;
|
||||
}
|
||||
|
||||
/** 独立保存右侧采集面板的商品描述,不要求整页草稿处于可保存状态。 */
|
||||
@Put('products/:productId/source-description')
|
||||
async saveSourceDescription(
|
||||
@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 = saveProductSourceDescriptionRequestSchema.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.saveSourceDescription(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('product_id', productId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
parseEditorRevision(ifMatch),
|
||||
parsed.data,
|
||||
)
|
||||
).body;
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductSourceDescriptionRequest,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
@@ -1031,6 +1032,121 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存采集描述覆盖,避免未完成的整页草稿阻塞该独立编辑动作。 */
|
||||
async saveSourceDescription(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
productId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
expected: { product: number; profile: number },
|
||||
input: SaveProductSourceDescriptionRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'PUT',
|
||||
path: `/api/products/${productId}/source-description`,
|
||||
key,
|
||||
request: {
|
||||
if_match: expected,
|
||||
platform_id: platformId,
|
||||
...input,
|
||||
},
|
||||
},
|
||||
async (transaction) => {
|
||||
await this.requireTargetPlatform(transaction, platformId);
|
||||
const product = await this.requireProduct(transaction, productId, true);
|
||||
const profile = await this.findProfile(transaction, productId, platformId);
|
||||
if (!profile)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'EDITOR_CONTEXT_REQUIRED',
|
||||
'请先从采集箱进入商品编辑器',
|
||||
);
|
||||
if (Number(product.revision) !== expected.product)
|
||||
await this.throwProductRevisionConflict(
|
||||
transaction,
|
||||
productId,
|
||||
expected.product,
|
||||
);
|
||||
if (Number(profile.revision) !== expected.profile)
|
||||
await this.throwProfileRevisionConflict(
|
||||
transaction,
|
||||
profile.id,
|
||||
expected.profile,
|
||||
);
|
||||
if (
|
||||
input.source_snapshot_revision !==
|
||||
Number(product.source_snapshot_revision)
|
||||
) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
'SOURCE_SNAPSHOT_CHANGED',
|
||||
'采集来源已更新,请重新加载并确认变更',
|
||||
[
|
||||
{
|
||||
field: 'source_snapshot_revision',
|
||||
code: 'SOURCE_SNAPSHOT_CHANGED',
|
||||
message: `当前采集版本为 ${product.source_snapshot_revision}`,
|
||||
current_revision: Number(product.source_snapshot_revision),
|
||||
expected_revision: input.source_snapshot_revision,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
const currentSource = resolveSourceMaterial(product);
|
||||
const overriddenFields = new Set(currentSource.state.overridden_fields);
|
||||
if (input.description === currentSource.state.latest.description) {
|
||||
overriddenFields.delete('description');
|
||||
} else {
|
||||
overriddenFields.add('description');
|
||||
}
|
||||
const overrides = selectSourceMaterialOverrides(
|
||||
{ ...currentSource.effective, description: input.description },
|
||||
[...overriddenFields],
|
||||
);
|
||||
const updatedProduct = await transaction.query<{ revision: number }>(
|
||||
`UPDATE atlas.products
|
||||
SET ai_source_material_overrides = $1::jsonb,
|
||||
ai_source_material_base_revision = $2,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $3 AND revision = $4
|
||||
RETURNING revision`,
|
||||
[
|
||||
JSON.stringify(overrides),
|
||||
input.source_snapshot_revision,
|
||||
productId,
|
||||
expected.product,
|
||||
],
|
||||
);
|
||||
if (!updatedProduct.rows[0])
|
||||
await this.throwProductRevisionConflict(
|
||||
transaction,
|
||||
productId,
|
||||
expected.product,
|
||||
);
|
||||
const savedSource = resolveSourceMaterial({
|
||||
...product,
|
||||
ai_source_material_overrides: overrides,
|
||||
ai_source_material_base_revision: input.source_snapshot_revision,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
product_revision: Number(updatedProduct.rows[0].revision),
|
||||
profile_revision: Number(profile.revision),
|
||||
source_material: savedSource.effective,
|
||||
source_material_state: savedSource.state,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */
|
||||
private async ensureListings(
|
||||
transaction: DatabaseTransaction,
|
||||
|
||||
@@ -1844,4 +1844,70 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[asset],
|
||||
);
|
||||
});
|
||||
|
||||
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||
const created = await createCollectionItem('独立保存描述测试商品');
|
||||
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);
|
||||
const before = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const key = randomUUID();
|
||||
const body = {
|
||||
description: '独立持久化后的采集商品描述',
|
||||
source_snapshot_revision:
|
||||
before.body.data.source_material.source_snapshot_revision,
|
||||
};
|
||||
const saved = await request(
|
||||
`/api/products/${created.productId}/source-description`,
|
||||
{
|
||||
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(body),
|
||||
},
|
||||
);
|
||||
assert.equal(saved.response.status, 200);
|
||||
assert.equal(
|
||||
saved.body.data.source_material.description,
|
||||
'独立持久化后的采集商品描述',
|
||||
);
|
||||
assert.ok(
|
||||
saved.body.data.source_material_state.overridden_fields.includes(
|
||||
'description',
|
||||
),
|
||||
);
|
||||
const replay = await request(
|
||||
`/api/products/${created.productId}/source-description`,
|
||||
{
|
||||
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(body),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(replay.body, saved.body);
|
||||
const after = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(
|
||||
after.body.data.source_material.description,
|
||||
'独立持久化后的采集商品描述',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user