feat(collection): 采集箱开发
This commit is contained in:
@@ -1,5 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const uuid = z.uuid();
|
||||
|
||||
export const createCollectionItemRequestSchema = z.object({
|
||||
title: z.string().trim().min(1).max(300).optional(),
|
||||
note: z.string().trim().max(2_000).nullable().optional(),
|
||||
}).strict();
|
||||
export const updateCollectionItemRequestSchema = z.object({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
}).strict();
|
||||
export const listCollectionItemsQuerySchema = z.object({
|
||||
cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
q: z.string().trim().min(1).max(300).optional(),
|
||||
source_platform_code: z.enum(['OZON', '1688', 'TAOBAO', 'TMALL']).optional(),
|
||||
listing_status: z.enum(['not_listed', 'partially_listed', 'listed']).optional(),
|
||||
}).strict();
|
||||
export const bulkDeleteCollectionItemsRequestSchema = z.object({ ids: z.array(uuid).min(1).max(100) }).strict();
|
||||
export const clearCollectionItemsRequestSchema = z.object({ confirm: z.literal(true), expected_active_count: z.number().int().min(0) }).strict();
|
||||
export const retryCollectionAssetsRequestSchema = z.object({ transfer_ids: z.array(uuid).min(1).max(100) }).strict();
|
||||
export type CreateCollectionItemRequest = z.infer<typeof createCollectionItemRequestSchema>;
|
||||
export type UpdateCollectionItemRequest = z.infer<typeof updateCollectionItemRequestSchema>;
|
||||
export type ListCollectionItemsQuery = z.infer<typeof listCollectionItemsQuerySchema>;
|
||||
|
||||
export type CollectionItem = {
|
||||
id: string; product_id: string; source: { kind: 'platform' | 'manual'; platform_code: string | null; url: string | null };
|
||||
title: string | null; thumbnail: { asset_id: string | null; preview_url: string | null; status: string };
|
||||
source_price: { min: string | null; max: string | null; currency_code: string | null };
|
||||
variant_summary: { count: number; needs_review: boolean }; note: string | null;
|
||||
collection_status: string; listing_summary: { status: 'not_listed' | 'partially_listed' | 'listed'; detail: string; last_error: null };
|
||||
created_at: string; updated_at: string; revision: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 浏览器扩展与 API 共享的采集载荷最小契约。
|
||||
* 领域字段会随采集模块实现逐步补全,不能在扩展侧绕过此契约直接构造 API 请求。
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- Fixed platform-capability schema used by collection sources and the ERP header.
|
||||
UPDATE platforms
|
||||
SET capabilities = capabilities || jsonb_build_object(
|
||||
'can_be_source', true, 'can_be_target', true, 'can_be_header_context', true
|
||||
), updated_at = now()
|
||||
WHERE code = 'OZON';
|
||||
|
||||
INSERT INTO platforms (code, name, capabilities)
|
||||
VALUES
|
||||
('1688', '1688', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb),
|
||||
('TAOBAO', '淘宝', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb),
|
||||
('TMALL', '天猫', '{"can_be_source": true, "can_be_target": false, "can_be_header_context": false}'::jsonb)
|
||||
ON CONFLICT (code) DO UPDATE SET capabilities = platforms.capabilities || EXCLUDED.capabilities, updated_at = now();
|
||||
|
||||
CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual');
|
||||
CREATE TYPE collection_item_status AS ENUM ('received', 'materializing', 'ready_for_edit', 'needs_review', 'failed', 'archived', 'deleted');
|
||||
CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
|
||||
CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail');
|
||||
|
||||
ALTER TABLE workspaces ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0);
|
||||
|
||||
CREATE TABLE collection_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||
source_kind collection_source_kind NOT NULL, source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT,
|
||||
source_item_key TEXT NOT NULL, source_url TEXT, source_title TEXT, source_currency_code CHAR(3) REFERENCES currencies(code),
|
||||
source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0),
|
||||
source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= source_min_price),
|
||||
note TEXT, status collection_item_status NOT NULL DEFAULT 'received', raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, parser_version TEXT, collected_at TIMESTAMPTZ,
|
||||
last_error JSONB, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), UNIQUE (workspace_id, id),
|
||||
CHECK ((source_kind = 'platform' AND source_platform_id IS NOT NULL) OR (source_kind = 'manual' AND source_platform_id IS NULL)),
|
||||
CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_collection_items_active_source_identity ON collection_items (workspace_id, source_platform_id, source_item_key) WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX ix_collection_items_workspace_list ON collection_items (workspace_id, updated_at DESC, id DESC) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE configured_limit INTEGER; active_count INTEGER;
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||
SELECT collection_item_limit INTO configured_limit FROM workspaces WHERE id = NEW.workspace_id FOR UPDATE;
|
||||
SELECT count(*) INTO active_count FROM collection_items WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||
IF active_count >= configured_limit THEN RAISE EXCEPTION 'collection item limit reached' USING ERRCODE = 'check_violation'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER enforce_collection_item_limit_before_write BEFORE INSERT OR UPDATE OF deleted_at ON collection_items FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit();
|
||||
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL,
|
||||
source_title TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), version BIGINT NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT,
|
||||
UNIQUE (workspace_id, id), UNIQUE (workspace_id, collection_item_id)
|
||||
);
|
||||
CREATE TABLE product_variants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL,
|
||||
source_variant_key TEXT, display_name TEXT, source_sku TEXT, sort_order INTEGER NOT NULL DEFAULT 0, deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL;
|
||||
CREATE TABLE collection_asset_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL, product_id UUID NOT NULL,
|
||||
source_variant_id UUID, role media_candidate_role NOT NULL, source_url TEXT NOT NULL, status processing_status NOT NULL DEFAULT 'queued',
|
||||
asset_id UUID, error_payload JSONB, task_key UUID NOT NULL DEFAULT gen_random_uuid(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ,
|
||||
FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, task_key)
|
||||
);
|
||||
|
||||
DO $$ DECLARE table_name TEXT; BEGIN
|
||||
FOREACH table_name IN ARRAY ARRAY['collection_items', 'products', 'product_variants', 'collection_asset_transfers'] LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
|
||||
EXECUTE format('CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,12 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE OR REPLACE FUNCTION atlas.enforce_collection_item_limit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE configured_limit INTEGER; active_count INTEGER;
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN
|
||||
SELECT collection_item_limit INTO configured_limit FROM atlas.workspaces WHERE id = NEW.workspace_id FOR UPDATE;
|
||||
SELECT count(*) INTO active_count FROM atlas.collection_items WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL;
|
||||
IF active_count >= configured_limit THEN RAISE EXCEPTION 'collection item limit reached' USING ERRCODE = 'check_violation'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
Reference in New Issue
Block a user