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');
});
});
+1
View File
@@ -7,6 +7,7 @@ declare module "wxt/browser" {
| "/"
| "/background.js"
| "/content-scripts/collector.js"
| "/content-scripts/ozon-page-json.js"
type HtmlPublicPath = Extract<PublicPath, `${string}.html`>
export interface WxtRuntime {
getURL(path: PublicPath): string;
+150 -14
View File
@@ -1,15 +1,151 @@
import { collectCurrentProduct, supportedProductPlatform } from '../lib/source-parsers.js';
import {
collectCurrentProduct,
supportedProductPlatform,
} from '../lib/source-parsers.js';
import type { OzonPageJsonFetcher } from '../lib/ozon-parser.js';
export default defineContentScript({ matches: ['*://www.ozon.ru/*', '*://www.ozon.kz/*', '*://www.ozon.by/*', '*://detail.1688.com/*', '*://item.taobao.com/*', '*://detail.tmall.com/*'], runAt: 'document_idle', main() {
let mount: HTMLDivElement | null = null; let shadow: ShadowRoot | null = null; let observedUrl = location.href;
const render = async () => {
if (!supportedProductPlatform()) { mount?.remove(); mount = null; shadow = null; return; }
if (!mount) { mount = document.createElement('div'); mount.style.cssText = 'position:fixed;right:22px;bottom:80px;z-index:2147483647'; document.documentElement.append(mount); shadow = mount.attachShadow({ mode: 'open' }); }
const root = shadow!; const session = await browser.runtime.sendMessage({ type: 'atlas.session' }).then((value: { session?: unknown }) => value.session).catch(() => null);
root.innerHTML = `<style>:host{all:initial}.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px}.dot{width:9px;height:9px;border-radius:50%;background:#ffd43b;border:0}.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600;cursor:pointer}.btn.primary{background:#ff5353}.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;cursor:pointer;padding:5px}.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}</style><div class="box"><div class="head"><span>Atlas ERP</span><button class="dot" aria-label="收起"></button></div>${session ? '<button class="btn primary" data-action="edit">编辑上架</button><button class="btn" data-action="collect">采集商品</button><button class="link" data-action="erp">进入 ERP</button>' : '<button class="btn primary" data-action="login">登录 Atlas ERP</button>'}<div class="hint" data-hint></div></div>`;
root.querySelector('.dot')?.addEventListener('click', () => { mount?.remove(); mount = null; shadow = null; });
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach((button) => button.addEventListener('click', () => void action(button.dataset.action ?? '', root)));
};
const action = async (kind: string, shadow: ShadowRoot) => { const hint = shadow.querySelector<HTMLElement>('[data-hint]')!; try { if (kind === 'login') { hint.textContent = '正在登录…'; await browser.runtime.sendMessage({ type: 'atlas.login' }); await render(); return; } if (kind === 'erp') { await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' }); return; } hint.textContent = '正在采集…'; const payload = collectCurrentProduct(); const response = await browser.runtime.sendMessage({ type: 'atlas.collect', payload }); if (kind === 'edit') await browser.runtime.sendMessage({ type: 'atlas.open', path: response.result.data.editor_entry_path }); hint.textContent = kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱'; } catch (error) { hint.textContent = error instanceof Error ? error.message : '采集失败,请重试'; } };
void render(); setInterval(() => { if (location.href !== observedUrl) { observedUrl = location.href; void render(); } }, 700);
} });
export default defineContentScript({
matches: [
'*://www.ozon.ru/*',
'*://www.ozon.kz/*',
'*://www.ozon.by/*',
'*://detail.1688.com/*',
'*://item.taobao.com/*',
'*://detail.tmall.com/*',
],
runAt: 'document_idle',
main() {
let mount: HTMLDivElement | null = null;
let shadow: ShadowRoot | null = null;
let observedUrl = location.href;
const render = async () => {
if (!supportedProductPlatform()) {
mount?.remove();
mount = null;
shadow = null;
return;
}
if (!mount) {
mount = document.createElement('div');
mount.style.cssText =
'position:fixed;right:22px;bottom:80px;z-index:2147483647';
document.documentElement.append(mount);
shadow = mount.attachShadow({ mode: 'open' });
}
const root = shadow!;
const session = await browser.runtime
.sendMessage({ type: 'atlas.session' })
.then((value: { session?: unknown }) => value.session)
.catch(() => null);
root.innerHTML = `<style>:host{all:initial}.box{width:156px;padding:12px;background:#fff;border-radius:12px;box-shadow:0 8px 28px #0003;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}.head{display:flex;justify-content:space-between;align-items:center;font-weight:700;margin-bottom:10px}.dot{width:9px;height:9px;border-radius:50%;background:#ffd43b;border:0}.btn{display:block;width:100%;border:0;border-radius:18px;padding:9px 8px;margin:7px 0;background:#2878f0;color:#fff;font-weight:600;cursor:pointer}.btn.primary{background:#ff5353}.link{display:block;width:100%;border:0;background:transparent;color:#1668dc;cursor:pointer;padding:5px}.hint{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}</style><div class="box"><div class="head"><span>Atlas ERP</span><button class="dot" aria-label="收起"></button></div>${session ? '<button class="btn primary" data-action="edit">编辑上架</button><button class="btn" data-action="collect">采集商品</button><button class="link" data-action="erp">进入 ERP</button>' : '<button class="btn primary" data-action="login">登录 Atlas ERP</button>'}<div class="hint" data-hint></div></div>`;
root.querySelector('.dot')?.addEventListener('click', () => {
mount?.remove();
mount = null;
shadow = null;
});
root.querySelectorAll<HTMLButtonElement>('[data-action]').forEach(
(button) =>
button.addEventListener('click', () =>
void action(button.dataset.action ?? '', root),
),
);
};
const action = async (kind: string, root: ShadowRoot) => {
const hint = root.querySelector<HTMLElement>('[data-hint]')!;
try {
if (kind === 'login') {
hint.textContent = '正在登录…';
await browser.runtime.sendMessage({ type: 'atlas.login' });
await render();
return;
}
if (kind === 'erp') {
await browser.runtime.sendMessage({ type: 'atlas.open', path: '/' });
return;
}
hint.textContent = '正在采集多 SKU…';
const payload = await collectCurrentProduct(
document,
location.href,
supportedProductPlatform() === 'ozon'
? requestOzonPageJson
: undefined,
);
const response = await browser.runtime.sendMessage({
type: 'atlas.collect',
payload,
});
if (kind === 'edit') {
await browser.runtime.sendMessage({
type: 'atlas.open',
path: response.result.data.editor_entry_path,
});
}
hint.textContent =
kind === 'edit' ? '已打开采集数据测试页' : '已进入采集箱';
} catch (error) {
hint.textContent =
error instanceof Error ? error.message : '采集失败,请重试';
}
};
void render();
setInterval(() => {
if (location.href !== observedUrl) {
observedUrl = location.href;
void render();
}
}, 700);
},
});
const requestOzonPageJson: OzonPageJsonFetcher = ({ kind, sku }) =>
new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
const timeout = window.setTimeout(() => {
window.removeEventListener('message', receive);
reject(new Error('Ozon 页面状态读取超时'));
}, 15_000);
const receive = (event: MessageEvent<unknown>) => {
if (event.source !== window || !isBridgeResponse(event.data, requestId)) return;
window.clearTimeout(timeout);
window.removeEventListener('message', receive);
if (event.data.ok) resolve(event.data.data);
else reject(new Error(event.data.error));
};
window.addEventListener('message', receive);
window.postMessage(
{
source: 'atlas-collector',
type: 'ozon-page-json.request',
requestId,
kind,
sku,
},
location.origin,
);
});
function isBridgeResponse(
value: unknown,
requestId: string,
): value is {
source: 'atlas-collector';
type: 'ozon-page-json.response';
requestId: string;
ok: boolean;
data?: unknown;
error: string;
} {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const message = value as Record<string, unknown>;
return (
message.source === 'atlas-collector' &&
message.type === 'ozon-page-json.response' &&
message.requestId === requestId &&
typeof message.ok === 'boolean' &&
(message.ok || typeof message.error === 'string')
);
}
@@ -0,0 +1,105 @@
/**
* Runs in Ozon's MAIN world so the request has exactly the same origin and
* session context as the page. It accepts only Atlas' two fixed, numeric-SKU
* routes and returns a small widget-state allowlist; it never exposes cookies,
* request headers or arbitrary fetch capability to the isolated script.
*/
export default defineContentScript({
matches: ['*://www.ozon.ru/*', '*://www.ozon.kz/*', '*://www.ozon.by/*'],
world: 'MAIN',
runAt: 'document_idle',
main() {
window.addEventListener('message', (event) => {
if (event.source !== window || !isRequest(event.data)) return;
void respond(event.data);
});
},
});
type Request = {
source: 'atlas-collector';
type: 'ozon-page-json.request';
requestId: string;
kind: 'modal-aspects' | 'product' | 'product-detail';
sku: string;
};
function isRequest(value: unknown): value is Request {
if (!value || typeof value !== 'object' || Array.isArray(value))
return false;
const data = value as Record<string, unknown>;
return (
data.source === 'atlas-collector' &&
data.type === 'ozon-page-json.request' &&
typeof data.requestId === 'string' &&
/^[a-zA-Z0-9-]{16,120}$/.test(data.requestId) &&
(data.kind === 'modal-aspects' ||
data.kind === 'product' ||
data.kind === 'product-detail') &&
typeof data.sku === 'string' &&
/^\d{4,}$/.test(data.sku)
);
}
async function respond(request: Request) {
try {
const pagePath =
request.kind === 'modal-aspects'
? `/modal/aspectsNew?product_id=${request.sku}`
: request.kind === 'product-detail'
? `/product/${request.sku}/?layout_container=pdpPage2column&layout_page_index=2`
: `/product/${request.sku}/`;
const response = await fetch(
`/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(pagePath)}`,
{ credentials: 'same-origin' },
);
if (!response.ok)
throw new Error(`Ozon 页面状态请求失败(HTTP ${response.status})`);
const value = await response.json();
window.postMessage(
{
source: 'atlas-collector',
type: 'ozon-page-json.response',
requestId: request.requestId,
ok: true,
data: allowlistedPageState(value),
},
location.origin,
);
} catch (error) {
window.postMessage(
{
source: 'atlas-collector',
type: 'ozon-page-json.response',
requestId: request.requestId,
ok: false,
error:
error instanceof Error
? error.message
: 'Ozon 页面状态请求失败',
},
location.origin,
);
}
}
function allowlistedPageState(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value))
return { widgetStates: {} };
const states = (value as { widgetStates?: unknown }).widgetStates;
if (!states || typeof states !== 'object' || Array.isArray(states))
return { widgetStates: {} };
const widgetStates: Record<string, unknown> = {};
for (const [name, state] of Object.entries(
states as Record<string, unknown>,
)) {
if (
/^web(Aspects|AspectsModal|ProductHeading|Price|Gallery|Description|RichContent|Features|ShortCharacteristics|DetailedCharacteristics|Characteristics)-/.test(
name,
)
) {
widgetStates[name] = state;
}
}
return { widgetStates };
}
+64 -20
View File
@@ -2,13 +2,11 @@ import type { CollectedProductPayload } from '@atlas/contracts';
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
const clientId = (import.meta.env.ATLAS_EXTENSION_CLIENT_ID as string | undefined) ?? 'atlas-collector';
type Tokens = { access_token: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number; token_type: 'Bearer' };
type Session = { authenticated: true; workspace: { id: string; available: boolean }; can_collect: boolean };
const storageKey = 'atlas.extension.tokens.v1';
const storageKey = 'atlas.extension.web-token.v1';
async function loadTokens() { return (await browser.storage.local.get(storageKey))[storageKey] as Tokens | undefined; }
async function saveTokens(tokens: Tokens) { await browser.storage.local.set({ [storageKey]: tokens }); }
async function loadToken() { return (await browser.storage.local.get(storageKey))[storageKey] as string | undefined; }
async function saveToken(token: string) { await browser.storage.local.set({ [storageKey]: token }); }
async function clearTokens() { await browser.storage.local.remove(storageKey); }
async function request(path: string, options: RequestInit = {}, token?: string) {
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
@@ -16,24 +14,70 @@ async function request(path: string, options: RequestInit = {}, token?: string)
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
}
async function activeAccessToken() {
let tokens = await loadTokens(); if (!tokens) return null;
try { await request('/extension/session', {}, tokens.access_token); return tokens.access_token; }
catch (error) {
if ((error as { status?: number }).status !== 401) throw error;
try { const refreshed = await request('/extension/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: tokens.refresh_token }) }) as { data: Tokens }; tokens = refreshed.data; await saveTokens(tokens); return tokens.access_token; }
catch { await clearTokens(); return null; }
}
const cached = await loadToken();
const usableCached = await validateAccessToken(cached);
if (usableCached) return usableCached;
// 用户可能先在 Atlas Web 登录、再打开来源商品页。此时插件还没有自己的
// 缓存,仍应直接从已打开的 Atlas 标签页同步,而不是错误地展示“登录”。
const synchronized = await syncAccessTokenFromWebTabs();
return validateAccessToken(synchronized);
}
export async function getSession(): Promise<Session | null> { const token = await activeAccessToken(); if (!token) return null; return (await request('/extension/session', {}, token) as { data: Session }).data; }
export async function login() {
const verifier = crypto.getRandomValues(new Uint8Array(64)); const verifierText = base64url(verifier); const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifierText))));
const state = base64url(crypto.getRandomValues(new Uint8Array(32))); const redirectUri = browser.identity.getRedirectURL('callback');
const authorize = new URL(`${apiOrigin}/api/extension/authorize`); authorize.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
const callback = await browser.identity.launchWebAuthFlow({ url: authorize.toString(), interactive: true });
if (!callback) throw new Error('未完成 Atlas ERP 登录'); const result = new URL(callback); if (result.searchParams.get('state') !== state || !result.searchParams.get('code')) throw new Error('扩展授权状态校验失败');
const exchanged = await request('/extension/auth/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: clientId, code: result.searchParams.get('code'), redirect_uri: redirectUri, code_verifier: verifierText }) }) as { data: Tokens };
await saveTokens(exchanged.data); return getSession();
const currentSession = await getSession();
if (currentSession) return currentSession;
const tab = await browser.tabs.create({ url: webOrigin, active: true });
if (tab.id === undefined) throw new Error('无法打开 Atlas ERP 登录页');
const deadline = Date.now() + 3 * 60 * 1_000;
while (Date.now() < deadline) {
const token = await readWebAccessToken(tab.id);
if (token) {
await saveToken(token);
const session = await getSession();
if (session) return session;
await clearTokens();
}
await delay(2_000);
}
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
}
export async function submit(payload: CollectedProductPayload) { const token = await activeAccessToken(); if (!token) throw new Error('请先登录 Atlas ERP'); return request('/extension/materials', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() }, body: JSON.stringify(payload) }, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>; }
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
function base64url(bytes: Uint8Array) { let value = ''; for (const byte of bytes) value += String.fromCharCode(byte); return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
async function readWebAccessToken(tabId: number) {
try {
const result = await browser.scripting.executeScript({ target: { tabId }, func: () => localStorage.getItem('atlas.extension.web-access') });
const raw = result[0]?.result;
if (typeof raw !== 'string') return null;
const parsed = JSON.parse(raw) as { accessToken?: unknown };
return typeof parsed.accessToken === 'string' && parsed.accessToken.length > 0 ? parsed.accessToken : null;
} catch { return null; }
}
async function syncAccessTokenFromWebTabs() {
const tabs = await browser.tabs.query({ url: `${webOrigin}/*` });
// tabs.query 返回的顺序未承诺。倒序优先尝试最近的标签页(Chrome 通常最后返回它)。
for (const tab of tabs.reverse()) {
if (tab.id === undefined) continue;
const token = await readWebAccessToken(tab.id);
if (token) {
await saveToken(token);
return token;
}
}
return null;
}
async function validateAccessToken(token: string | undefined | null) {
if (!token) return null;
try {
await request('/extension/session', {}, token);
return token;
} catch (error) {
if ((error as { status?: number }).status === 401) {
await clearTokens();
return null;
}
throw error;
}
}
function delay(milliseconds: number) { return new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); }
File diff suppressed because it is too large Load Diff
+336
View File
@@ -0,0 +1,336 @@
import type { CollectedProductPayload } from '@atlas/contracts';
type SupportedMediaPlatform = 'ozon' | '1688' | 'taobao' | 'tmall';
type DomMediaPlatform = Exclude<SupportedMediaPlatform, 'ozon'>;
type Asset = CollectedProductPayload['assets'][number];
type ImageGroup = {
role: Asset['role'];
relation: Asset['relation'];
selectors: string[];
sourceProps?: Array<
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage'
>;
excludeWithin?: string[];
minWidth?: number;
minHeight?: number;
};
/**
* These profiles are ported from the proven collector extension. The crucial
* invariant is that images always come from a product widget, never from a
* page-wide `document.images` scan (which also includes logos, ads and
* recommendation cards).
*/
const DOM_IMAGE_PROFILES: Record<DomMediaPlatform, ImageGroup[]> = {
'1688': [
{
role: 'source_main',
relation: 'inherited_product_default',
selectors: [
'[data-module="od_picture_gallery"] img',
'.module-od-picture-gallery img',
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
'#content .od-scroller-item .v-image-cover',
'#content .od-picture-gallery-list .v-image-cover',
'#dt-tab img',
'.detail-gallery-turn img.detail-gallery-img',
'.img-list-wrapper img.od-gallery-img',
],
minWidth: 200,
minHeight: 200,
},
{
role: 'source_sku',
relation: 'axis_value',
selectors: [
'[data-module="od_sku_selection"] img',
'.module-od-sku-selection img',
'.pc-sku-wrapper .prop-item-inner-wrapper',
'.sku-item-wrapper',
'.specification-cell',
'.sku-filter-button',
'.expand-view-item',
'.feature-item img',
],
sourceProps: ['backgroundImage'],
minWidth: 20,
minHeight: 20,
},
{
role: 'source_detail',
relation: 'product_candidate',
selectors: [
'#detail img',
'.de-description-detail img',
'#detailContentContainer img',
'.html-description img',
],
minWidth: 300,
minHeight: 100,
},
],
taobao: [
{
role: 'source_main',
relation: 'inherited_product_default',
selectors: [
'[class*="picGallery--"] img',
'#picGalleryEle img',
'[class*="thumbnailPic--"]',
],
},
{
role: 'source_sku',
relation: 'axis_value',
selectors: [
'[class*="valueItem--"]',
'[class*="valueItemImgWrap--"]',
],
minWidth: 20,
minHeight: 20,
},
taobaoDetailProfile(),
],
tmall: [
{
role: 'source_main',
relation: 'inherited_product_default',
selectors: [
'[class*="picGallery--"] img',
'#picGalleryEle img',
'[class*="thumbnailPic--"]',
],
},
{
role: 'source_sku',
relation: 'axis_value',
selectors: [
'[class*="valueItem--"]',
'[class*="valueItemImgWrap--"]',
],
minWidth: 20,
minHeight: 20,
},
taobaoDetailProfile(),
],
};
function taobaoDetailProfile(): ImageGroup {
return {
role: 'source_detail',
relation: 'product_candidate',
selectors: [
'[class*="tabDetailWrap--"] img',
'[class*="detailInfo--"] img',
],
// The old collector verified that review photos and recommendation
// cards can be large enough to pass a size-only filter.
excludeWithin: [
'[class*="Comment--"]',
'[class*="comments--"]',
'[class*="userInfo--"]',
'[class*="rate"]',
'[class*="RecommendInfo--"]',
'[data-spm="recommends"]',
'[class*="recommend--"]',
'[class*="cardPic--"]',
],
minWidth: 300,
minHeight: 100,
};
}
export function collectPlatformDomAssets(
platform: DomMediaPlatform,
root: Document,
pageUrl: string,
): Asset[] {
const assets: Asset[] = [];
const seen = new Set<string>();
for (const group of DOM_IMAGE_PROFILES[platform]) {
for (const selector of group.selectors) {
for (const element of Array.from(
root.querySelectorAll<HTMLElement>(selector),
)) {
if (
group.excludeWithin?.some((excluded) =>
element.closest(excluded),
)
)
continue;
if (!passesDimensions(element, group)) continue;
const url = imageUrlFromElement(
element,
group.sourceProps,
pageUrl,
platform,
);
if (!url) continue;
// Keep the old collector's group-level de-duplication: an
// aspect cover may legitimately also be a gallery image.
const key = `${group.role}:${dedupeImageUrl(url, platform)}`;
if (seen.has(key)) continue;
seen.add(key);
assets.push({
url,
role: group.role,
relation: group.relation,
sortOrder: assets.length,
evidenceRef: `${platform}:dom:${group.role}`,
});
}
}
}
return assets;
}
/** Restores the original CDN URL before payload storage and de-duplication. */
export function normaliseImageUrl(
raw: string,
pageUrl: string,
platform: SupportedMediaPlatform,
): string | undefined {
let url: URL;
try {
url = new URL(raw, pageUrl);
} catch {
return undefined;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
if (platform === 'ozon') {
// Ozon thumbnails use /wc50/, /wc100/, /c600/, etc. The same media
// without this segment is its original CDN resource.
url.pathname = url.pathname
.replace(/\/(?:wc|c)\d+\//i, '/')
.replace(/\/{2,}/g, '/');
} else {
// Alibaba image URLs commonly append a transform after the file
// extension (e.g. image.jpg_400x400.jpg). Keep the original image.
const original = url.pathname.match(
/^(.+?\.(?:jpe?g|png|gif|bmp|heic|webp|avif))_/i,
);
if (original) url.pathname = original[1];
}
for (const parameter of ['width', 'height', 'size', 'quality', 'w', 'h']) {
url.searchParams.delete(parameter);
}
return url.toString();
}
export function dedupeImageUrl(url: string, platform: SupportedMediaPlatform) {
const normalised = normaliseImageUrl(url, url, platform) ?? url;
try {
const parsed = new URL(normalised);
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return normalised;
}
}
function imageUrlFromElement(
element: HTMLElement,
sourceProps: ImageGroup['sourceProps'] = [
'data-lazyload-src',
'data-src',
'srcset',
'currentSrc',
'src',
'backgroundImage',
],
pageUrl: string,
platform: SupportedMediaPlatform,
) {
const nestedTarget = element.matches('img, source')
? null
: element.querySelector<HTMLElement>('img, source');
const imageTarget = element.matches('img, source') ? element : nestedTarget;
for (const prop of sourceProps) {
// 1688 uses background images on the selected SKU option container.
// For every other property, prefer the actual nested image/source.
const target = prop === 'backgroundImage' ? element : imageTarget;
if (!target) continue;
let raw = '';
if (prop === 'srcset')
raw = bestSrcset(target.getAttribute('srcset') ?? '');
else if (prop === 'currentSrc')
raw = (target as HTMLImageElement).currentSrc ?? '';
else if (prop === 'backgroundImage') {
const style =
target.ownerDocument.defaultView?.getComputedStyle(target)
.backgroundImage ?? target.style.backgroundImage;
raw = style.match(/url\(["']?(.*?)["']?\)/i)?.[1] ?? '';
// Some 1688 SKU selectors directly match an <img> despite the
// group being background-image-first.
if (!raw && target.tagName === 'IMG') {
raw =
(target as HTMLImageElement).currentSrc ||
target.getAttribute('src') ||
'';
}
if (!raw && nestedTarget && nestedTarget !== target) {
const nestedStyle =
nestedTarget.ownerDocument.defaultView?.getComputedStyle(
nestedTarget,
).backgroundImage ?? nestedTarget.style.backgroundImage;
raw =
nestedStyle.match(/url\(["']?(.*?)["']?\)/i)?.[1] ??
((nestedTarget as HTMLImageElement).currentSrc ||
nestedTarget.getAttribute('src') ||
'');
}
} else raw = target.getAttribute(prop) ?? '';
const url = raw && normaliseImageUrl(raw, pageUrl, platform);
if (url) return url;
}
return undefined;
}
function passesDimensions(element: HTMLElement, group: ImageGroup) {
if (!group.minWidth && !group.minHeight) return true;
const image = element.matches('img')
? (element as HTMLImageElement)
: element.querySelector<HTMLImageElement>('img');
const measured = image ?? element;
const width = image?.naturalWidth || measured.offsetWidth || 0;
const height = image?.naturalHeight || measured.offsetHeight || 0;
// Browser snapshots and unrendered lazy images often report 0. As in the
// old collector, only reject when a real measured size proves it is tiny.
return !(
width > 0 &&
height > 0 &&
(width < (group.minWidth ?? 0) || height < (group.minHeight ?? 0))
);
}
function bestSrcset(srcset: string) {
let best = '';
let bestSize = -1;
for (const part of srcset
.split(',')
.map((value) => value.trim())
.filter(Boolean)) {
const [url, descriptor = ''] = part.split(/\s+/, 2);
const width = descriptor.match(/^(\d+)w$/)?.[1];
const density = descriptor.match(/^(\d+(?:\.\d+)?)x$/)?.[1];
const size = width
? Number(width)
: density
? Number(density) * 1_000
: 0;
if (size >= bestSize) {
best = url;
bestSize = size;
}
}
return best;
}
+410 -39
View File
@@ -1,32 +1,62 @@
import type { CollectedProductPayload } from '@atlas/contracts';
import { collectOzonProduct, isOzonProductPage } from './ozon-parser.js';
import {
collectOzonProduct,
isOzonProductPage,
type OzonPageJsonFetcher,
} from './ozon-parser.js';
import { collectPlatformDomAssets } from './product-media.js';
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
type Json = Record<string, unknown>;
type Axis = CollectedProductPayload['variantAxes'][number];
type Variant = CollectedProductPayload['variants'][number];
export function supportedProductPlatform(url = location.href): SourcePlatform | null {
export function supportedProductPlatform(
url = location.href,
): SourcePlatform | null {
if (isOzonProductPage(url)) return 'ozon';
if (/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/.test(url)) return '1688';
if (/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/.test(url))
return '1688';
if (/^https:\/\/item\.taobao\.com\/item\.htm/.test(url)) return 'taobao';
if (/^https:\/\/detail\.tmall\.com\/item\.htm/.test(url)) return 'tmall';
return null;
}
export function collectCurrentProduct(root: Document = document, pageUrl = location.href): CollectedProductPayload {
export async function collectCurrentProduct(
root: Document = document,
pageUrl = location.href,
fetchOzonPageJson?: OzonPageJsonFetcher,
): Promise<CollectedProductPayload> {
const platform = supportedProductPlatform(pageUrl);
if (platform === 'ozon') return collectOzonProduct(root, pageUrl);
if (platform === 'ozon')
return collectOzonProduct(root, pageUrl, fetchOzonPageJson);
if (!platform) throw new Error('当前页面不是支持的商品详情页');
return platform === '1688' ? collect1688(root, pageUrl) : collectTaobao(root, pageUrl, platform);
return platform === '1688'
? collect1688(root, pageUrl)
: collectTaobao(root, pageUrl, platform);
}
function collect1688(root: Document, pageUrl: string): CollectedProductPayload {
const state = pageState(root); const skuMap = findFirst(state, 'skuMapOriginal'); const skuProps = findFirst(state, 'skuProps');
const axes = parse1688Axes(skuProps); const evidence: CollectedProductPayload['evidence'] = [{ ref: 'page:url', kind: 'page', path: 'location.href', raw_value: pageUrl }];
const state = pageState(root);
const skuMap = findFirst(state, 'skuMapOriginal');
const skuProps = findFirst(state, 'skuProps');
const axes = parse1688Axes(skuProps);
const evidence: CollectedProductPayload['evidence'] = [
{
ref: 'page:url',
kind: 'page',
path: 'location.href',
raw_value: pageUrl,
},
];
const variants: Variant[] = [];
if (isRecord(skuMap)) {
evidence.push({ ref: '1688:sku-map', kind: 'state', path: 'skuMapOriginal', raw_value: `组合记录 ${Object.keys(skuMap).length} 条` });
evidence.push({
ref: '1688:sku-map',
kind: 'state',
path: 'skuMapOriginal',
raw_value: `组合记录 ${Object.keys(skuMap).length} 条`,
});
for (const [combination, row] of Object.entries(skuMap)) {
if (!isRecord(row)) continue;
const sourceSku = stringAt(row, ['skuId', 'sku_id', 'skuCode']);
@@ -34,50 +64,391 @@ function collect1688(root: Document, pageUrl: string): CollectedProductPayload {
// record. Never expand it with the Cartesian product of skuProps.
const sourceVariantKey = sourceSku ?? `1688:${combination}`;
if (!combination || combination === 'undefined') continue;
variants.push({ sourceVariantKey, ...(sourceSku ? { sourceSku } : {}), displayName: selectionsForCombination(combination, axes).map((item) => item.valueName).join(' / ') || `1688 SKU ${sourceSku ?? combination}`, selections: selectionsForCombination(combination, axes), ...(moneyAt(row, ['discountPrice', 'price', 'salePrice']) ? { sourcePrice: { amount: moneyAt(row, ['discountPrice', 'price', 'salePrice'])!, currencyCode: 'CNY' } } : {}), confidence: 'verified', evidenceRefs: ['1688:sku-map'] });
variants.push({
sourceVariantKey,
...(sourceSku ? { sourceSku } : {}),
displayName:
selectionsForCombination(combination, axes)
.map((item) => item.valueName)
.join(' / ') || `1688 SKU ${sourceSku ?? combination}`,
selections: selectionsForCombination(combination, axes),
...(moneyAt(row, ['discountPrice', 'price', 'salePrice'])
? {
sourcePrice: {
amount: moneyAt(row, [
'discountPrice',
'price',
'salePrice',
])!,
currencyCode: 'CNY',
},
}
: {}),
confidence: 'verified',
evidenceRefs: ['1688:sku-map'],
});
}
}
return basePayload('1688', offerId(pageUrl), root, pageUrl, axes, uniqueVariants(variants), evidence, '1688-state-1.0.0');
return basePayload(
'1688',
offerId(pageUrl),
root,
pageUrl,
axes,
uniqueVariants(variants),
evidence,
'1688-state-1.0.0',
);
}
function collectTaobao(root: Document, pageUrl: string, platform: 'taobao' | 'tmall'): CollectedProductPayload {
const state = pageState(root); const skuBase = findFirst(state, 'skuBase'); const skuCore = findFirst(state, 'skuCore');
const axes = parseTaobaoAxes(isRecord(skuBase) ? skuBase.props : undefined); const evidence: CollectedProductPayload['evidence'] = [{ ref: 'page:url', kind: 'page', path: 'location.href', raw_value: pageUrl }];
function collectTaobao(
root: Document,
pageUrl: string,
platform: 'taobao' | 'tmall',
): CollectedProductPayload {
const state = pageState(root);
const skuBase = findFirst(state, 'skuBase');
const skuCore = findFirst(state, 'skuCore');
const axes = parseTaobaoAxes(isRecord(skuBase) ? skuBase.props : undefined);
const evidence: CollectedProductPayload['evidence'] = [
{
ref: 'page:url',
kind: 'page',
path: 'location.href',
raw_value: pageUrl,
},
];
const variants: Variant[] = [];
const skus = isRecord(skuBase) && Array.isArray(skuBase.skus) ? skuBase.skus : [];
const sku2info = isRecord(skuCore) && isRecord(skuCore.sku2info) ? skuCore.sku2info : {};
if (skus.length) evidence.push({ ref: 'taobao:sku-base', kind: 'state', path: 'skuBase.skus', raw_value: `组合记录 ${skus.length} 条` });
const skus =
isRecord(skuBase) && Array.isArray(skuBase.skus) ? skuBase.skus : [];
const sku2info =
isRecord(skuCore) && isRecord(skuCore.sku2info) ? skuCore.sku2info : {};
if (skus.length)
evidence.push({
ref: 'taobao:sku-base',
kind: 'state',
path: 'skuBase.skus',
raw_value: `组合记录 ${skus.length} 条`,
});
for (const sku of skus) {
if (!isRecord(sku)) continue;
const skuId = stringAt(sku, ['skuId', 'sku_id']); const propPath = stringAt(sku, ['propPath', 'prop_path']);
const skuId = stringAt(sku, ['skuId', 'sku_id']);
const propPath = stringAt(sku, ['propPath', 'prop_path']);
// skuId + propPath is the required combination proof. Props alone only
// become axes and cause a single default SKU on the server.
if (!skuId || !propPath) continue;
const info = isRecord(sku2info[skuId]) ? sku2info[skuId] : {};
const selections = selectionsForPropPath(propPath, axes);
variants.push({ sourceVariantKey: skuId, sourceSku: skuId, displayName: selections.map((item) => item.valueName).join(' / ') || `SKU ${skuId}`, selections, ...(moneyAt(info, ['price', 'promotionPrice', 'discountPrice']) ? { sourcePrice: { amount: moneyAt(info, ['price', 'promotionPrice', 'discountPrice'])!, currencyCode: 'CNY' } } : {}), confidence: 'verified', evidenceRefs: ['taobao:sku-base'] });
variants.push({
sourceVariantKey: skuId,
sourceSku: skuId,
displayName:
selections.map((item) => item.valueName).join(' / ') ||
`SKU ${skuId}`,
selections,
...(moneyAt(info, ['price', 'promotionPrice', 'discountPrice'])
? {
sourcePrice: {
amount: moneyAt(info, [
'price',
'promotionPrice',
'discountPrice',
])!,
currencyCode: 'CNY',
},
}
: {}),
confidence: 'verified',
evidenceRefs: ['taobao:sku-base'],
});
}
return basePayload(platform, itemId(pageUrl), root, pageUrl, axes, uniqueVariants(variants), evidence, `${platform}-state-1.0.0`);
return basePayload(
platform,
itemId(pageUrl),
root,
pageUrl,
axes,
uniqueVariants(variants),
evidence,
`${platform}-state-1.0.0`,
);
}
function basePayload(platform: Exclude<SourcePlatform, 'ozon'>, itemKey: string, root: Document, pageUrl: string, axes: Axis[], variants: Variant[], evidence: CollectedProductPayload['evidence'], parserVersion: string): CollectedProductPayload {
const canonical = root.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ?? pageUrl.split('#')[0]; const title = root.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim() || root.querySelector<HTMLMetaElement>('meta[property="og:title"]')?.content?.trim();
const assets = Array.from(root.images).map((image) => image.currentSrc || image.src).filter((url) => /^https:\/\//.test(url)).filter((url, index, all) => all.indexOf(url) === index).slice(0, 40).map((url, sortOrder) => ({ url, role: 'source_main' as const, relation: 'product_candidate' as const, sortOrder, evidenceRef: 'page:gallery' }));
return { source: { platform, itemKey, canonicalUrl: canonical, collectedAt: new Date().toISOString(), parserVersion }, common: { ...(title ? { title: { value: title, raw_value: title, confidence: 'verified' as const } } : {}), attributes: [], categories: [] }, variantAxes: axes, variants, assets, evidence };
function basePayload(
platform: Exclude<SourcePlatform, 'ozon'>,
itemKey: string,
root: Document,
pageUrl: string,
axes: Axis[],
variants: Variant[],
evidence: CollectedProductPayload['evidence'],
parserVersion: string,
): CollectedProductPayload {
const canonical =
root.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ??
pageUrl.split('#')[0];
const title =
root.querySelector('h1')?.textContent?.replace(/\s+/g, ' ').trim() ||
root
.querySelector<HTMLMetaElement>('meta[property="og:title"]')
?.content?.trim();
const assets = collectPlatformDomAssets(platform, root, canonical);
evidence.push({
ref: `${platform}:dom:image-profile`,
kind: 'dom',
path: 'platform-scoped gallery / sku / detail selectors',
raw_value: `采集 ${assets.length} 张受限商品区域图片`,
});
return {
source: {
platform,
itemKey,
canonicalUrl: canonical,
collectedAt: new Date().toISOString(),
parserVersion,
},
common: {
...(title
? {
title: {
value: title,
raw_value: title,
confidence: 'verified' as const,
},
}
: {}),
attributes: [],
categories: [],
},
variantAxes: axes,
variants,
assets,
evidence,
};
}
function pageState(root: Document) { const all: unknown[] = []; for (const script of Array.from(root.scripts)) { const text = script.textContent?.trim() ?? ''; if (text.length < 2 || text.length > 5_000_000) continue; if (script.type === 'application/ld+json') { try { all.push(JSON.parse(text)); } catch { /* ignored evidence */ } } for (const marker of ['skuMapOriginal', 'skuBase', 'skuCore', 'skuProps']) { const object = objectAfterMarker(text, marker); if (object) all.push(object); } } return all; }
function objectAfterMarker(text: string, marker: string): unknown | null { const position = text.indexOf(marker); if (position < 0) return null; const start = text.slice(position).search(/[\[{]/); if (start < 0) return null; const first = position + start; const open = text[first]; const close = open === '{' ? '}' : ']'; let depth = 0; let quote = ''; let escaped = false; for (let index = first; index < text.length && index - first < 2_000_000; index += 1) { const char = text[index]; if (quote) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === quote) quote = ''; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === open) depth += 1; if (char === close && --depth === 0) { try { return JSON.parse(text.slice(first, index + 1)); } catch { return null; } } } return null; }
function findFirst(states: unknown[], key: string): unknown { for (const state of states) { const found = findKey(state, key); if (found !== undefined) return found; } return undefined; }
function findKey(value: unknown, key: string, seen = new Set<unknown>()): unknown { if (!value || typeof value !== 'object' || seen.has(value)) return undefined; seen.add(value); if (isRecord(value) && key in value) return value[key]; for (const child of Array.isArray(value) ? value : Object.values(value)) { const found = findKey(child, key, seen); if (found !== undefined) return found; } return undefined; }
function parse1688Axes(value: unknown): Axis[] { const entries = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.skuProps) ? value.skuProps : []; return entries.flatMap((entry, axisIndex) => { if (!isRecord(entry)) return []; const axisKey = stringAt(entry, ['prop', 'propId', 'id']) ?? `axis:${axisIndex}`; const name = stringAt(entry, ['name', 'propName']) ?? axisKey; const values = (Array.isArray(entry.value) ? entry.value : Array.isArray(entry.values) ? entry.values : []).flatMap((item, valueIndex) => isRecord(item) ? [{ key: stringAt(item, ['valueId', 'vid', 'id']) ?? `${axisKey}:${valueIndex}`, name: stringAt(item, ['name', 'valueName']) ?? `${valueIndex + 1}`, ...(urlAt(item, ['imageUrl', 'image', 'imageUrlLarge']) ? { image_url: urlAt(item, ['imageUrl', 'image', 'imageUrlLarge'])! } : {}) }] : []); return values.length ? [{ key: axisKey, name, values }] : []; }); }
function parseTaobaoAxes(value: unknown): Axis[] { return (Array.isArray(value) ? value : []).flatMap((entry, index) => { if (!isRecord(entry)) return []; const key = stringAt(entry, ['pid', 'propId']) ?? `axis:${index}`; const name = stringAt(entry, ['name', 'propName']) ?? key; const values = (Array.isArray(entry.values) ? entry.values : []).flatMap((item, valueIndex) => isRecord(item) ? [{ key: stringAt(item, ['vid', 'valueId']) ?? `${key}:${valueIndex}`, name: stringAt(item, ['name', 'valueName']) ?? `${valueIndex + 1}`, ...(urlAt(item, ['image', 'imageUrl']) ? { image_url: urlAt(item, ['image', 'imageUrl'])! } : {}) }] : []); return values.length ? [{ key, name, values }] : []; }); }
function selectionsForPropPath(path: string, axes: Axis[]) { return path.split(/[;,]/).flatMap((pair) => { const [axisKey, valueKey] = pair.split(':'); const axis = axes.find((item) => item.key === axisKey); const value = axis?.values.find((item) => item.key === valueKey); return axis && value ? [{ axisKey, valueKey, valueName: value.name }] : []; }); }
function selectionsForCombination(combination: string, axes: Axis[]) { const ids = combination.split(/[;,&]/).map((value) => value.trim()).filter(Boolean); return axes.flatMap((axis) => { const value = axis.values.find((item) => ids.includes(item.key)); return value ? [{ axisKey: axis.key, valueKey: value.key, valueName: value.name }] : []; }); }
function stringAt(value: Json, keys: string[]) { for (const key of keys) if (typeof value[key] === 'string' || typeof value[key] === 'number') return String(value[key]); return undefined; }
function moneyAt(value: Json, keys: string[]) { const raw = stringAt(value, keys)?.replace(/,/g, '.'); return raw && /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined; }
function urlAt(value: Json, keys: string[]) { const raw = stringAt(value, keys); return raw && /^https:\/\//.test(raw) ? raw : undefined; }
function uniqueVariants(variants: Variant[]) { const seen = new Set<string>(); return variants.filter((variant) => !seen.has(variant.sourceVariantKey) && !!seen.add(variant.sourceVariantKey)); }
function offerId(url: string) { return url.match(/\/offer\/(\d+)\.html/)?.[1] ?? `1688:${new URL(url).pathname}`; }
function itemId(url: string) { return new URL(url).searchParams.get('id') ?? new URL(url).pathname; }
function isRecord(value: unknown): value is Json { return !!value && typeof value === 'object' && !Array.isArray(value); }
function pageState(root: Document) {
const all: unknown[] = [];
for (const script of Array.from(root.scripts)) {
const text = script.textContent?.trim() ?? '';
if (text.length < 2 || text.length > 5_000_000) continue;
if (script.type === 'application/ld+json') {
try {
all.push(JSON.parse(text));
} catch {
/* ignored evidence */
}
}
for (const marker of [
'skuMapOriginal',
'skuBase',
'skuCore',
'skuProps',
]) {
const object = objectAfterMarker(text, marker);
if (object) all.push(object);
}
}
return all;
}
function objectAfterMarker(text: string, marker: string): unknown | null {
const position = text.indexOf(marker);
if (position < 0) return null;
const start = text.slice(position).search(/[\[{]/);
if (start < 0) return null;
const first = position + start;
const open = text[first];
const close = open === '{' ? '}' : ']';
let depth = 0;
let quote = '';
let escaped = false;
for (
let index = first;
index < text.length && index - first < 2_000_000;
index += 1
) {
const char = text[index];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === open) depth += 1;
if (char === close && --depth === 0) {
try {
return JSON.parse(text.slice(first, index + 1));
} catch {
return null;
}
}
}
return null;
}
function findFirst(states: unknown[], key: string): unknown {
for (const state of states) {
const found = findKey(state, key);
if (found !== undefined) return found;
}
return undefined;
}
function findKey(
value: unknown,
key: string,
seen = new Set<unknown>(),
): unknown {
if (!value || typeof value !== 'object' || seen.has(value))
return undefined;
seen.add(value);
if (isRecord(value) && key in value) return value[key];
for (const child of Array.isArray(value) ? value : Object.values(value)) {
const found = findKey(child, key, seen);
if (found !== undefined) return found;
}
return undefined;
}
function parse1688Axes(value: unknown): Axis[] {
const entries = Array.isArray(value)
? value
: isRecord(value) && Array.isArray(value.skuProps)
? value.skuProps
: [];
return entries.flatMap((entry, axisIndex) => {
if (!isRecord(entry)) return [];
const axisKey =
stringAt(entry, ['prop', 'propId', 'id']) ?? `axis:${axisIndex}`;
const name = stringAt(entry, ['name', 'propName']) ?? axisKey;
const values = (
Array.isArray(entry.value)
? entry.value
: Array.isArray(entry.values)
? entry.values
: []
).flatMap((item, valueIndex) =>
isRecord(item)
? [
{
key:
stringAt(item, ['valueId', 'vid', 'id']) ??
`${axisKey}:${valueIndex}`,
name:
stringAt(item, ['name', 'valueName']) ??
`${valueIndex + 1}`,
...(urlAt(item, [
'imageUrl',
'image',
'imageUrlLarge',
])
? {
image_url: urlAt(item, [
'imageUrl',
'image',
'imageUrlLarge',
])!,
}
: {}),
},
]
: [],
);
return values.length ? [{ key: axisKey, name, values }] : [];
});
}
function parseTaobaoAxes(value: unknown): Axis[] {
return (Array.isArray(value) ? value : []).flatMap((entry, index) => {
if (!isRecord(entry)) return [];
const key = stringAt(entry, ['pid', 'propId']) ?? `axis:${index}`;
const name = stringAt(entry, ['name', 'propName']) ?? key;
const values = (
Array.isArray(entry.values) ? entry.values : []
).flatMap((item, valueIndex) =>
isRecord(item)
? [
{
key:
stringAt(item, ['vid', 'valueId']) ??
`${key}:${valueIndex}`,
name:
stringAt(item, ['name', 'valueName']) ??
`${valueIndex + 1}`,
...(urlAt(item, ['image', 'imageUrl'])
? {
image_url: urlAt(item, [
'image',
'imageUrl',
])!,
}
: {}),
},
]
: [],
);
return values.length ? [{ key, name, values }] : [];
});
}
function selectionsForPropPath(path: string, axes: Axis[]) {
return path.split(/[;,]/).flatMap((pair) => {
const [axisKey, valueKey] = pair.split(':');
const axis = axes.find((item) => item.key === axisKey);
const value = axis?.values.find((item) => item.key === valueKey);
return axis && value
? [{ axisKey, valueKey, valueName: value.name }]
: [];
});
}
function selectionsForCombination(combination: string, axes: Axis[]) {
const ids = combination
.split(/[;,&]/)
.map((value) => value.trim())
.filter(Boolean);
return axes.flatMap((axis) => {
const value = axis.values.find((item) => ids.includes(item.key));
return value
? [
{
axisKey: axis.key,
valueKey: value.key,
valueName: value.name,
},
]
: [];
});
}
function stringAt(value: Json, keys: string[]) {
for (const key of keys)
if (typeof value[key] === 'string' || typeof value[key] === 'number')
return String(value[key]);
return undefined;
}
function moneyAt(value: Json, keys: string[]) {
const raw = stringAt(value, keys)?.replace(/,/g, '.');
return raw && /^\d+(\.\d{1,4})?$/.test(raw) ? raw : undefined;
}
function urlAt(value: Json, keys: string[]) {
const raw = stringAt(value, keys);
return raw && /^https:\/\//.test(raw) ? raw : undefined;
}
function uniqueVariants(variants: Variant[]) {
const seen = new Set<string>();
return variants.filter(
(variant) =>
!seen.has(variant.sourceVariantKey) &&
!!seen.add(variant.sourceVariantKey),
);
}
function offerId(url: string) {
return (
url.match(/\/offer\/(\d+)\.html/)?.[1] ??
`1688:${new URL(url).pathname}`
);
}
function itemId(url: string) {
return new URL(url).searchParams.get('id') ?? new URL(url).pathname;
}
function isRecord(value: unknown): value is Json {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
+4 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"build": "wxt build",
"dev": "wxt",
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit"
"typecheck": "wxt prepare && tsc -p tsconfig.json --noEmit",
"test": "tsx --test test/*.test.mts",
"test:parsers": "tsx --test test/*.test.mts"
},
"dependencies": {
"@atlas/contracts": "workspace:*",
@@ -17,6 +19,7 @@
"devDependencies": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"tsx": "4.23.13",
"typescript": "7.0.2",
"wxt": "0.21.4"
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
extractOzonCharacteristics,
extractOzonDescription,
extractOzonGalleryImageUrls,
} from '../lib/ozon-parser.ts';
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
test('Ozon gallery fixture only returns product-gallery images', async () => {
const fixture = await readFile(
new URL('../../../packages/reference/ozon1.html', import.meta.url),
'utf8',
);
const state = fixture.match(
/id="state-webGallery-[^"]+"\s+data-state='([^']+)'/,
)?.[1];
assert.ok(state, 'Ozon gallery widget state must exist in the fixture');
const urls = extractOzonGalleryImageUrls(
JSON.parse(state),
'https://www.ozon.ru/product/ryukzak-5387005639/',
);
assert.equal(urls.length, 11);
assert.ok(urls.every((url) => /\/s3\/multimedia-/.test(url)));
assert.ok(
urls.every(
(url) => !/logo_ozon|payments-cdn|recommend|review/i.test(url),
),
);
});
test('Ozon short characteristics fixture returns its product parameters', async () => {
const fixture = await readFile(
new URL('../../../packages/reference/ozon1.html', import.meta.url),
'utf8',
);
const state = fixture.match(
/id="state-webShortCharacteristics-[^"]+"\s+data-state='([^']+)'/,
)?.[1];
assert.ok(state, 'Ozon short characteristics widget state must exist');
const attributes = extractOzonCharacteristics([
{
...JSON.parse(state),
__atlasWidgetName: 'webShortCharacteristics-fixture',
},
]);
assert.equal(attributes.length, 5);
assert.deepEqual(
attributes.slice(0, 3).map(({ name, value }) => ({ name, value })),
[
{ name: '类型', value: '背包' },
{ name: '原产国', value: '中国' },
{ name: '材料', value: 'Cordura 1000D' },
],
);
});
test('Ozon rich annotation converts to description text without images', () => {
const description = extractOzonDescription([
{
__atlasWidgetName: 'webDescription-fixture',
richAnnotationJson: JSON.stringify([
{ type: 'text', content: '耐用的背包' },
{ type: 'image', src: 'https://ir.ozone.ru/s3/example.jpg' },
{ type: 'text', text: '适合旅行' },
]),
},
]);
assert.equal(description?.value, '耐用的背包\n适合旅行');
});
test('Ozon thumbnail variants normalise to one original URL', () => {
const original = 'https://ir.ozone.ru/s3/multimedia-1-9/13597160877.jpg';
const thumbnail =
'https://ir.ozone.ru/s3/multimedia-1-9/wc100/13597160877.jpg?width=100&quality=80';
assert.equal(normaliseImageUrl(thumbnail, original, 'ozon'), original);
assert.equal(
dedupeImageUrl(thumbnail, 'ozon'),
dedupeImageUrl(original, 'ozon'),
);
});
test('Alibaba transformed image URLs normalise before de-duplication', () => {
const original = 'https://cbu01.alicdn.com/img/ibank/O1CN01.jpg';
const transformed = `${original}_400x400.jpg?width=400`;
assert.equal(normaliseImageUrl(transformed, original, '1688'), original);
});
+8 -1
View File
@@ -1,5 +1,7 @@
import { defineConfig } from 'wxt';
const atlasWebOrigin = new URL(process.env.ATLAS_WEB_ORIGIN ?? 'http://localhost:8100').origin;
export default defineConfig({
modules: ['@wxt-dev/module-react'],
// 扩展 dev server 固定 8110:默认取 3000–3010 第一个空闲端口,会漂移且易与常见服务冲突
@@ -12,9 +14,14 @@ export default defineConfig({
manifest: {
name: 'Atlas Seller Kit Collector',
description: '将已确认的来源商品采集到 Atlas 工作空间。',
permissions: ['storage', 'identity'],
// Only the public key is committed. Chrome derives a stable extension ID
// from it for unpacked and self-hosted builds; never put a private key here.
key: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsPGP9lPew7Xf+xWAc7yIAp+eLjC5SxkqzMXV/9dyn/HtMsskSbVGuYbmg0MWJdi7g3fLe/sa16Q+xp59C+yt11vKGO3a9vho6SfKDnXzkiLBvxKILTBvymnNtk4U/O5LMPWU838TnOZNRIxICJdvp1rF0EO2V8mgOY6cBenXNha1WMYpjfl17ehaKxgrKTFOT63ynTgT6NVbf3sFwi0hFCDH59TfTu5w8iQbYpm+2C+N9cmWfCcnmxtz3TxL+PCtW+RQhfEH1AFCxKZe8JkWqe2XrxLvQHjBlmM4dSvrazA454NhvGkQD41hQqLkfNAooSxLby33t2LWkY7dJmVJDQIDAQAB',
// `tabs` 只用于查询已打开的 Atlas 标签页以同步登录态;不读取其他网页内容。
permissions: ['storage', 'scripting', 'tabs'],
host_permissions: [
'http://localhost:8200/*',
`${atlasWebOrigin}/*`,
'*://*.ozon.ru/*',
'*://*.ozon.kz/*',
'*://*.ozon.by/*',
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Card, Collapse, Descriptions, Spin, Table, Tag, Typography } from 'antd';
import { Alert, Card, Collapse, Descriptions, Image, Spin, Table, Tag, Typography } from 'antd';
import type { CollectionItemInspection } from '@atlas/contracts';
import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
/** Temporary parser inspection surface. This is deliberately read-only: it lets
* product and parser work be checked before these values become editor fields. */
@@ -11,35 +12,220 @@ export default function CollectionInspectionPage() {
const { id } = useParams<{ id: string }>();
const [record, setRecord] = useState<CollectionItemInspection>();
const [error, setError] = useState<string>();
useEffect(() => { if (!id) return; void collectionApi.inspection(id).then(setRecord).catch((reason) => setError(apiErrorMessage(reason))); }, [id]);
if (error) return <Alert type="error" message="无法读取采集数据" description={error} showIcon />;
if (!record) return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
return <Card title="采集数据测试页" extra={<Tag color="blue">只读测试</Tag>}>
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="这里展示扩展实际提交、服务端实际保存的数据,不代表已确认的商品编辑字段。" />
<Descriptions bordered size="small" column={2} items={[
{ key: 'title', label: '商品标题', children: record.title ?? '未命名' },
{ key: 'source', label: '采集来源', children: `${record.source.platform_code ?? '手动'} · ${record.source.url ?? '—'}` },
{ key: 'status', label: '采集状态', children: record.collection_status },
{ key: 'parser', label: '解析器版本', children: record.parser_version ?? '—' },
{ key: 'sku', label: '已创建 SKU', children: record.variants.length },
{ key: 'assets', label: '素材任务', children: record.asset_transfers.length },
]} />
<Typography.Title level={5} style={{ marginTop: 24 }}>SKU 草稿</Typography.Title>
<Table size="small" pagination={false} rowKey="id" dataSource={record.variants} columns={[
{ title: '来源 SKU ID', dataIndex: 'source_variant_key', render: (value) => value ?? '默认 SKU(待确认)' },
{ title: '来源货号', dataIndex: 'source_sku', render: (value) => value ?? '—' },
{ title: '名称', dataIndex: 'display_name', render: (value) => value ?? '—' },
]} />
<Typography.Title level={5} style={{ marginTop: 24 }}>素材转存任务</Typography.Title>
<Table size="small" pagination={false} rowKey="id" dataSource={record.asset_transfers} columns={[
{ title: '类型', dataIndex: 'role' }, { title: '状态', dataIndex: 'status' },
{ title: '来源 URL', dataIndex: 'source_url', ellipsis: true, render: (value) => <Typography.Link href={value} target="_blank">查看来源</Typography.Link> },
{ title: '错误', dataIndex: 'error_payload', render: (value) => value ? <Typography.Text type="danger">{JSON.stringify(value)}</Typography.Text> : '—' },
]} />
<Collapse style={{ marginTop: 24 }} items={[
{ key: 'normalized', label: '规范化采集载荷(normalized_payload)', children: <Json value={record.normalized_payload} /> },
{ key: 'evidence', label: '原始证据(raw_evidence)', children: <Json value={record.raw_evidence} /> },
]} />
</Card>;
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
useEffect(() => {
if (!id) return;
void collectionApi
.inspection(id)
.then(setRecord)
.catch((reason) => setError(apiErrorMessage(reason)));
}, [id]);
const assetIds = useMemo(
() => [
...new Set(
(record?.asset_transfers ?? [])
.filter((transfer) => transfer.status === 'succeeded' && !!transfer.asset_id)
.map((transfer) => transfer.asset_id as string)
),
],
[record]
);
useEffect(() => {
let disposed = false;
setPreviewUrls({});
if (!assetIds.length) return;
void loadPreviewUrls(assetIds).then((urls) => {
if (!disposed) setPreviewUrls(urls);
});
return () => {
disposed = true;
};
}, [assetIds]);
const variantsById = useMemo(
() => new Map((record?.variants ?? []).map((variant) => [variant.id, variant])),
[record]
);
if (error)
return <Alert type="error" message="无法读取采集数据" description={error} showIcon />;
if (!record)
return (
<div style={{ padding: 64, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
return (
<Card title="采集数据测试页" extra={<Tag color="blue">只读测试</Tag>}>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="这里展示扩展实际提交、服务端实际保存的数据,不代表已确认的商品编辑字段。"
/>
<Descriptions
bordered
size="small"
column={2}
items={[
{ key: 'title', label: '商品标题', children: record.title ?? '未命名' },
{
key: 'source',
label: '采集来源',
children: `${record.source.platform_code ?? '手动'} · ${record.source.url ?? '—'}`,
},
{ key: 'status', label: '采集状态', children: record.collection_status },
{ key: 'parser', label: '解析器版本', children: record.parser_version ?? '—' },
{ key: 'sku', label: '已创建 SKU', children: record.variants.length },
{ key: 'assets', label: '素材任务', children: record.asset_transfers.length },
]}
/>
<Typography.Title level={5} style={{ marginTop: 24 }}>
SKU 草稿
</Typography.Title>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.variants}
columns={[
{
title: '来源 SKU ID',
dataIndex: 'source_variant_key',
render: (value) => value ?? '默认 SKU(待确认)',
},
{
title: '来源货号',
dataIndex: 'source_sku',
render: (value) => value ?? '—',
},
{
title: '名称',
dataIndex: 'display_name',
render: (value) => value ?? '—',
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 24 }}>
素材转存任务
</Typography.Title>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.asset_transfers}
columns={[
{
title: '素材预览',
width: 108,
render: (_, transfer) => {
const url = transfer.asset_id
? previewUrls[transfer.asset_id]
: undefined;
if (url)
return (
<Image
width={72}
height={72}
src={url}
alt="已转存采集素材"
style={{ objectFit: 'cover' }}
/>
);
return (
<Typography.Text type="secondary">
{transfer.status === 'succeeded' ? '预览加载中…' : '待转存'}
</Typography.Text>
);
},
},
{ title: '类型', dataIndex: 'role' },
{
title: '关联 SKU',
render: (_, transfer) => {
const variant = transfer.source_variant_id
? variantsById.get(transfer.source_variant_id)
: undefined;
return variant?.source_sku ?? variant?.source_variant_key ?? '商品级';
},
},
{ title: '状态', dataIndex: 'status' },
{
title: '来源 URL',
dataIndex: 'source_url',
ellipsis: true,
render: (value) => (
<Typography.Link href={value} target="_blank">
查看来源
</Typography.Link>
),
},
{
title: '错误',
dataIndex: 'error_payload',
render: (value) =>
value ? (
<Typography.Text type="danger">
{JSON.stringify(value)}
</Typography.Text>
) : (
'—'
),
},
]}
/>
<Collapse
style={{ marginTop: 24 }}
items={[
{
key: 'normalized',
label: '规范化采集载荷(normalized_payload)',
children: <Json value={record.normalized_payload} />,
},
{
key: 'evidence',
label: '原始证据(raw_evidence)',
children: <Json value={record.raw_evidence} />,
},
]}
/>
</Card>
);
}
async function loadPreviewUrls(assetIds: string[]) {
const urls: Record<string, string> = {};
let cursor = 0;
const worker = async () => {
while (cursor < assetIds.length) {
const assetId = assetIds[cursor++];
try {
urls[assetId] = (await mediaApi.createPreviewUrl(assetId)).url;
} catch {
// The task row remains inspectable through its source URL and error state.
}
}
};
await Promise.all(Array.from({ length: Math.min(4, assetIds.length) }, () => worker()));
return urls;
}
function Json({ value }: { value: unknown }) {
return (
<pre
style={{
margin: 0,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 12,
}}
>
{JSON.stringify(value, null, 2)}
</pre>
);
}
function Json({ value }: { value: unknown }) { return <pre style={{ margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{JSON.stringify(value, null, 2)}</pre>; }
+6 -3
View File
@@ -1,16 +1,19 @@
/**
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
* 正常 Web 调用仍只从内存读取 token。为自托管采集插件互操作,维护者
* 明确批准将当前短期 token 镜像到本站 localStorage;见
* docs/project/extension-login-alternatives.md,不得扩大到其他调用方。
*/
let accessToken: string | null = null;
export const extensionWebAccessStorageKey = 'atlas.extension.web-access';
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
localStorage.setItem(extensionWebAccessStorageKey, JSON.stringify({ accessToken: token }));
},
clear: () => {
accessToken = null;
localStorage.removeItem(extensionWebAccessStorageKey);
},
};
+13 -7
View File
@@ -6,27 +6,33 @@ import type { Database } from '@atlas/database';
import type { OutboxJob } from './outbox-dispatcher.js';
const MAX_BYTES = 20 * 1024 * 1024;
type Transfer = { id: string; source_url: string; source_variant_id: string | null };
type Transfer = { id: string; source_url: string; source_variant_id: string | null; source_snapshot_revision: string | number };
/** Downloads source images only after a public-address check. A failed image is
* terminal for this transfer, not for the product collection transaction. */
export async function materializeCollectionAssets(database: Database, storage: S3Client, bucket: string, job: OutboxJob) {
await ensureBucket(storage, bucket);
const snapshotRevision = Number(job.payload.source_snapshot_revision ?? 1);
if (!Number.isSafeInteger(snapshotRevision) || snapshotRevision < 1) return;
const transfers = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const rows = await tx.query<Transfer>(`UPDATE atlas.collection_asset_transfers SET status = 'running', updated_at = now()
WHERE id IN (SELECT id FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND status = 'queued' ORDER BY created_at FOR UPDATE SKIP LOCKED)
RETURNING id, source_url, source_variant_id`, [String(job.payload.collection_item_id)]);
WHERE id IN (SELECT id FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND status = 'queued' ORDER BY created_at FOR UPDATE SKIP LOCKED)
RETURNING id, source_url, source_variant_id, source_snapshot_revision`, [String(job.payload.collection_item_id), snapshotRevision]);
return rows.rows;
});
for (const transfer of transfers) await materializeOne(database, storage, bucket, job.workspaceId, transfer);
await database.workspaceTransaction(job.workspaceId, async (tx) => {
await tx.query(`UPDATE atlas.collection_items SET status = CASE WHEN EXISTS (SELECT 1 FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND status = 'running') THEN 'materializing'::atlas.collection_item_status ELSE 'ready_for_edit'::atlas.collection_item_status END, updated_at = now(), revision = revision + 1 WHERE id = $1 AND deleted_at IS NULL`, [String(job.payload.collection_item_id)]);
await tx.query(`UPDATE atlas.collection_items SET status = CASE WHEN EXISTS (SELECT 1 FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND status = 'running') THEN 'materializing'::atlas.collection_item_status ELSE 'ready_for_edit'::atlas.collection_item_status END, updated_at = now(), revision = revision + 1 WHERE id = $1 AND source_snapshot_revision = $2 AND deleted_at IS NULL`, [String(job.payload.collection_item_id), snapshotRevision]);
});
}
async function materializeOne(database: Database, storage: S3Client, bucket: string, workspaceId: string, transfer: Transfer) {
const snapshotRevision = Number(transfer.source_snapshot_revision);
try {
const downloaded = await downloadImage(transfer.source_url);
const current = await database.workspaceTransaction(workspaceId, async (tx) => (await tx.query<{ id: string }>(`SELECT t.id FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id
WHERE t.id = $1 AND t.status = 'running' AND t.source_snapshot_revision = $2 AND c.source_snapshot_revision = $2`, [transfer.id, snapshotRevision])).rows[0]);
if (!current) return;
const checksum = createHash('sha256').update(downloaded.bytes).digest('hex');
const objectKey = `collections/${workspaceId}/${checksum}`;
await storage.send(new PutObjectCommand({ Bucket: bucket, Key: objectKey, Body: downloaded.bytes, ContentType: downloaded.contentType }));
@@ -34,15 +40,15 @@ async function materializeOne(database: Database, storage: S3Client, bucket: str
const existing = await tx.query<{ id: string }>('SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1', [checksum]);
const assetId = existing.rows[0]?.id;
if (assetId) {
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'succeeded', asset_id = $2, completed_at = now(), updated_at = now(), error_payload = NULL WHERE id = $1 AND status = 'running'`, [transfer.id, assetId]);
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'succeeded', asset_id = $2, completed_at = now(), updated_at = now(), error_payload = NULL WHERE id = $1 AND status = 'running' AND source_snapshot_revision = $3`, [transfer.id, assetId, snapshotRevision]);
return;
}
const asset = await tx.query<{ id: string }>(`INSERT INTO atlas.media_assets (workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, 'local_minio', $1, $2, $3, $4, $5, $6, $7) RETURNING id`, [bucket, objectKey, downloaded.contentType, downloaded.bytes.byteLength, checksum, downloaded.width, downloaded.height]);
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'succeeded', asset_id = $2, completed_at = now(), updated_at = now(), error_payload = NULL WHERE id = $1 AND status = 'running'`, [transfer.id, asset.rows[0].id]);
await tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'succeeded', asset_id = $2, completed_at = now(), updated_at = now(), error_payload = NULL WHERE id = $1 AND status = 'running' AND source_snapshot_revision = $3`, [transfer.id, asset.rows[0].id, snapshotRevision]);
});
} catch (error) {
await database.workspaceTransaction(workspaceId, (tx) => tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'failed', completed_at = now(), updated_at = now(), error_payload = jsonb_build_object('code', 'SOURCE_ASSET_DOWNLOAD_FAILED', 'message', $2) WHERE id = $1 AND status = 'running'`, [transfer.id, error instanceof Error ? error.message.slice(0, 300) : '素材下载失败']));
await database.workspaceTransaction(workspaceId, (tx) => tx.query(`UPDATE atlas.collection_asset_transfers SET status = 'failed', completed_at = now(), updated_at = now(), error_payload = jsonb_build_object('code', 'SOURCE_ASSET_DOWNLOAD_FAILED', 'message', $2) WHERE id = $1 AND status = 'running' AND source_snapshot_revision = $3`, [transfer.id, error instanceof Error ? error.message.slice(0, 300) : '素材下载失败', snapshotRevision]));
}
}