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
@@ -59,6 +59,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.collection_asset_transfers 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.product_platform_profiles WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -168,6 +179,62 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||
assert.equal(first.body.data.materialization.queued, 0);
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
const targetPlatform = platforms.body.data.find(
|
||||
(platform: { code: string }) => platform.code === 'OZON',
|
||||
);
|
||||
assert.ok(targetPlatform);
|
||||
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, '复采测试店铺',
|
||||
$2, 'RUB', true, 'active', 'valid')`,
|
||||
[targetPlatform.id, `refresh-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const editorContext = await request(
|
||||
`/api/collection-items/${first.body.data.collection_item.id}/editor-contexts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(editorContext.response.status, 201);
|
||||
// Simulate real draft work before the source page is collected again.
|
||||
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
||||
// fields / platform mapping just because a newer source snapshot arrives.
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const variant = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.product_variants
|
||||
WHERE product_id = $1 AND source_variant_key = '123456789'`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET source_title = '人工确认商品名'
|
||||
WHERE id = $1`,
|
||||
[first.body.data.product_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variants
|
||||
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
|
||||
WHERE id = $1`,
|
||||
[variant.rows[0].id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings
|
||||
SET offer_id = 'manual-offer-001'
|
||||
WHERE product_variant_id = $1`,
|
||||
[variant.rows[0].id],
|
||||
);
|
||||
});
|
||||
// A sibling Ozon URL has a different entered SKU. It must still reuse
|
||||
// the item because the submitted real-SKU sets overlap at 123456789.
|
||||
const refreshedPayload = {
|
||||
@@ -216,7 +283,8 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
{
|
||||
url: 'https://videos.example.test/refreshed-product.mp4',
|
||||
mediaType: 'video',
|
||||
posterUrl: 'https://images.example.test/refreshed-video-poster.jpg',
|
||||
posterUrl:
|
||||
'https://images.example.test/refreshed-video-poster.jpg',
|
||||
role: 'source_main',
|
||||
relation: 'product_candidate',
|
||||
sortOrder: 1,
|
||||
@@ -241,6 +309,33 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(second.body.data.materialization.pending_selection, 2);
|
||||
assert.equal(second.body.data.materialization.queued, 0);
|
||||
const editorAfterRefresh = await request(
|
||||
`/api/products/${first.body.data.product_id}/editor`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'X-Atlas-Platform-Id': targetPlatform.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(editorAfterRefresh.response.status, 200);
|
||||
assert.equal(
|
||||
editorAfterRefresh.body.data.product.common.source_title,
|
||||
'人工确认商品名',
|
||||
);
|
||||
const originalVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
variant.source_variant_key === '123456789',
|
||||
);
|
||||
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
variant.source_variant_key === '987654321',
|
||||
);
|
||||
assert.ok(discoveredVariant);
|
||||
assert.equal(discoveredVariant.listing.status, 'not_listed');
|
||||
const inspection = await request(
|
||||
`/api/collection-items/${first.body.data.collection_item.id}`,
|
||||
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
||||
@@ -269,10 +364,17 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
'pending_selection',
|
||||
);
|
||||
const videoTransfer = inspection.body.data.asset_transfers.find(
|
||||
(transfer: { media_type: string }) => transfer.media_type === 'video',
|
||||
(transfer: { media_type: string }) =>
|
||||
transfer.media_type === 'video',
|
||||
);
|
||||
assert.equal(
|
||||
videoTransfer?.source_url,
|
||||
'https://videos.example.test/refreshed-product.mp4',
|
||||
);
|
||||
assert.equal(
|
||||
videoTransfer?.poster_url,
|
||||
'https://images.example.test/refreshed-video-poster.jpg',
|
||||
);
|
||||
assert.equal(videoTransfer?.source_url, 'https://videos.example.test/refreshed-product.mp4');
|
||||
assert.equal(videoTransfer?.poster_url, 'https://images.example.test/refreshed-video-poster.jpg');
|
||||
const eventsBeforeSelection = await database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (tx) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user