feat(extension): 采集插件功能开发

This commit is contained in:
2026-09-06 22:32:39 +08:00
parent 87db74fb89
commit 4c0f3a4a79
35 changed files with 790 additions and 20 deletions
+31 -7
View File
@@ -31,6 +31,18 @@ export type CollectionItem = {
created_at: string; updated_at: string; revision: number;
};
/** Development-only read model used by the collection inspection page. It keeps
* raw evidence separate from editable product fields so parser behaviour can be
* reviewed before the product editor consumes it. */
export type CollectionItemInspection = CollectionItem & {
normalized_payload: unknown;
raw_evidence: unknown;
parser_version: string | null;
collected_at: string | null;
variants: Array<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>;
asset_transfers: Array<{ id: string; source_variant_id: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: string; completed_at: string | null }>;
};
/**
* 浏览器扩展与 API 共享的采集载荷最小契约。
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
@@ -44,13 +56,25 @@ export const collectedProductPayloadSchema = z.object({
parserVersion: z.string().min(1),
}),
common: z.object({
title: z.string().min(1).optional(),
}),
variantAxes: z.array(z.unknown()),
variants: z.array(z.unknown()),
assets: z.array(z.unknown()),
evidence: z.array(z.unknown()),
});
title: z.object({ value: z.string().min(1).max(1_000), raw_value: z.string().max(4_000).optional(), confidence: z.enum(['verified', 'partial', 'ambiguous']).default('partial') }).optional(),
description: z.object({ value: z.string().max(100_000), raw_value: z.string().max(120_000).optional(), confidence: z.enum(['verified', 'partial', 'ambiguous']).default('partial') }).optional(),
sourcePrice: z.object({ amount: z.string().regex(/^\d+(\.\d{1,4})?$/), currencyCode: z.string().length(3).toUpperCase(), raw_value: z.string().max(200).optional() }).optional(),
attributes: z.array(z.object({ key: z.string().min(1).max(200), name: z.string().min(1).max(300), value: z.string().max(2_000), raw_value: z.string().max(4_000).optional() })).max(500).default([]),
categories: z.array(z.object({ key: z.string().min(1).max(300), name: z.string().min(1).max(500) })).max(50).default([]),
}).strict(),
variantAxes: z.array(z.object({ key: z.string().min(1).max(300), name: z.string().min(1).max(300), values: z.array(z.object({ key: z.string().min(1).max(300), name: z.string().min(1).max(300), image_url: z.url().optional() })).max(200) })).max(20),
variants: z.array(z.object({
sourceVariantKey: z.string().min(1).max(500), sourceSku: z.string().max(500).optional(), displayName: z.string().min(1).max(1_000),
selections: z.array(z.object({ axisKey: z.string().min(1).max(300), valueKey: z.string().min(1).max(300), valueName: z.string().min(1).max(300) })).max(50),
sourcePrice: z.object({ amount: z.string().regex(/^\d+(\.\d{1,4})?$/), currencyCode: z.string().length(3).toUpperCase() }).optional(),
confidence: z.enum(['verified', 'partial']), evidenceRefs: z.array(z.string().min(1).max(300)).max(100),
}).strict()).max(100),
assets: z.array(z.object({
url: z.url(), role: z.enum(['source_main', 'source_sku', 'source_detail']), variantSourceKey: z.string().min(1).max(500).optional(),
relation: z.enum(['variant_direct', 'axis_value', 'product_candidate', 'inherited_product_default']), sortOrder: z.number().int().min(0).max(10_000).default(0), refererOrigin: z.url().optional(), evidenceRef: z.string().max(300).optional(),
}).strict()).max(500),
evidence: z.array(z.object({ ref: z.string().min(1).max(300), kind: z.enum(['page', 'state', 'json_ld', 'dom']), path: z.string().max(1_000).optional(), raw_value: z.string().max(20_000).optional() }).strict()).max(500),
}).strict();
export type CollectedProductPayload = z.infer<
typeof collectedProductPayloadSchema
@@ -0,0 +1,40 @@
SET search_path TO atlas, public;
CREATE TABLE extension_authorization_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_hash TEXT NOT NULL UNIQUE,
code_challenge TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (expires_at > created_at),
UNIQUE (workspace_id, id)
);
CREATE TABLE extension_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
client_id TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_extension_sessions_active ON extension_sessions (workspace_id, user_id, client_id, expires_at)
WHERE revoked_at IS NULL;
ALTER TABLE extension_authorization_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE extension_authorization_codes FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON extension_authorization_codes
USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id());
ALTER TABLE extension_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE extension_sessions FORCE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON extension_sessions
USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id());