Update seller store experience
This commit is contained in:
@@ -9,39 +9,110 @@ import { DATABASE } from '../database/database.module.js';
|
||||
export class ExtensionService {
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
) {}
|
||||
|
||||
public async ingest(workspaceId: string, userId: string, key: string, payload: CollectedProductPayload) {
|
||||
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/extension/materials', key, request: payload }, async (tx) => {
|
||||
const platform = await tx.query<{ id: string; code: string }>(`SELECT id, code FROM atlas.platforms
|
||||
WHERE code = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_source')::boolean, false)`, [payload.source.platform.toUpperCase()]);
|
||||
if (!platform.rows[0]) throw new ApiError(422, 'SOURCE_PLATFORM_UNAVAILABLE', '该来源平台当前不可采集');
|
||||
const existing = await this.findExistingSourceItem(
|
||||
tx,
|
||||
public async ingest(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
payload: CollectedProductPayload,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
platform.rows[0].id,
|
||||
payload,
|
||||
);
|
||||
if (existing) {
|
||||
await this.replaceSourceSnapshot(
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: '/api/extension/materials',
|
||||
key,
|
||||
request: payload,
|
||||
},
|
||||
async (tx) => {
|
||||
const platform = await tx.query<{ id: string; code: string }>(
|
||||
`SELECT id, code FROM atlas.platforms
|
||||
WHERE code = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_source')::boolean, false)`,
|
||||
[payload.source.platform.toUpperCase()],
|
||||
);
|
||||
if (!platform.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SOURCE_PLATFORM_UNAVAILABLE',
|
||||
'该来源平台当前不可采集',
|
||||
);
|
||||
const existing = await this.findExistingSourceItem(
|
||||
tx,
|
||||
existing.id,
|
||||
existing.product_id,
|
||||
workspaceId,
|
||||
platform.rows[0].id,
|
||||
payload,
|
||||
);
|
||||
return { status: 200, body: { data: await this.materialResponse(tx, existing.id, existing.product_id, true, true) } };
|
||||
}
|
||||
const { status, sourcePrice } = sourceState(payload);
|
||||
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items
|
||||
if (existing) {
|
||||
await this.replaceSourceSnapshot(
|
||||
tx,
|
||||
workspaceId,
|
||||
existing.id,
|
||||
existing.product_id,
|
||||
payload,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: await this.materialResponse(
|
||||
tx,
|
||||
existing.id,
|
||||
existing.product_id,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
const { status, sourcePrice } = sourceState(payload);
|
||||
const item = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.collection_items
|
||||
(workspace_id, source_kind, source_platform_id, source_item_key, source_url, source_title, source_currency_code, source_min_price, source_max_price, status, raw_evidence, normalized_payload, parser_version, collected_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'platform', $1, $2, $3, $4, $5, $6, $6, $7::atlas.collection_item_status, $8, $9, $10, $11) RETURNING id`,
|
||||
[platform.rows[0].id, payload.source.itemKey, payload.source.canonicalUrl, payload.common.title?.value ?? null, sourcePrice?.currencyCode ?? null, sourcePrice?.amount ?? null, status, JSON.stringify(payload.evidence), JSON.stringify(payload), payload.source.parserVersion, payload.source.collectedAt]);
|
||||
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, payload.common.title?.value ?? null]);
|
||||
await this.writeSourceSnapshotRows(tx, item.rows[0].id, product.rows[0].id, 1, payload);
|
||||
return { status: 201, body: { data: await this.materialResponse(tx, item.rows[0].id, product.rows[0].id, false, false) } };
|
||||
});
|
||||
[
|
||||
platform.rows[0].id,
|
||||
payload.source.itemKey,
|
||||
payload.source.canonicalUrl,
|
||||
payload.common.title?.value ?? null,
|
||||
sourcePrice?.currencyCode ?? null,
|
||||
sourcePrice?.amount ?? null,
|
||||
status,
|
||||
JSON.stringify(payload.evidence),
|
||||
JSON.stringify(payload),
|
||||
payload.source.parserVersion,
|
||||
payload.source.collectedAt,
|
||||
],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||
[item.rows[0].id, payload.common.title?.value ?? null],
|
||||
);
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
workspaceId,
|
||||
item.rows[0].id,
|
||||
product.rows[0].id,
|
||||
1,
|
||||
payload,
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
data: await this.materialResponse(
|
||||
tx,
|
||||
item.rows[0].id,
|
||||
product.rows[0].id,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +141,10 @@ export class ExtensionService {
|
||||
const sourceSkus = [
|
||||
...new Set(
|
||||
payload.variants
|
||||
.map((variant) => variant.sourceSku ?? variant.sourceVariantKey)
|
||||
.map(
|
||||
(variant) =>
|
||||
variant.sourceSku ?? variant.sourceVariantKey,
|
||||
)
|
||||
.filter((sku) => sku.trim().length > 0),
|
||||
),
|
||||
];
|
||||
@@ -109,55 +183,201 @@ export class ExtensionService {
|
||||
return byVariantOverlap.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async replaceSourceSnapshot(tx: { query: Database['query'] }, collectionItemId: string, productId: string, payload: CollectedProductPayload) {
|
||||
private async replaceSourceSnapshot(
|
||||
tx: { query: Database['query'] },
|
||||
workspaceId: string,
|
||||
collectionItemId: string,
|
||||
productId: string,
|
||||
payload: CollectedProductPayload,
|
||||
) {
|
||||
const { status, sourcePrice } = sourceState(payload);
|
||||
// 不删除旧任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
||||
// 不删除旧候选/任务记录:运行中的 Worker 可能已拿到它。取消并保留审计记录,
|
||||
// 新快照在查询和 Worker 中均按 source_snapshot_revision 隔离。
|
||||
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', completed_at = now(), updated_at = now()
|
||||
WHERE collection_item_id = $1 AND status IN ('queued', 'running')`, [collectionItemId]);
|
||||
const item = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_items
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_asset_transfers SET status = 'cancelled', completed_at = now(), updated_at = now()
|
||||
WHERE collection_item_id = $1 AND status IN ('pending_selection', 'queued', 'running')`,
|
||||
[collectionItemId],
|
||||
);
|
||||
const item = await tx.query<{
|
||||
source_snapshot_revision: string | number;
|
||||
}>(
|
||||
`UPDATE atlas.collection_items
|
||||
SET source_url = $1, source_title = $2, source_currency_code = $3, source_min_price = $4, source_max_price = $4,
|
||||
status = $5::atlas.collection_item_status, raw_evidence = $6, normalized_payload = $7, parser_version = $8,
|
||||
collected_at = $9, source_snapshot_revision = source_snapshot_revision + 1, revision = revision + 1, updated_at = now()
|
||||
WHERE id = $10 AND deleted_at IS NULL RETURNING source_snapshot_revision`,
|
||||
[payload.source.canonicalUrl, payload.common.title?.value ?? null, sourcePrice?.currencyCode ?? null, sourcePrice?.amount ?? null, status, JSON.stringify(payload.evidence), JSON.stringify(payload), payload.source.parserVersion, payload.source.collectedAt, collectionItemId]);
|
||||
[
|
||||
payload.source.canonicalUrl,
|
||||
payload.common.title?.value ?? null,
|
||||
sourcePrice?.currencyCode ?? null,
|
||||
sourcePrice?.amount ?? null,
|
||||
status,
|
||||
JSON.stringify(payload.evidence),
|
||||
JSON.stringify(payload),
|
||||
payload.source.parserVersion,
|
||||
payload.source.collectedAt,
|
||||
collectionItemId,
|
||||
],
|
||||
);
|
||||
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(), version = version + 1 WHERE id = $2`, [payload.common.title?.value ?? null, productId]);
|
||||
await tx.query('DELETE FROM atlas.product_variants WHERE product_id = $1', [productId]);
|
||||
await this.writeSourceSnapshotRows(tx, collectionItemId, productId, snapshotRevision, payload);
|
||||
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`,
|
||||
[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],
|
||||
);
|
||||
await this.writeSourceSnapshotRows(
|
||||
tx,
|
||||
workspaceId,
|
||||
collectionItemId,
|
||||
productId,
|
||||
snapshotRevision,
|
||||
payload,
|
||||
);
|
||||
return snapshotRevision;
|
||||
}
|
||||
|
||||
private async writeSourceSnapshotRows(tx: { query: Database['query'] }, collectionItemId: string, productId: string, snapshotRevision: number, payload: CollectedProductPayload) {
|
||||
const verifiedVariants = payload.variants.filter((variant) => variant.sourceVariantKey.trim().length > 0);
|
||||
const variantRows = verifiedVariants.length ? verifiedVariants : [{ sourceVariantKey: null, sourceSku: undefined, displayName: '默认 SKU' }];
|
||||
private async writeSourceSnapshotRows(
|
||||
tx: { query: Database['query'] },
|
||||
workspaceId: string,
|
||||
collectionItemId: string,
|
||||
productId: string,
|
||||
snapshotRevision: number,
|
||||
payload: CollectedProductPayload,
|
||||
) {
|
||||
const verifiedVariants = payload.variants.filter(
|
||||
(variant) => variant.sourceVariantKey.trim().length > 0,
|
||||
);
|
||||
const variantRows = verifiedVariants.length
|
||||
? verifiedVariants
|
||||
: [
|
||||
{
|
||||
sourceVariantKey: null,
|
||||
sourceSku: undefined,
|
||||
displayName: '默认 SKU',
|
||||
},
|
||||
];
|
||||
const variants = new Map<string, 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 (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5) RETURNING id`, [productId, variant.sourceVariantKey, variant.sourceSku ?? null, variant.displayName, index]);
|
||||
if (variant.sourceVariantKey) variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
||||
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,
|
||||
],
|
||||
);
|
||||
if (variant.sourceVariantKey)
|
||||
variants.set(variant.sourceVariantKey, inserted.rows[0].id);
|
||||
}
|
||||
for (const asset of payload.assets) {
|
||||
await tx.query(`INSERT INTO atlas.collection_asset_transfers (workspace_id, collection_item_id, product_id, source_variant_id, role, source_url, status, error_payload, source_snapshot_revision)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::atlas.media_candidate_role, $5, 'queued', $6, $7)`,
|
||||
[collectionItemId, productId, asset.variantSourceKey ? variants.get(asset.variantSourceKey) ?? null : null, asset.role, asset.url, JSON.stringify({ relation: asset.relation, referer_origin: asset.refererOrigin ?? null, evidence_ref: asset.evidenceRef ?? null, sort_order: asset.sortOrder }), snapshotRevision]);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.collection_asset_transfers (workspace_id, collection_item_id, product_id, source_variant_id, media_type, poster_url, role, source_url, status, error_payload, source_snapshot_revision)
|
||||
VALUES ($1, $2, $3, $4, $5::atlas.collection_media_type, $6, $7::atlas.media_candidate_role, $8, 'pending_selection', $9, $10)`,
|
||||
[
|
||||
workspaceId,
|
||||
collectionItemId,
|
||||
productId,
|
||||
asset.variantSourceKey
|
||||
? (variants.get(asset.variantSourceKey) ?? null)
|
||||
: null,
|
||||
asset.mediaType,
|
||||
asset.posterUrl ?? null,
|
||||
asset.role,
|
||||
asset.url,
|
||||
JSON.stringify({
|
||||
relation: asset.relation,
|
||||
referer_origin: asset.refererOrigin ?? null,
|
||||
evidence_ref: asset.evidenceRef ?? null,
|
||||
sort_order: asset.sortOrder,
|
||||
}),
|
||||
snapshotRevision,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (payload.assets.length) 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, 'collection.assets.materialize.requested', jsonb_build_object('collection_item_id', $1::uuid, 'source_snapshot_revision', $2::bigint), $3)`, [collectionItemId, snapshotRevision, `collection.assets.materialize:${collectionItemId}:${snapshotRevision}`]);
|
||||
else await tx.query(`UPDATE atlas.collection_items SET status = CASE WHEN status = 'needs_review' THEN status ELSE 'ready_for_edit' END, updated_at = now() WHERE id = $1 AND source_snapshot_revision = $2`, [collectionItemId, snapshotRevision]);
|
||||
// Collecting records source-media candidates only. A later explicit
|
||||
// selection queues the media and creates the materialisation event.
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_items SET status = CASE WHEN status = 'needs_review' THEN status ELSE 'ready_for_edit' END, updated_at = now() WHERE id = $1 AND source_snapshot_revision = $2`,
|
||||
[collectionItemId, snapshotRevision],
|
||||
);
|
||||
}
|
||||
|
||||
private async materialResponse(tx: { query: Database['query'] }, collectionItemId: string, productId: string, reused: boolean, refreshed: boolean) {
|
||||
const count = await tx.query<{ total: string; queued: string; succeeded: string; failed: string }>(`SELECT count(*)::text AS total, count(*) FILTER (WHERE t.status = 'queued')::text AS queued, count(*) FILTER (WHERE t.status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE t.status = 'failed')::text AS failed FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id WHERE t.collection_item_id = $1 AND t.source_snapshot_revision = c.source_snapshot_revision`, [collectionItemId]);
|
||||
const status = await tx.query<{ status: string; source_title: string | null }>('SELECT status, source_title FROM atlas.collection_items WHERE id = $1', [collectionItemId]);
|
||||
return { collection_item: { id: collectionItemId, status: status.rows[0]?.status ?? 'received', source_title: status.rows[0]?.source_title ?? null, variant_count: 0 }, product_id: productId, reused, refreshed, editor_entry_path: `/collection-test/${collectionItemId}`, materialization: { total: Number(count.rows[0]?.total ?? 0), queued: Number(count.rows[0]?.queued ?? 0), succeeded: Number(count.rows[0]?.succeeded ?? 0), failed: Number(count.rows[0]?.failed ?? 0) } };
|
||||
private async materialResponse(
|
||||
tx: { query: Database['query'] },
|
||||
collectionItemId: string,
|
||||
productId: string,
|
||||
reused: boolean,
|
||||
refreshed: boolean,
|
||||
) {
|
||||
const count = await tx.query<{
|
||||
total: string;
|
||||
pending_selection: string;
|
||||
queued: string;
|
||||
succeeded: string;
|
||||
failed: string;
|
||||
}>(
|
||||
`SELECT count(*)::text AS total, count(*) FILTER (WHERE t.status = 'pending_selection')::text AS pending_selection, count(*) FILTER (WHERE t.status = 'queued')::text AS queued, count(*) FILTER (WHERE t.status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE t.status = 'failed')::text AS failed FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id WHERE t.collection_item_id = $1 AND t.source_snapshot_revision = c.source_snapshot_revision`,
|
||||
[collectionItemId],
|
||||
);
|
||||
const status = await tx.query<{
|
||||
status: string;
|
||||
source_title: string | null;
|
||||
}>(
|
||||
'SELECT status, source_title FROM atlas.collection_items WHERE id = $1',
|
||||
[collectionItemId],
|
||||
);
|
||||
return {
|
||||
collection_item: {
|
||||
id: collectionItemId,
|
||||
status: status.rows[0]?.status ?? 'received',
|
||||
source_title: status.rows[0]?.source_title ?? null,
|
||||
variant_count: 0,
|
||||
},
|
||||
product_id: productId,
|
||||
reused,
|
||||
refreshed,
|
||||
editor_entry_path: `/collection-test/${collectionItemId}`,
|
||||
materialization: {
|
||||
total: Number(count.rows[0]?.total ?? 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function sourceState(payload: CollectedProductPayload) {
|
||||
const verifiedVariants = payload.variants.filter((variant) => variant.sourceVariantKey.trim().length > 0);
|
||||
return { status: verifiedVariants.length || !payload.variantAxes.length ? 'materializing' : 'needs_review', sourcePrice: payload.common.sourcePrice };
|
||||
const verifiedVariants = payload.variants.filter(
|
||||
(variant) => variant.sourceVariantKey.trim().length > 0,
|
||||
);
|
||||
return {
|
||||
status:
|
||||
verifiedVariants.length || !payload.variantAxes.length
|
||||
? 'ready_for_edit'
|
||||
: 'needs_review',
|
||||
sourcePrice: payload.common.sourcePrice,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user