Expand seller store functionality

This commit is contained in:
R524809
2026-09-07 17:51:11 +08:00
parent 4c0f3a4a79
commit ceb644a3f9
35 changed files with 32883 additions and 364 deletions
@@ -35,9 +35,9 @@ export class CollectionService {
async detail(workspaceId: string, id: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const item = await this.requireItem(tx, id);
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
const transfers = await tx.query<{ id: string; source_variant_id: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>('SELECT id, source_variant_id, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 ORDER BY created_at, id', [id]);
const transfers = await tx.query<{ id: string; source_variant_id: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>('SELECT id, source_variant_id, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY created_at, id', [id, source.rows[0]?.source_snapshot_revision ?? 1]);
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
});
}
@@ -50,7 +50,7 @@ export class CollectionService {
}
async bulkDelete(workspaceId: string, userId: string, key: string, ids: { ids: string[] }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/bulk-delete', ids, false); }
async clear(workspaceId: string, userId: string, key: string, input: { confirm: true; expected_active_count: number }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/clear', input, true); }
async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'queued', error_payload = NULL, updated_at = now() WHERE collection_item_id = $1 AND id = ANY($2::uuid[]) AND status IN ('failed', 'cancelled')`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集记录中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id }); return { status: 202, body: { data: { operation } } }; }); }
async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集快照中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); }
private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; }
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { 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, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
@@ -58,7 +58,7 @@ export class CollectionService {
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].revision }]); }
}
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id`;
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; }
function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; }
-4
View File
@@ -27,10 +27,6 @@ const environmentSchema = z.object({
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
WEB_ORIGIN: z.url().default('http://localhost:8100'),
// The redirect URI is deliberately exact. A wildcard would allow another
// installed extension to exchange a code created from the user's Web session.
EXTENSION_COLLECTOR_CLIENT_ID: z.string().min(1).max(128).default('atlas-collector'),
EXTENSION_COLLECTOR_REDIRECT_URI: z.url().default('https://atlas-collector.chromiumapp.org/callback'),
DATABASE_URL: z.url(),
REDIS_URL: z.url(),
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
+24 -4
View File
@@ -1,5 +1,8 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { ExtensionService } from './extension.service.js';
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { AuthService } from '../auth/auth.service.js';
import { ApiError } from '../common/api-error.js';
import { DATABASE } from '../database/database.module.js';
export type ExtensionAuthenticatedRequest = {
header: (name: string) => string | undefined;
@@ -8,10 +11,27 @@ export type ExtensionAuthenticatedRequest = {
@Injectable()
export class ExtensionAccessGuard implements CanActivate {
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
public constructor(@Inject(AuthService) private readonly auth: AuthService, @Inject(DATABASE) private readonly database: Database) {}
public async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<ExtensionAuthenticatedRequest>();
request.extensionAuth = await this.extension.authenticate(request.header('authorization'));
// 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。
// AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。
let authenticated: { userId: string; sessionId: string };
try {
authenticated = await this.auth.authenticate(request.header('authorization'));
} catch (error) {
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
if (error instanceof UnauthorizedException)
throw new ApiError(401, 'SESSION_EXPIRED', 'Atlas ERP 登录已过期,请重新登录');
throw error;
}
const workspace = await this.database.query<{ id: string }>(`SELECT w.id FROM atlas.workspace_memberships m
JOIN atlas.workspaces w ON w.id = m.workspace_id
WHERE m.user_id = $1 AND w.status = 'active'
ORDER BY m.joined_at LIMIT 1`, [authenticated.userId]);
if (!workspace.rows[0]) throw new ApiError(403, 'WORKSPACE_MEMBERSHIP_REQUIRED', '当前账户没有可用工作空间');
request.extensionAuth = { userId: authenticated.userId, workspaceId: workspace.rows[0].id, sessionId: authenticated.sessionId, clientId: 'atlas-web' };
return true;
}
}
+2 -20
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Headers, Inject, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Headers, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import { collectedProductPayloadSchema } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
@@ -6,28 +6,11 @@ import { Public } from '../common/public.decorator.js';
import { ExtensionAccessGuard, type ExtensionAuthenticatedRequest } from './extension-auth.guard.js';
import { ExtensionService } from './extension.service.js';
type RequestLike = { header: (name: string) => string | undefined };
type ResponseLike = { redirect: (status: number, url: string) => void; status: (code: number) => ResponseLike };
const authorizeSchema = z.object({ client_id: z.string().min(1).max(128), redirect_uri: z.url(), state: z.string().min(16).max(1_000), code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43,128}$/), code_challenge_method: z.literal('S256') }).strict();
const exchangeSchema = z.object({ client_id: z.string().min(1).max(128), code: z.string().min(32).max(1_000), redirect_uri: z.url(), code_verifier: z.string().min(43).max(128) }).strict();
const refreshSchema = z.object({ refresh_token: z.string().min(32).max(1_000) }).strict();
type ResponseLike = { status: (code: number) => ResponseLike };
@Controller('extension')
export class ExtensionController {
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
@Get('authorize') @Public()
public async authorize(@Query() query: unknown, @Req() request: RequestLike, @Res() response: ResponseLike) {
const result = await this.extension.authorize(parse(authorizeSchema, query) as z.infer<typeof authorizeSchema>, cookie(request.header('cookie'), 'atlas_refresh'));
const target = 'redirect_url' in result ? result.redirect_url : result.login_url;
if (!target) throw new ApiError(500, 'EXTENSION_AUTHORIZATION_REDIRECT_MISSING', '扩展授权跳转地址缺失');
response.redirect(302, target);
}
@Post('auth/exchange') @Public()
public async exchange(@Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { const result = await this.extension.exchange(parse(exchangeSchema, body) as z.infer<typeof exchangeSchema>); response.status(result.status); return result.body; }
@Post('auth/refresh') @Public()
public async refresh(@Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) { const input = parse(refreshSchema, body) as z.infer<typeof refreshSchema>; const result = await this.extension.refresh(input.refresh_token); response.status(result.status); return result.body; }
@Post('auth/revoke') @Public() @UseGuards(ExtensionAccessGuard)
public async revoke(@Req() request: ExtensionAuthenticatedRequest, @Res({ passthrough: true }) response: ResponseLike) { await this.extension.revoke(request.extensionAuth.workspaceId, request.extensionAuth.sessionId); response.status(204); }
@Get('session') @Public() @UseGuards(ExtensionAccessGuard)
public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; }
@Post('materials') @Public() @UseGuards(ExtensionAccessGuard)
@@ -37,4 +20,3 @@ export class ExtensionController {
}
}
function parse<T extends { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((item) => ({ field: item.path.join('.'), code: 'INVALID_FIELD', message: item.message }))); }
function cookie(header: string | undefined, name: string) { return header?.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1); }
+131 -108
View File
@@ -1,140 +1,163 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { CollectedProductPayload } from '@atlas/contracts';
import type { Database } from '@atlas/database';
import { createOpaqueToken, signExtensionAccessToken, tokenHash, verifyExtensionAccessToken } from '../auth/auth.crypto.js';
import { AuthService } from '../auth/auth.service.js';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
type AuthorizeInput = { client_id: string; redirect_uri: string; state: string; code_challenge: string; code_challenge_method: 'S256' };
type ExchangeInput = { client_id: string; code: string; redirect_uri: string; code_verifier: string };
type ExtensionSession = { id: string; workspace_id: string; user_id: string; client_id: string; expires_at: Date | string; revoked_at: Date | string | null };
@Injectable()
export class ExtensionService {
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(AuthService) private readonly auth: AuthService,
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
public async authorize(input: AuthorizeInput, refreshToken: string | undefined) {
this.assertClient(input.client_id, input.redirect_uri);
const session = await this.auth.resolveWebRefreshSession(refreshToken);
if (!session) return { login_url: `${this.config.WEB_ORIGIN}/login?continue=${encodeURIComponent(this.authorizationUrl(input))}` };
// Prefixing a high-entropy secret with the workspace UUID lets the server
// establish RLS before looking up the code; it is not an authorization secret.
const rawCode = `${session.workspace.id}.${createOpaqueToken()}`;
await this.database.workspaceTransaction(session.workspace.id, async (tx) => {
await tx.query(`INSERT INTO atlas.extension_authorization_codes
(workspace_id, user_id, client_id, redirect_uri, code_hash, code_challenge, expires_at)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, now() + interval '60 seconds')`,
[session.userId, input.client_id, input.redirect_uri, tokenHash(rawCode), input.code_challenge]);
});
const callback = new URL(input.redirect_uri);
callback.searchParams.set('code', rawCode);
callback.searchParams.set('state', input.state);
return { redirect_url: callback.toString() };
}
public async exchange(input: ExchangeInput) {
this.assertClient(input.client_id, input.redirect_uri);
const hash = tokenHash(input.code); const workspaceId = workspacePrefix(input.code);
if (!workspaceId) throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
const code = await this.database.workspaceTransaction(workspaceId, async (tx) => (await tx.query<{ workspace_id: string; user_id: string; client_id: string; redirect_uri: string; code_challenge: string }>(
`SELECT workspace_id, user_id, client_id, redirect_uri, code_challenge FROM atlas.extension_authorization_codes WHERE code_hash = $1 AND consumed_at IS NULL AND expires_at > now()`, [hash])).rows[0]);
if (!code || code.client_id !== input.client_id || code.redirect_uri !== input.redirect_uri || !constantEquals(code.code_challenge, pkceChallenge(input.code_verifier))) {
throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
}
return this.database.workspaceTransaction(code.workspace_id, async (tx) => {
const consumed = await tx.query(`UPDATE atlas.extension_authorization_codes SET consumed_at = now()
WHERE code_hash = $1 AND consumed_at IS NULL AND expires_at > now()`, [hash]);
if (!consumed.rowCount) throw new ApiError(401, 'EXTENSION_AUTHORIZATION_INVALID', '扩展授权码无效或已过期');
return this.createSession(tx, code.workspace_id, code.user_id, code.client_id);
});
}
public async refresh(refreshToken: string) {
const workspaceId = workspacePrefix(refreshToken); if (!workspaceId) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query<ExtensionSession>(`SELECT id, workspace_id, user_id, client_id, expires_at, revoked_at FROM atlas.extension_sessions WHERE refresh_token_hash = $1 AND revoked_at IS NULL AND expires_at > now() FOR UPDATE`, [tokenHash(refreshToken)]);
const old = result.rows[0];
if (!old) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
await tx.query('UPDATE atlas.extension_sessions SET revoked_at = now(), last_used_at = now() WHERE id = $1', [old.id]);
return this.createSession(tx, old.workspace_id, old.user_id, old.client_id);
});
}
public async revoke(workspaceId: string, sessionId: string) {
await this.database.workspaceTransaction(workspaceId, (tx) => tx.query('UPDATE atlas.extension_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL', [sessionId]));
}
public async authenticate(header: string | undefined) {
const token = header?.replace(/^Bearer\s+/i, '');
const claims = token ? verifyExtensionAccessToken(token, this.config.AUTH_JWT_SECRET) : null;
if (!claims) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
const session = await this.database.workspaceTransaction(claims.wid, async (tx) => (await tx.query<ExtensionSession>(`SELECT id, workspace_id, user_id, client_id, expires_at, revoked_at FROM atlas.extension_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()`, [claims.sid, claims.sub])).rows[0]);
if (!session) throw new ApiError(401, 'EXTENSION_SESSION_EXPIRED', '扩展登录已过期');
return { userId: session.user_id, workspaceId: session.workspace_id, sessionId: session.id, clientId: session.client_id };
}
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 tx.query<{ id: string; product_id: string }>(`SELECT c.id, p.id AS product_id FROM atlas.collection_items c JOIN atlas.products p ON p.collection_item_id = c.id
WHERE c.source_platform_id = $1 AND c.source_item_key = $2 AND c.deleted_at IS NULL`, [platform.rows[0].id, payload.source.itemKey]);
if (existing.rows[0]) return { status: 200, body: { data: await this.materialResponse(tx, existing.rows[0].id, existing.rows[0].product_id, true) } };
const verifiedVariants = payload.variants.filter((variant) => variant.sourceVariantKey.trim().length > 0);
const status = verifiedVariants.length || !payload.variantAxes.length ? 'materializing' : 'needs_review';
const sourcePrice = payload.common.sourcePrice;
const existing = await this.findExistingSourceItem(
tx,
workspaceId,
platform.rows[0].id,
payload,
);
if (existing) {
await this.replaceSourceSnapshot(
tx,
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]);
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`, [product.rows[0].id, 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)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4::atlas.media_candidate_role, $5, 'queued', $6)`,
[item.rows[0].id, product.rows[0].id, 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 })]);
}
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), $2)`, [item.rows[0].id, `collection.assets.materialize:${item.rows[0].id}`]);
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`, [item.rows[0].id]);
return { status: 201, body: { data: await this.materialResponse(tx, item.rows[0].id, product.rows[0].id, false) } };
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) } };
});
}
private async materialResponse(tx: { query: Database['query'] }, collectionItemId: string, productId: string, reused: boolean) {
const count = await tx.query<{ total: string; queued: string; succeeded: string; failed: string }>(`SELECT count(*)::text AS total, count(*) FILTER (WHERE status = 'queued')::text AS queued, count(*) FILTER (WHERE status = 'succeeded')::text AS succeeded, count(*) FILTER (WHERE status = 'failed')::text AS failed FROM atlas.collection_asset_transfers WHERE collection_item_id = $1`, [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, 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) } };
/**
* Ozon has no public, stable SPU ID. The entered URL SKU remains the source
* identity for audit purposes, while a re-collection from any sibling URL
* reuses the same item when its real source SKU set intersects this one.
*/
private async findExistingSourceItem(
tx: { query: Database['query'] },
workspaceId: string,
platformId: string,
payload: CollectedProductPayload,
): Promise<{ id: string; product_id: string } | null> {
const bySourceKey = await tx.query<{ id: string; product_id: string }>(
`SELECT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
WHERE c.source_platform_id = $1
AND c.source_item_key = $2
AND c.deleted_at IS NULL`,
[platformId, payload.source.itemKey],
);
if (bySourceKey.rows[0]) return bySourceKey.rows[0];
if (payload.source.platform !== 'ozon') return null;
const sourceSkus = [
...new Set(
payload.variants
.map((variant) => variant.sourceSku ?? variant.sourceVariantKey)
.filter((sku) => sku.trim().length > 0),
),
];
if (!sourceSkus.length) return null;
// Different sibling URLs have different source_item_key values. Locking
// each real SKU in sorted order makes two concurrent first collections
// of the same public variant group observe one another's snapshot.
for (const sourceSku of sourceSkus.sort()) {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))`,
[`${workspaceId}:${platformId}:${sourceSku}`],
);
}
const byVariantOverlap = await tx.query<{
id: string;
product_id: string;
}>(
`SELECT DISTINCT c.id, p.id AS product_id
FROM atlas.collection_items c
JOIN atlas.products p ON p.collection_item_id = c.id
JOIN atlas.product_variants v ON v.product_id = p.id
WHERE c.source_platform_id = $1
AND c.deleted_at IS NULL
AND v.deleted_at IS NULL
AND coalesce(v.source_sku, v.source_variant_key) = ANY($2::text[])
LIMIT 2`,
[platformId, sourceSkus],
);
if (byVariantOverlap.rows.length > 1) {
throw new ApiError(
409,
'SOURCE_VARIANT_GROUP_AMBIGUOUS',
'发现多条历史采集记录包含相同 Ozon SKU,请先在采集箱处理重复记录',
);
}
return byVariantOverlap.rows[0] ?? null;
}
private async createSession(tx: { query: Database['query'] }, workspaceId: string, userId: string, clientId: string) {
const refreshToken = `${workspaceId}.${createOpaqueToken()}`; const id = randomUUID();
await tx.query(`INSERT INTO atlas.extension_sessions (id, workspace_id, user_id, client_id, refresh_token_hash, expires_at)
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $3, $4, now() + interval '30 days')`, [id, userId, clientId, tokenHash(refreshToken)]);
const accessToken = signExtensionAccessToken({ sub: userId, sid: id, wid: workspaceId, typ: 'extension', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 15 * 60 }, this.config.AUTH_JWT_SECRET);
return { status: 200, body: { data: { access_token: accessToken, expires_in: 900, refresh_token: refreshToken, refresh_token_expires_in: 2_592_000, token_type: 'Bearer' } } };
private async replaceSourceSnapshot(tx: { query: Database['query'] }, collectionItemId: string, productId: string, payload: CollectedProductPayload) {
const { status, sourcePrice } = sourceState(payload);
// 不删除旧任务记录:运行中的 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
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]);
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);
return snapshotRevision;
}
private assertClient(clientId: string, redirectUri: string) { if (clientId !== this.config.EXTENSION_COLLECTOR_CLIENT_ID || redirectUri !== this.config.EXTENSION_COLLECTOR_REDIRECT_URI) throw new ApiError(422, 'EXTENSION_CLIENT_INVALID', '扩展客户端或回调地址未登记'); }
private authorizationUrl(input: AuthorizeInput) { const url = new URL(`${this.config.API_PUBLIC_ORIGIN}/api/extension/authorize`); for (const [key, value] of Object.entries(input)) url.searchParams.set(key, value); return url.toString(); }
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' }];
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);
}
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]);
}
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]);
}
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) } };
}
}
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 };
}
function pkceChallenge(verifier: string) { return createHash('sha256').update(verifier).digest('base64url'); }
function constantEquals(left: string, right: string) { return left.length === right.length && left.split('').every((value, index) => value === right[index]); }
function workspacePrefix(value: string) { const prefix = value.split('.', 1)[0]; return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(prefix) ? prefix : null; }
+17 -15
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
@@ -8,27 +8,29 @@ import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication; let database: Database; let baseUrl = ''; let workspaceId = ''; let userId = '';
const redirectUri = 'https://atlas-collector.chromiumapp.org/callback';
async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { redirect: 'manual', ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 || response.status === 302 ? undefined : await response.json() as any }; }
function challenge(verifier: string) { return createHash('sha256').update(verifier).digest('base64url'); }
describe('扩展授权与采集 HTTP 集成测试', () => {
beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100' }); const created = await createApplication(); app = created.app; database = app.get<Database>(DATABASE); await app.listen(0, '127.0.0.1'); baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; });
afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.extension_authorization_codes WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.extension_sessions WHERE workspace_id = $1', [workspaceId]); }); await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); await app.close(); });
it('通过已有 Web 会话完成 PKCE 并幂等写入来源商品', async () => {
it('未登录令牌返回可操作的扩展会话错误', async () => {
const session = await request('/api/extension/session');
assert.equal(session.response.status, 401);
assert.equal(session.body.error.code, 'SESSION_EXPIRED');
assert.equal(session.body.error.message, 'Atlas ERP 登录已过期,请重新登录');
});
it('通过 Atlas Web access token 写入来源商品,并以新快照覆盖重复采集', async () => {
const registration = await request('/api/auth/register', { method: 'POST', body: JSON.stringify({ email: `extension-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) });
assert.equal(registration.response.status, 201); userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const webAccessToken = registration.body.data.access_token as string;
const cookie = registration.response.headers.get('set-cookie')?.match(/atlas_refresh=[^;]+/)?.[0]; assert.ok(cookie);
const verifier = 'a'.repeat(64); const state = 'b'.repeat(32);
const authorize = await request(`/api/extension/authorize?${new URLSearchParams({ client_id: 'atlas-collector', redirect_uri: redirectUri, state, code_challenge: challenge(verifier), code_challenge_method: 'S256' })}`, { headers: { Cookie: cookie } });
assert.equal(authorize.response.status, 302); const callback = new URL(authorize.response.headers.get('location')!); assert.equal(callback.origin, 'https://atlas-collector.chromiumapp.org'); assert.equal(callback.searchParams.get('state'), state);
const exchange = await request('/api/extension/auth/exchange', { method: 'POST', body: JSON.stringify({ client_id: 'atlas-collector', redirect_uri: redirectUri, code: callback.searchParams.get('code'), code_verifier: verifier }) });
assert.equal(exchange.response.status, 200); const accessToken = exchange.body.data.access_token as string; assert.ok(exchange.body.data.refresh_token);
const session = await request('/api/extension/session', { headers: { Authorization: `Bearer ${accessToken}` } }); assert.equal(session.response.status, 200); assert.equal(session.body.data.workspace.id, workspaceId);
const payload = { source: { platform: 'ozon', itemKey: '123456789', canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/', collectedAt: new Date().toISOString(), parserVersion: 'test-1' }, common: { title: { value: '来源商品', confidence: 'verified' }, attributes: [], categories: [] }, variantAxes: [], variants: [{ sourceVariantKey: '123456789', sourceSku: '123456789', displayName: '默认 SKU', selections: [], confidence: 'verified', evidenceRefs: ['page'] }], assets: [], evidence: [{ ref: 'page', kind: 'page', path: 'test' }] };
const first = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(payload) }); assert.equal(first.response.status, 201); assert.equal(first.body.data.reused, false);
const second = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(payload) }); assert.equal(second.response.status, 200); assert.equal(second.body.data.reused, true); assert.equal(second.body.data.collection_item.id, first.body.data.collection_item.id);
const inspection = await request(`/api/collection-items/${first.body.data.collection_item.id}`, { headers: { Authorization: `Bearer ${webAccessToken}` } }); assert.equal(inspection.response.status, 200); assert.equal(inspection.body.data.variants[0].source_variant_key, '123456789'); assert.equal(inspection.body.data.normalized_payload.source.platform, 'ozon');
const session = await request('/api/extension/session', { headers: { Authorization: `Bearer ${webAccessToken}` } }); assert.equal(session.response.status, 200); assert.equal(session.body.data.workspace.id, workspaceId);
const payload = { source: { platform: 'ozon', itemKey: '123456789', canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/', collectedAt: new Date().toISOString(), parserVersion: 'test-1' }, common: { title: { value: '来源商品', confidence: 'verified' }, sourcePrice: { amount: '99.90', currencyCode: 'RUB' }, attributes: [], categories: [] }, variantAxes: [], variants: [{ sourceVariantKey: '123456789', sourceSku: '123456789', displayName: '默认 SKU', selections: [], confidence: 'verified', evidenceRefs: ['page'] }], assets: [{ url: 'https://images.example.test/product.jpg', role: 'source_main', relation: 'product_candidate', sortOrder: 0, evidenceRef: 'page' }], evidence: [{ ref: 'page', kind: 'page', path: 'test' }] };
const first = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(payload) }); assert.equal(first.response.status, 201); assert.equal(first.body.data.reused, false); assert.equal(first.body.data.materialization.queued, 1);
// 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 = { ...payload, source: { ...payload.source, itemKey: '987654321', canonicalUrl: 'https://www.ozon.ru/product/demo-987654321/', collectedAt: new Date(Date.now() + 1_000).toISOString(), parserVersion: 'test-2' }, common: { ...payload.common, title: { value: '第二次采集的中文标题', confidence: 'verified' }, sourcePrice: { amount: '109.90', currencyCode: 'RUB' } }, variants: [{ sourceVariantKey: '123456789', sourceSku: '123456789', displayName: '第一 SKU', selections: [], confidence: 'verified', evidenceRefs: ['page'] }, { sourceVariantKey: '987654321', sourceSku: '987654321', displayName: '第二次采集 SKU', selections: [], confidence: 'verified', evidenceRefs: ['page'] }], assets: [{ url: 'https://images.example.test/refreshed-product.jpg', role: 'source_main', relation: 'product_candidate', sortOrder: 0, evidenceRef: 'page' }] };
const second = await request('/api/extension/materials', { method: 'POST', headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() }, body: JSON.stringify(refreshedPayload) }); assert.equal(second.response.status, 200); assert.equal(second.body.data.reused, true); assert.equal(second.body.data.refreshed, true); assert.equal(second.body.data.collection_item.id, first.body.data.collection_item.id); assert.equal(second.body.data.materialization.queued, 1);
const inspection = await request(`/api/collection-items/${first.body.data.collection_item.id}`, { headers: { Authorization: `Bearer ${webAccessToken}` } }); assert.equal(inspection.response.status, 200); assert.equal(inspection.body.data.title, '第二次采集的中文标题'); assert.equal(inspection.body.data.normalized_payload.source.parserVersion, 'test-2'); assert.equal(inspection.body.data.variants.length, 2); assert.deepEqual(inspection.body.data.variants.map((variant: { source_variant_key: string }) => variant.source_variant_key), ['123456789', '987654321']); assert.equal(inspection.body.data.asset_transfers.length, 1); assert.equal(inspection.body.data.asset_transfers[0].source_url, 'https://images.example.test/refreshed-product.jpg');
});
});