From 5b77ba72b557c657f44316bc35bfcfe0e961ccf7 Mon Sep 17 00:00:00 2001 From: R524809 Date: Tue, 1 Sep 2026 17:35:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=EF=BC=8C=E9=A1=B9=E7=9B=AE=E9=9C=80=E6=B1=82=E3=80=81?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 17 + README.md | 18 + docs/README.md | 23 + docs/architecture/foundation-data-model.md | 57 ++ docs/architecture/sql/001_foundation.sql | 526 ++++++++++++++ .../architecture/technology-and-deployment.md | 117 ++++ .../ozon-seller-api/01-authentication.md | 234 +++++++ .../ozon-seller-api/02-category-tree.md | 333 +++++++++ .../ozon-seller-api/03-category-attributes.md | 660 ++++++++++++++++++ .../ozon-seller-api/04-product-import.md | 616 ++++++++++++++++ .../ozon-seller-api/05-product-info.md | 557 +++++++++++++++ .../ozon-seller-api/06-product-list.md | 477 +++++++++++++ .../integrations/ozon-seller-api/09-stocks.md | 599 ++++++++++++++++ docs/integrations/ozon-seller-api/README.md | 102 +++ docs/integrations/ozon-seller-api/SUMMARY.md | 143 ++++ docs/product/README.md | 27 + docs/product/api.md | 42 ++ docs/product/data-model.md | 44 ++ docs/product/product-ai-editor.md | 42 ++ docs/project/HANDOFF.md | 70 ++ docs/requirements/account-store-watermark.md | 155 ++++ .../cel-ozon-rfbs-rates-review.md | 122 ++++ docs/requirements/pricing-tool.md | 192 +++++ 23 files changed, 5173 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/architecture/foundation-data-model.md create mode 100644 docs/architecture/sql/001_foundation.sql create mode 100644 docs/architecture/technology-and-deployment.md create mode 100644 docs/integrations/ozon-seller-api/01-authentication.md create mode 100644 docs/integrations/ozon-seller-api/02-category-tree.md create mode 100644 docs/integrations/ozon-seller-api/03-category-attributes.md create mode 100644 docs/integrations/ozon-seller-api/04-product-import.md create mode 100644 docs/integrations/ozon-seller-api/05-product-info.md create mode 100644 docs/integrations/ozon-seller-api/06-product-list.md create mode 100644 docs/integrations/ozon-seller-api/09-stocks.md create mode 100644 docs/integrations/ozon-seller-api/README.md create mode 100644 docs/integrations/ozon-seller-api/SUMMARY.md create mode 100644 docs/product/README.md create mode 100644 docs/product/api.md create mode 100644 docs/product/data-model.md create mode 100644 docs/product/product-ai-editor.md create mode 100644 docs/project/HANDOFF.md create mode 100644 docs/requirements/account-store-watermark.md create mode 100644 docs/requirements/cel-ozon-rfbs-rates-review.md create mode 100644 docs/requirements/pricing-tool.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13066fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Secrets and local configuration +.env +.env.* +!.env.example + +# Operating-system and editor files +.DS_Store +.idea/ +.vscode/ + +# Common future runtime/build artifacts +node_modules/ +dist/ +.venv/ +__pycache__/ +*.py[cod] +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..6776a79 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Atlas Seller Kit + +Atlas Seller Kit 是面向多用户、多门店、多平台与多国家市场的云端卖家商品工作台。首个编辑工作流是「商品 AI 编辑页」:以商品(SPU)和可售 SKU 管理商品信息、媒体、定价与后续平台发布。 + +本仓库是从 `ozon-seller-kit` 独立出来的新产品,不继承其本地 SQLite、无鉴权或单店铺的运行假设,也不复制其旧页面代码。 + +## 当前状态 + +仅完成产品与架构设计,尚未开始应用代码开发。开始新 AI 对话或开发前,请先阅读 [项目交接](./docs/project/HANDOFF.md),再阅读 [文档导航](./docs/README.md)。 + +## 设计目标 + +- 云端部署,使用 PostgreSQL 而不是本地数据库; +- 用户 → 工作空间 → 门店的多租户隔离; +- 门店可接入不同平台、国家、语言和币种; +- 商品级通用内容与 SKU 级可售数据清晰分离; +- 一 SKU 对应一个可独立发布的平台 offer; +- AI 生成、媒体处理和发布采用可恢复的后台任务。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..46fd96b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,23 @@ +# Atlas Seller Kit 文档 + +> 先确认产品需求,再进入数据库、接口和开发设计。本目录不代表已实施功能。 + +## 目录 + +| 类型 | 位置 | 内容 | +|---|---|---| +| 项目 | [`project/`](./project/) | 产品定位、交接与已确认的长期约束 | +| 产品需求 | [`requirements/`](./requirements/) | 页面、流程、字段和待确认事项 | +| 基础架构 | [`architecture/`](./architecture/) | 多租户、平台、门店、水印、物流和定价的数据设计及 DDL | +| 商品领域设计 | [`product/`](./product/) | 已有的多 SKU 商品编辑页、领域模型与 API 草案 | +| 外部平台资料 | [`integrations/`](./integrations/) | Ozon Seller API 的整理资料与适配依据 | + +## 当前阅读顺序 + +1. [`project/HANDOFF.md`](./project/HANDOFF.md):项目边界和已经确认的商品规则。 +2. [`requirements/account-store-watermark.md`](./requirements/account-store-watermark.md):账户、门店和水印需求。 +3. [`requirements/pricing-tool.md`](./requirements/pricing-tool.md):独立试算、SKU 定价方案和物流规则需求。 +4. [`requirements/cel-ozon-rfbs-rates-review.md`](./requirements/cel-ozon-rfbs-rates-review.md):从 CEL Ozon-rFBS 资费表提取的待审核规则。 +5. [`architecture/foundation-data-model.md`](./architecture/foundation-data-model.md):基础模块数据模型和 PostgreSQL DDL。 +6. [`architecture/technology-and-deployment.md`](./architecture/technology-and-deployment.md):已确认的 NestJS、PostgreSQL、Redis/BullMQ、MinIO、七牛 Kodo 与部署策略。 +7. 需求确认后,才创建对应的接口和开发方案文档。 diff --git a/docs/architecture/foundation-data-model.md b/docs/architecture/foundation-data-model.md new file mode 100644 index 0000000..c09575c --- /dev/null +++ b/docs/architecture/foundation-data-model.md @@ -0,0 +1,57 @@ +# 云端基础模块:数据模型与数据库边界 + +> 状态:设计稿。数据库为 PostgreSQL;配套可执行 DDL 见 [`sql/001_foundation.sql`](./sql/001_foundation.sql)。本阶段只产出设计和 SQL,不连接、不创建实际数据库。 + +## 1. 核心边界 + +```text +账户 User ── Workspace(租户)── Membership + ├─ Store ── Store secret / Warehouse / Watermark binding + ├─ Watermark template ── versions / uploaded asset + └─ Pricing policy ── Quote / quote options + +Platform(全局) ── Platform product category ── Platform commission category mapping + └─ Commission price tiers + +Logistics provider(全局) ── Rate card ── Product type × transport method × rate rule +``` + +- **工作空间即租户**:新账户创建一个工作空间,创建者是 `owner`。用户、工作空间、成员关系分离,以支持未来多人协作和一个用户管理多个工作空间。 +- **平台与当前店铺是全局上下文**:店铺属于“工作空间 + 平台”;每个平台最多一个默认店铺。所有门店业务数据均携带 `workspace_id`。 +- **两套类目均强绑定平台**:平台商品类目来自 Ozon 等平台的商品分类;平台佣金类目来自该平台的佣金规则树。它们在同一平台内独立维护,并由版本化映射关联;SQL 使用 `platform_id + category_id` 的复合外键阻止跨平台映射,绝不按名称或 ID 猜测。 +- **CEL 价表是全局规则数据**:由后台维护版本和生效期;报价只引用确定版本。首期仅 Ozon-rFBS + CEL,香港空运不录入首期规则。 +- **报价是快照**:定价方案可以变,CEL/佣金规则可以更新,但已确认 SKU 报价始终保留当时输入、费率、规则版本和费用拆分。 + +## 2. 关键表的产品映射 + +| 领域 | 表 | 说明 | +|---|---|---| +| 账户与租户 | `users`、`workspaces`、`workspace_memberships`、`user_sessions` | 手机号密码登录;短信验证/风控字段预留但一期不启用。 | +| 平台与门店 | `platforms`、`currencies`、`platform_currency_support`、`stores`、`store_secrets`、`store_warehouses` | API Key 单独加密保存;密钥过期时间、商品限额无数据时使用 `NULL`,界面显示 `—`。 | +| 水印 | `media_assets`、`watermark_fonts`、`watermark_templates`、`watermark_template_versions`、`store_watermark_bindings` | 店铺绑定一个当前模板;发布任务未来固化模板版本,而不修改原图。 | +| 类目佣金 | `platform_product_categories`、`commission_categories`、`commission_category_rules`、`platform_product_category_commission_mappings` | 平台商品类目和平台佣金类目都强绑定同一平台;佣金按销售价 RUB 阶梯计费,抓取数据以 `competitor_capture` 来源和待审核版本录入。 | +| 跨境物流 | `logistics_providers`、`logistics_rate_cards`、`logistics_product_types`、`logistics_transport_methods`、`logistics_rate_rules` | CEL 产品类型、运输方式、货值/重量/尺寸/抛重规则、费率和版本分开保存。 | +| 定价 | `pricing_policies`、`pricing_policy_transport_methods`、`pricing_quotes`、`pricing_quote_options` | 店铺默认方案、SKU 单次临时选择、所有候选运输方式结果与最终确认结果。 | + +## 3. 必须由服务端保证的规则 + +1. 在每一个请求事务开始后,先验证当前用户的工作空间成员身份,再执行 `SET LOCAL app.workspace_id = ''`;PostgreSQL RLS 作为第二道隔离。 +2. `store_secrets.secret_ciphertext` 只保存信封加密后的密文、nonce 和密钥版本。不得写日志、返回 API 响应或发往浏览器缓存。 +3. 一个工作空间在同一平台只有一个活动默认店铺;切换默认店铺必须在同一事务内完成。 +4. 定价引擎枚举“佣金档位 × CEL 产品类型 × 运输方式”,回验最终销售价的 RUB 佣金档和 CNY 商品货值档;不能沿用旧项目的固定 12% / 18% 佣金。 +5. `pricing_quotes.product_variant_id` 暂为逻辑外键:商品/SKU DDL 落地时会补为指向 `product_variants` 的真实外键。报价本身可在没有 SKU 的“即时试算”中存在。 + +## 4. 初始数据与迁移顺序 + +1. 执行 `001_foundation.sql`。 +2. 初始化 `OZON` 平台、`CNY` / `RUB` 币种和平台币种支持关系。 +3. 建立 Ozon 平台商品类目和 Ozon 平台佣金类目树,再人工审核并导入同平台映射与阶梯佣金规则。 +4. 将已 Review 的 CEL Ozon-rFBS 规则录入一个 `draft` rate card,用真实 CEL 报价复核后发布为 `active`。 +5. 商品/SKU 模块落地后,增加 `product_variants` 外键并把 SKU “定价”按钮接到 `pricing_quotes`。 + +## 5. 仍不在此 DDL 内的内容 + +- 短信发送、验证码、风控供应商和密码找回流程; +- Ozon API 的实际同步 Worker、密钥轮换 Worker 和对象存储上传; +- 商品/SPU/SKU、媒体处理任务与真正的上架任务表; +- 平台/物流/佣金规则的后台管理 UI 与导入程序。 diff --git a/docs/architecture/sql/001_foundation.sql b/docs/architecture/sql/001_foundation.sql new file mode 100644 index 0000000..d2d5ce0 --- /dev/null +++ b/docs/architecture/sql/001_foundation.sql @@ -0,0 +1,526 @@ +-- Atlas Seller Kit: cloud foundation schema +-- PostgreSQL 15+ recommended. Run through a migration tool, not from the browser. + +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE SCHEMA IF NOT EXISTS atlas; +SET search_path TO atlas, public; + +CREATE TYPE membership_role AS ENUM ('owner', 'admin', 'editor', 'viewer'); +CREATE TYPE account_status AS ENUM ('active', 'disabled', 'pending_review'); +CREATE TYPE platform_status AS ENUM ('active', 'disabled'); +CREATE TYPE store_status AS ENUM ('verifying', 'active', 'credential_invalid', 'sync_error', 'disabled', 'deleted'); +CREATE TYPE credential_status AS ENUM ('unknown', 'valid', 'invalid', 'expired', 'rotation_required'); +CREATE TYPE secret_kind AS ENUM ('seller_api_key', 'browser_cookie'); +CREATE TYPE watermark_kind AS ENUM ('text', 'image'); +CREATE TYPE watermark_position AS ENUM ('top_left', 'top_center', 'top_right', 'middle_left', 'center', 'middle_right', 'bottom_left', 'bottom_center', 'bottom_right'); +CREATE TYPE record_status AS ENUM ('draft', 'active', 'archived', 'superseded'); +CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_sync', 'product_observed'); +CREATE TYPE fulfillment_mode AS ENUM ('rfbs', 'fbs', 'fbo', 'fbp', 'unknown'); +CREATE TYPE billing_weight_basis AS ENUM ('actual_only', 'max_actual_volume', 'conditional_volume'); +CREATE TYPE quote_status AS ENUM ('draft', 'calculated', 'confirmed', 'invalid', 'superseded'); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + phone_e164 TEXT NOT NULL UNIQUE CHECK (phone_e164 ~ '^\\+[1-9][0-9]{5,14}$'), + password_hash TEXT NOT NULL, + display_name TEXT, + status account_status NOT NULL DEFAULT 'active', + phone_verified_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE workspaces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug CITEXT NOT NULL UNIQUE, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE workspace_memberships ( + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + role membership_role NOT NULL DEFAULT 'editor', + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, user_id) +); + +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + ip_hash TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (expires_at > created_at) +); + +CREATE TABLE platforms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code CITEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status platform_status NOT NULL DEFAULT 'active', + capabilities JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE currencies ( + code CHAR(3) PRIMARY KEY, + name TEXT NOT NULL, + symbol TEXT NOT NULL, + decimal_places SMALLINT NOT NULL DEFAULT 2 CHECK (decimal_places BETWEEN 0 AND 6), + is_enabled BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE platform_currency_support ( + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + is_enabled BOOLEAN NOT NULL DEFAULT true, + PRIMARY KEY (platform_id, currency_code) +); + +CREATE TABLE stores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + platform_client_id TEXT NOT NULL, + currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + is_default BOOLEAN NOT NULL DEFAULT false, + note TEXT, + status store_status NOT NULL DEFAULT 'verifying', + credential_status credential_status NOT NULL DEFAULT 'unknown', + credential_checked_at TIMESTAMPTZ, + api_key_expires_at TIMESTAMPTZ, + product_limit INTEGER CHECK (product_limit IS NULL OR product_limit >= 0), + product_limit_checked_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, id, platform_id), + UNIQUE (workspace_id, platform_id, platform_client_id) +); +CREATE UNIQUE INDEX ux_stores_one_default_per_platform + ON stores (workspace_id, platform_id) + WHERE is_default AND deleted_at IS NULL; +CREATE INDEX ix_stores_workspace_platform ON stores (workspace_id, platform_id, created_at DESC); + +CREATE TABLE store_secrets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + kind secret_kind NOT NULL, + secret_ciphertext BYTEA NOT NULL, + secret_nonce BYTEA NOT NULL, + encryption_key_version TEXT NOT NULL, + fingerprint TEXT NOT NULL, + status credential_status NOT NULL DEFAULT 'unknown', + expires_at TIMESTAMPTZ, + last_verified_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, store_id, kind, fingerprint) +); +CREATE UNIQUE INDEX ux_store_secrets_one_current_kind + ON store_secrets (workspace_id, store_id, kind) + WHERE revoked_at IS NULL; + +CREATE TABLE store_warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + platform_warehouse_id TEXT NOT NULL, + name TEXT NOT NULL, + fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown', + is_rfbs BOOLEAN, + status TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + synced_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, store_id, platform_warehouse_id) +); + +CREATE TABLE media_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + object_key TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size BIGINT NOT NULL CHECK (byte_size > 0), + checksum_sha256 TEXT NOT NULL, + width_px INTEGER CHECK (width_px IS NULL OR width_px > 0), + height_px INTEGER CHECK (height_px IS NULL OR height_px > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, object_key), + UNIQUE (workspace_id, checksum_sha256) +); + +CREATE TABLE watermark_fonts ( + code CITEXT PRIMARY KEY, + display_name TEXT NOT NULL, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE watermark_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + kind watermark_kind NOT NULL, + current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0), + status record_status NOT NULL DEFAULT 'active', + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, name) +); + +CREATE TABLE watermark_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + template_id UUID NOT NULL, + version INTEGER NOT NULL CHECK (version > 0), + kind watermark_kind NOT NULL, + text_content TEXT, + image_asset_id UUID, + font_code CITEXT REFERENCES watermark_fonts(code), + font_size_px SMALLINT CHECK (font_size_px BETWEEN 12 AND 60), + opacity NUMERIC(5,4) NOT NULL CHECK (opacity BETWEEN 0 AND 1), + scale_ratio NUMERIC(7,4) CHECK (scale_ratio > 0), + position watermark_position NOT NULL, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, image_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, template_id, version), + CHECK ( + (kind = 'text' AND text_content IS NOT NULL AND font_code IS NOT NULL AND font_size_px IS NOT NULL AND image_asset_id IS NULL) + OR (kind = 'image' AND image_asset_id IS NOT NULL AND scale_ratio IS NOT NULL AND text_content IS NULL) + ) +); + +CREATE TABLE store_watermark_bindings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + template_id UUID NOT NULL, + bound_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + bound_at TIMESTAMPTZ NOT NULL DEFAULT now(), + unbound_at TIMESTAMPTZ, + FOREIGN KEY (workspace_id, store_id) REFERENCES stores(workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, template_id) REFERENCES watermark_templates(workspace_id, id) ON DELETE RESTRICT, + CHECK (unbound_at IS NULL OR unbound_at >= bound_at) +); +CREATE UNIQUE INDEX ux_store_watermark_one_active + ON store_watermark_bindings (workspace_id, store_id) + WHERE unbound_at IS NULL; + +CREATE TABLE platform_product_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + parent_id UUID REFERENCES platform_product_categories(id) ON DELETE RESTRICT, + external_category_id TEXT NOT NULL, + external_type_id TEXT, + name TEXT NOT NULL, + path TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + source_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (platform_id, id), + UNIQUE (platform_id, external_category_id, external_type_id) +); + +CREATE TABLE commission_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + parent_id UUID REFERENCES commission_categories(id) ON DELETE RESTRICT, + source_category_key TEXT, + name TEXT NOT NULL, + path TEXT NOT NULL, + source rule_source NOT NULL, + status record_status NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (platform_id, id), + UNIQUE (platform_id, path) +); + +CREATE TABLE commission_category_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + commission_category_id UUID NOT NULL REFERENCES commission_categories(id) ON DELETE CASCADE, + fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown', + market_code TEXT NOT NULL DEFAULT 'RU', + currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + min_price NUMERIC(18,4), + max_price NUMERIC(18,4), + min_inclusive BOOLEAN NOT NULL DEFAULT false, + max_inclusive BOOLEAN NOT NULL DEFAULT true, + commission_rate NUMERIC(7,6) NOT NULL CHECK (commission_rate >= 0 AND commission_rate < 1), + source rule_source NOT NULL, + version TEXT NOT NULL, + status record_status NOT NULL DEFAULT 'draft', + effective_from TIMESTAMPTZ NOT NULL, + effective_to TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (min_price IS NULL OR min_price >= 0), + CHECK (max_price IS NULL OR max_price >= 0), + CHECK (min_price IS NULL OR max_price IS NULL OR min_price <= max_price), + CHECK (effective_to IS NULL OR effective_to > effective_from) +); + +CREATE TABLE platform_product_category_commission_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE CASCADE, + platform_product_category_id UUID NOT NULL, + commission_category_id UUID NOT NULL, + fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown', + market_code TEXT NOT NULL DEFAULT 'RU', + priority SMALLINT NOT NULL DEFAULT 100 CHECK (priority >= 0), + source rule_source NOT NULL DEFAULT 'manual_rule', + status record_status NOT NULL DEFAULT 'draft', + effective_from TIMESTAMPTZ NOT NULL, + effective_to TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE CASCADE, + FOREIGN KEY (platform_id, commission_category_id) + REFERENCES commission_categories(platform_id, id) ON DELETE RESTRICT, + CHECK (effective_to IS NULL OR effective_to > effective_from) +); +CREATE INDEX ix_category_commission_mapping_lookup + ON platform_product_category_commission_mappings (platform_product_category_id, fulfillment, market_code, status, effective_from DESC, priority); + +CREATE TABLE logistics_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code CITEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE logistics_rate_cards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id UUID NOT NULL REFERENCES logistics_providers(id) ON DELETE RESTRICT, + platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT, + fulfillment fulfillment_mode NOT NULL, + market_code TEXT NOT NULL DEFAULT 'RU', + version TEXT NOT NULL, + source_name TEXT NOT NULL, + source_uri TEXT, + status record_status NOT NULL DEFAULT 'draft', + effective_from TIMESTAMPTZ NOT NULL, + effective_to TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (platform_id, id), + UNIQUE (provider_id, platform_id, fulfillment, market_code, version), + CHECK (effective_to IS NULL OR effective_to > effective_from) +); + +CREATE TABLE logistics_product_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE, + code CITEXT NOT NULL, + name TEXT NOT NULL, + merchandise_currency_code CHAR(3) NOT NULL REFERENCES currencies(code), + min_merchandise_value NUMERIC(18,4), + max_merchandise_value NUMERIC(18,4), + min_value_inclusive BOOLEAN NOT NULL DEFAULT false, + max_value_inclusive BOOLEAN NOT NULL DEFAULT true, + min_actual_weight_kg NUMERIC(12,3) NOT NULL DEFAULT 0.001 CHECK (min_actual_weight_kg >= 0.001), + max_actual_weight_kg NUMERIC(12,3), + max_sum_dimensions_cm NUMERIC(12,3), + max_longest_side_cm NUMERIC(12,3), + max_second_longest_side_cm NUMERIC(12,3), + billing_basis billing_weight_basis NOT NULL, + volume_divisor NUMERIC(12,3), + volume_condition_sum_gt_cm NUMERIC(12,3), + volume_round_scale SMALLINT CHECK (volume_round_scale BETWEEN 0 AND 6), + billing_round_scale SMALLINT CHECK (billing_round_scale BETWEEN 0 AND 6), + max_volume_weight_kg NUMERIC(12,3), + volume_limit_round_scale SMALLINT CHECK (volume_limit_round_scale BETWEEN 0 AND 6), + return_policy JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (rate_card_id, code), + CHECK (max_merchandise_value IS NULL OR max_merchandise_value >= 0), + CHECK (min_merchandise_value IS NULL OR min_merchandise_value >= 0), + CHECK (min_merchandise_value IS NULL OR max_merchandise_value IS NULL OR min_merchandise_value <= max_merchandise_value), + CHECK (max_actual_weight_kg IS NULL OR max_actual_weight_kg >= min_actual_weight_kg), + CHECK ((billing_basis = 'actual_only' AND volume_divisor IS NULL) OR (billing_basis <> 'actual_only' AND volume_divisor IS NOT NULL)) +); + +CREATE TABLE logistics_transport_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rate_card_id UUID NOT NULL REFERENCES logistics_rate_cards(id) ON DELETE CASCADE, + code CITEXT NOT NULL, + name TEXT NOT NULL, + speed_label TEXT, + min_transit_days SMALLINT CHECK (min_transit_days >= 0), + max_transit_days SMALLINT CHECK (max_transit_days >= min_transit_days), + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (rate_card_id, code) +); + +CREATE TABLE logistics_rate_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_type_id UUID NOT NULL REFERENCES logistics_product_types(id) ON DELETE CASCADE, + transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE CASCADE, + fixed_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (fixed_fee_cny >= 0), + per_billable_kg_fee_cny NUMERIC(18,4) NOT NULL CHECK (per_billable_kg_fee_cny >= 0), + min_charge_cny NUMERIC(18,4) CHECK (min_charge_cny IS NULL OR min_charge_cny >= 0), + surcharge_rules JSONB NOT NULL DEFAULT '[]'::jsonb, + status record_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (product_type_id, transport_method_id) +); + +CREATE TABLE pricing_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + name TEXT NOT NULL, + note TEXT, + status record_status NOT NULL DEFAULT 'active', + is_default BOOLEAN NOT NULL DEFAULT false, + rate_card_id UUID NOT NULL, + default_transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT, + domestic_shipping_and_label_fee_cny NUMERIC(18,4) NOT NULL DEFAULT 0 CHECK (domestic_shipping_and_label_fee_cny >= 0), + ad_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (ad_rate >= 0 AND ad_rate < 1), + last_mile_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (last_mile_rate >= 0 AND last_mile_rate < 1), + withdrawal_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (withdrawal_rate >= 0 AND withdrawal_rate < 1), + other_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (other_rate >= 0 AND other_rate < 1), + desired_markup_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (desired_markup_rate >= 0), + list_price_discount_rate NUMERIC(7,6) NOT NULL DEFAULT 0 CHECK (list_price_discount_rate >= 0 AND list_price_discount_rate < 1), + fx_strategy TEXT NOT NULL DEFAULT 'system' CHECK (fx_strategy IN ('system', 'fixed', 'override_allowed')), + fixed_cny_to_rub_rate NUMERIC(18,8), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (platform_id, rate_card_id) REFERENCES logistics_rate_cards(platform_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id), + UNIQUE (workspace_id, id, store_id, platform_id), + UNIQUE (workspace_id, store_id, name), + CHECK ((fx_strategy = 'fixed' AND fixed_cny_to_rub_rate > 0) OR (fx_strategy <> 'fixed' AND fixed_cny_to_rub_rate IS NULL)) +); +CREATE UNIQUE INDEX ux_pricing_policies_one_default + ON pricing_policies (workspace_id, store_id) + WHERE is_default AND status = 'active'; + +CREATE TABLE pricing_policy_transport_methods ( + workspace_id UUID NOT NULL, + pricing_policy_id UUID NOT NULL, + transport_method_id UUID NOT NULL REFERENCES logistics_transport_methods(id) ON DELETE RESTRICT, + is_enabled BOOLEAN NOT NULL DEFAULT true, + sort_order SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, pricing_policy_id, transport_method_id), + FOREIGN KEY (workspace_id, pricing_policy_id) REFERENCES pricing_policies(workspace_id, id) ON DELETE CASCADE +); + +CREATE TABLE pricing_quotes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, + store_id UUID NOT NULL, + platform_id UUID NOT NULL, + pricing_policy_id UUID, + product_variant_id UUID, -- Logical FK; add real FK in the product/SKU migration. + platform_product_category_id UUID, + fulfillment fulfillment_mode NOT NULL, + purchase_cost_cny NUMERIC(18,4) NOT NULL CHECK (purchase_cost_cny >= 0), + package_weight_kg NUMERIC(12,3) NOT NULL CHECK (package_weight_kg >= 0.001), + package_length_cm NUMERIC(12,3) NOT NULL CHECK (package_length_cm > 0), + package_width_cm NUMERIC(12,3) NOT NULL CHECK (package_width_cm > 0), + package_height_cm NUMERIC(12,3) NOT NULL CHECK (package_height_cm > 0), + fx_cny_to_rub NUMERIC(18,8) NOT NULL CHECK (fx_cny_to_rub > 0), + input_snapshot JSONB NOT NULL, + status quote_status NOT NULL DEFAULT 'draft', + selected_option_id UUID, + calculated_at TIMESTAMPTZ, + confirmed_at TIMESTAMPTZ, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, pricing_policy_id, store_id, platform_id) + REFERENCES pricing_policies(workspace_id, id, store_id, platform_id) ON DELETE RESTRICT, + FOREIGN KEY (platform_id, platform_product_category_id) + REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, + UNIQUE (workspace_id, id) +); + +CREATE TABLE pricing_quote_options ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + quote_id UUID NOT NULL, + logistics_rate_rule_id UUID REFERENCES logistics_rate_rules(id) ON DELETE SET NULL, + commission_rule_id UUID REFERENCES commission_category_rules(id) ON DELETE SET NULL, + logistics_product_type_id UUID REFERENCES logistics_product_types(id) ON DELETE SET NULL, + transport_method_id UUID REFERENCES logistics_transport_methods(id) ON DELETE SET NULL, + merchandise_value_cny NUMERIC(18,4) NOT NULL CHECK (merchandise_value_cny >= 0), + selling_price_rub NUMERIC(18,4) NOT NULL CHECK (selling_price_rub >= 0), + list_price_rub NUMERIC(18,4) NOT NULL CHECK (list_price_rub >= selling_price_rub), + billable_weight_kg NUMERIC(12,3) NOT NULL CHECK (billable_weight_kg >= 0.001), + logistics_fee_cny NUMERIC(18,4) NOT NULL CHECK (logistics_fee_cny >= 0), + commission_fee_rub NUMERIC(18,4) NOT NULL CHECK (commission_fee_rub >= 0), + domestic_fee_cny NUMERIC(18,4) NOT NULL CHECK (domestic_fee_cny >= 0), + ad_fee_rub NUMERIC(18,4) NOT NULL CHECK (ad_fee_rub >= 0), + last_mile_fee_rub NUMERIC(18,4) NOT NULL CHECK (last_mile_fee_rub >= 0), + withdrawal_fee_rub NUMERIC(18,4) NOT NULL CHECK (withdrawal_fee_rub >= 0), + other_fee_rub NUMERIC(18,4) NOT NULL CHECK (other_fee_rub >= 0), + profit_rub NUMERIC(18,4) NOT NULL, + calculation_snapshot JSONB NOT NULL, + is_valid BOOLEAN NOT NULL DEFAULT true, + validation_errors JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, quote_id) REFERENCES pricing_quotes(workspace_id, id) ON DELETE CASCADE, + UNIQUE (workspace_id, quote_id, transport_method_id, logistics_product_type_id, commission_rule_id) +); +ALTER TABLE pricing_quotes + ADD CONSTRAINT fk_pricing_quotes_selected_option + FOREIGN KEY (selected_option_id) REFERENCES pricing_quote_options(id) ON DELETE SET NULL; + +-- Tenant isolation. The API must set app.workspace_id with SET LOCAL after authorization. +CREATE OR REPLACE FUNCTION app_current_workspace_id() RETURNS UUID +LANGUAGE SQL STABLE AS $$ + SELECT NULLIF(current_setting('app.workspace_id', true), '')::UUID +$$; + +DO $$ +DECLARE table_name TEXT; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'stores', 'store_secrets', 'store_warehouses', 'media_assets', + 'watermark_templates', 'watermark_template_versions', 'store_watermark_bindings', + 'pricing_policies', 'pricing_policy_transport_methods', 'pricing_quotes', 'pricing_quote_options' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE 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 $$; diff --git a/docs/architecture/technology-and-deployment.md b/docs/architecture/technology-and-deployment.md new file mode 100644 index 0000000..6e2650a --- /dev/null +++ b/docs/architecture/technology-and-deployment.md @@ -0,0 +1,117 @@ +# 技术栈、异步任务与部署方案 + +> 状态:已确认。本文定义首期技术选型和环境边界;具体 Docker Compose、CI/CD、密钥和云资源参数在实施前补充为运行手册。 + +## 1. 已确认技术选型 + +| 层级 | 选型 | 职责 | +|---|---|---| +| 前端 | React 19、TypeScript、Vite、Ant Design、React Router、Axios | 延续 `ozon-seller-kit/studio` 的前端技术路线,构建卖家工作台。 | +| 主后端 | NestJS、TypeScript | 认证、门店、商品/SKU、定价、平台适配、任务编排和 HTTP API。 | +| 关系数据库 | PostgreSQL | 业务事实来源:用户、隐藏工作空间、门店、商品、报价、任务记录和审计。 | +| 缓存与队列 | Redis、BullMQ | 待执行任务、延迟/重试、进度和限流;不作为业务事实来源。 | +| 对象存储(本地) | MinIO | 本地开发存放图片和文件,使用 S3 兼容接口。 | +| 对象存储(生产) | 七牛云 Kodo | 生产及共享测试环境存放图片和文件。Kodo 的基础 S3 兼容能力用于上传、下载、删除和短时访问链接。 | +| 运行与编排 | Docker、Docker Compose | 本地一键启动依赖;首期服务器部署 API、Worker、Redis 和反向代理。 | + +不将旧项目的 FastAPI 服务作为新项目运行时依赖;旧项目的 Ozon 资料、计价规则、采集经验和前端技术路线可作为参考或选择性复用。 + +## 2. 服务边界 + +```text +Browser + │ HTTPS + ▼ +Reverse proxy / CDN + ├─ Web:Vite 构建的静态前端 + └─ NestJS API + ├─ PostgreSQL:业务数据、任务状态、审计与规则快照 + ├─ Redis:BullMQ 队列与短期缓存 + ├─ Kodo / MinIO:原图、处理图、AI 图和导入文件 + └─ BullMQ Worker:水印、AI、平台同步、上架与重试 +``` + +API 进程只处理短请求、鉴权、事务和任务投递。耗时或会失败重试的工作必须由独立 Worker 执行,不能在 HTTP 请求内直接完成。 + +## 3. Redis 与 BullMQ 的使用规则 + +Redis 是高速的临时基础设施;BullMQ 使用 Redis 保存队列中的任务及其调度/重试信息。两者都不是商品、门店或任务最终结果的唯一存储位置。 + +| 任务 | API 的动作 | Worker 的动作 | PostgreSQL 应保存的内容 | +|---|---|---|---| +| 水印/图片处理 | 创建任务并投递队列 | 读取原图、处理、上传派生图 | 任务状态、输入图片、输出图片、错误、重试次数 | +| AI 生图/智能规划 | 创建任务并投递队列 | 调用模型、轮询或处理回调、保存结果 | 模型/提示词版本、输入、结果、费用/用量、人工确认状态 | +| Ozon 同步 | 创建同步任务 | 限速调用 API,分页拉取并更新数据 | 同步游标、开始/完成时间、错误和结果摘要 | +| SKU 发布 | 创建发布任务 | 按店铺限流发布、查询结果、失败重试 | 发布状态、平台任务 ID、请求/响应摘要和错误 | + +实现要求: + +1. 所有长任务先在 PostgreSQL 创建任务记录,再在事务提交后投递队列;后续实施时使用 outbox 模式避免“数据库已提交但任务未投递”的遗漏。 +2. 任务必须幂等:相同 SKU 的一次发布任务重试,不得重复创建平台商品。 +3. 重试必须有次数上限、指数退避和可见错误;Ozon 等第三方 API 需按店铺/API 限额控制并发。 +4. Redis 可丢失或重建,Worker 应能根据 PostgreSQL 中的未完成任务恢复投递;业务结果不只存在于 Redis。 + +## 4. 对象存储策略 + +对象存储保存二进制文件;PostgreSQL 只保存对象键、文件哈希、MIME 类型、尺寸、归属关系和处理状态,不保存大图片的二进制内容。 + +### 4.1 概念 + +- **Bucket(存储桶)**:一个独立文件仓库,例如 `atlas-prod-assets`。 +- **Object Key(对象键)**:桶内文件的唯一键,例如 `workspaces/{workspaceId}/products/{productId}/original/{assetId}.jpg`。 +- **S3 兼容**:以 Bucket、Object Key、上传/下载/删除和预签名 URL 这一类通用 API 操作对象存储。MinIO 与七牛 Kodo 均可用于此模式;应用只依赖首期所需的基础能力。 + +### 4.2 文件分层与访问控制 + +建议对象键按业务归属分层: + +```text +workspaces/{workspaceId}/ + watermarks/{assetId}/ + products/{productId}/original/{assetId} + products/{productId}/processed/{assetId} + products/{productId}/ai-generated/{assetId} + imports/{taskId}/{filename} +``` + +规则: + +1. Bucket 默认私有;用户原图、模板图、导入文件和 AI 结果不长期公开。 +2. 前端上传使用服务端签发的短时上传凭证或预签名 URL;浏览器不保存永久 Kodo 密钥。 +3. 前端预览/下载使用短时访问 URL,或由 API 受控转发。 +4. Ozon 发布由 Worker 用受控服务端凭据读取文件;不因平台发布而把私有原图改为公共读。 +5. 对象删除应先检查数据库引用;商品、水印软删除或保留期结束后,由后台清理任务物理删除。 + +### 4.3 本地、测试与生产 + +| 环境 | 对象存储 | 说明 | +|---|---|---| +| local | Docker 中的 MinIO | 每位开发者独立数据,可随时用种子数据重建。 | +| staging | 七牛 Kodo 独立 Bucket | 家/公司联调和验收使用,严禁与生产 Bucket 混用。 | +| production | 七牛 Kodo 独立 Bucket | 保存真实用户文件,凭据只在生产运行环境中配置。 | + +首期不在普通单台云服务器自建生产 MinIO:自行承担磁盘冗余、备份、故障恢复、TLS、升级和监控,不能因“部署了 MinIO”而获得托管对象存储的可靠性。 + +## 5. 环境与数据库策略 + +| 环境 | PostgreSQL | 目的 | +|---|---|---| +| local | 本机 Docker PostgreSQL | 日常开发、迁移编写和自动化测试;每位开发者独立。 | +| staging | 云端独立 PostgreSQL | 家/公司共同联调、演示和测试店铺验证。 | +| production | 云托管 PostgreSQL | 真实业务数据;仅生产服务通过私网或受控网络访问。 | + +不能把生产数据库作为开发数据库,也不建议所有日常开发直接共用同一云端数据库。数据库结构通过迁移文件保持一致;演示数据通过版本化 seed 脚本保持一致。迁移顺序必须是 local → staging → production,并在生产前备份和验证回滚方案。 + +## 6. AI 能力边界 + +首期 AI 智能规划、文案和生图均通过 Provider Adapter 调用云模型 API。NestJS 负责权限、上下文组装、结构化结果校验、任务状态和人工确认;模型不得直接改价、发布或修改门店数据。 + +若未来出现 GPU 推理、自托管扩散模型、大规模 OCR/CV、训练或微调,再增加独立 Python AI Worker。Python Worker 通过队列或内部 API 接收任务,主业务后端仍保持 NestJS,避免过早维护双语言的主业务系统。 + +## 7. Docker 与首期部署 + +本地 Docker Compose 至少编排:`web`、`api`、`worker`、`postgres`、`redis`、`minio`。前端开发也可单独使用 Vite 热更新,并将 `/api` 代理至本地 API。 + +首期云端 Docker Compose 编排:`api`、`worker`、`redis`、`reverse-proxy`;PostgreSQL 使用云托管实例,图片使用七牛 Kodo。每个环境使用独立 `.env`/密钥管理配置,密钥不提交 Git、不进入前端构建产物和日志。 + +在规模、可用性或多机部署出现明确瓶颈前,不引入 Kubernetes。 diff --git a/docs/integrations/ozon-seller-api/01-authentication.md b/docs/integrations/ozon-seller-api/01-authentication.md new file mode 100644 index 0000000..6c2980e --- /dev/null +++ b/docs/integrations/ozon-seller-api/01-authentication.md @@ -0,0 +1,234 @@ +# Ozon Seller API 鉴权与基础 + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction + +--- + +## 1. 鉴权方式 + +Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带: + +```http +Client-Id: <你的 Client ID> +Api-Key: <你的 API Key> +Content-Type: application/json +``` + +### 获取凭证 + +1. 登录 Ozon 卖家后台 +2. 进入「设置」→「Seller API」 +3. 点击「生成 API Key」 +4. 选择权限级别: + - **只读**(Read):仅查询 + - **读写**(Read & Write):查询 + 创建/更新商品 + - **管理员**(Admin):所有权限 +5. 保存 `Client-Id` 和 `Api-Key`(**Api-Key 仅显示一次**) + +### 安全约束 + +- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺 +- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文 +- 🔄 **定期轮换**:建议每 90 天更换一次 +- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token + +--- + +## 2. Base URL + +``` +https://api-seller.ozon.ru +``` + +所有接口路径都基于此 URL,例如: +``` +POST https://api-seller.ozon.ru/v3/product/import +``` + +--- + +## 3. 请求示例 + +### cURL + +```bash +curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \ + -H "Client-Id: 123456" \ + -H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"language":"RU"}' +``` + +### Python (httpx) + +```python +import httpx + +headers = { + "Client-Id": "123456", + "Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "Content-Type": "application/json" +} + +async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/tree", + headers=headers, + json={"language": "RU"} + ) + data = resp.json() +``` + +--- + +## 4. 通用响应结构 + +### 成功响应(200/201) + +```json +{ + "result": { /* 业务数据 */ } +} +``` + +部分接口直接返回数组或对象,不包裹 `result`。 + +### 错误响应(4xx/5xx) + +```json +{ + "code": 400, + "message": "INVALID_ARGUMENT", + "details": [ + { + "typeUrl": "type.googleapis.com/ozon.ValidationError", + "value": "..." + } + ] +} +``` + +或简化版: + +```json +{ + "error": { + "code": "INVALID_PARAMETER", + "message": "offer_id is required" + } +} +``` + +--- + +## 5. 错误码 + +| HTTP 状态码 | 含义 | 常见原因 | 处理建议 | +|---|---|---|---| +| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 | +| **401** | 未鉴权 | 请求头缺 `Client-Id` 或 `Api-Key` | 检查请求头 | +| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key | +| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 | +| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 | +| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) | +| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 | +| **503** | 服务不可用 | 维护中 | 稍后重试 | + +--- + +## 6. 限流规则 + +官方未公开明确的限流阈值,根据社区经验: + +- **常规接口**:~10 req/s +- **批量接口**(如 `/v3/product/list`):~5 req/s +- **同一 task_id 轮询**:建议间隔 ≥5s + +触发 429 后: +1. 解析响应头 `Retry-After`(秒数) +2. 若无此头,使用指数退避:1s → 2s → 4s → 8s +3. 最多重试 3 次 + +--- + +## 7. 超时建议 + +| 接口类型 | 超时时间 | 理由 | +|---|---|---| +| 查询类(类目/属性/商品列表) | 30s | 轻量请求 | +| 导入类(`/v3/product/import`) | 60-90s | 后端需校验 + 入库 | +| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 | +| 图片上传 | 90s | 网络传输耗时 | + +--- + +## 8. 测试凭证有效性 + +### `/v1/roles` —— 获取当前 Key 的角色与权限 + +```http +POST https://api-seller.ozon.ru/v1/roles +``` + +**请求体**:空 `{}` + +**响应**: + +```json +{ + "result": [ + { + "role_name": "Seller", + "permissions": [ + "read:products", + "write:products", + "read:categories", + ... + ] + } + ] +} +``` + +**用途**: +- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效) +- ✅ 查看权限范围(判断是否有 `write:products`) +- ✅ 零业务副作用(不消耗额度,不修改数据) + +**V2 集成点**:`POST /api/shops/:id/test` 调此接口作连通性校验。 + +--- + +## 9. 请求 ID 追踪 + +部分接口响应包含 `request_id`(如图生图、导入任务),用于: +- 问题排查:联系 Ozon 支持时提供此 ID +- 幂等重试:某些接口可根据 `request_id` 避免重复创建 + +建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。 + +--- + +## 10. 环境 + +Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意: +- ⚠️ 所有操作都在真实店铺 +- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`) +- 🗑️ 测试后及时删除/归档测试商品 + +--- + +## 11. SDK 与工具 + +官方未提供 Python SDK,社区方案: +- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`) +- 第三方库:`ozon-api`(PyPI,非官方,更新滞后) + +--- + +## 12. 相关链接 + +- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/) +- [官方文档(俄文)](https://docs.ozon.ru/api/seller/) +- [卖家后台](https://seller.ozon.ru/) +- [API 状态页](https://status.ozon.ru/)(维护公告) diff --git a/docs/integrations/ozon-seller-api/02-category-tree.md b/docs/integrations/ozon-seller-api/02-category-tree.md new file mode 100644 index 0000000..31e0d82 --- /dev/null +++ b/docs/integrations/ozon-seller-api/02-category-tree.md @@ -0,0 +1,333 @@ +# 类目树查询 API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree + +--- + +## 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/tree` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) | + +--- + +## 请求 + +### 请求体 + +```json +{ + "language": "RU" +} +``` + +### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` | + +--- + +## 响应 + +### 成功响应(200) + +```json +{ + "result": [ + { + "description_category_id": 17033876, + "category_name": "Термокружки", + "type_id": 97114, + "type_name": "Термокружка", + "disabled": false, + "children": [] + }, + { + "description_category_id": 17028922, + "category_name": "Посуда", + "type_id": 0, + "type_name": "", + "disabled": true, + "children": [ + { + "description_category_id": 17033876, + "category_name": "Термокружки", + "type_id": 97114, + "type_name": "Термокружка", + "disabled": false, + "children": [] + } + ] + } + ] +} +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| description_category_id | integer | **类目 ID**(发布商品时必填) | +| category_name | string | 类目名称 | +| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) | +| type_name | string | 商品类型名称 | +| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) | +| children | array | 子类目(递归结构) | + +--- + +## 关键约束 + +1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品 +2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id` +3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级 + +--- + +## 使用场景 + +### 场景 1:前端类目选择器 + +``` +① 请求类目树(language=RU,给俄文用户看) +② 递归展开树形结构 +③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级) +④ 选中后保存 description_category_id + type_id +``` + +### 场景 2:服务端缓存 + +``` +① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理) +② 存入 category_tree 表(见 docs/v2/database.md §2.7) +③ TTL 24h,过期重拉 +④ 用户选类目时直接查库,不频繁调 API +``` + +--- + +## 示例代码 + +### Python(服务端缓存) + +```python +import httpx +from typing import List, Dict, Any + +async def fetch_category_tree( + client_id: str, + api_key: str, + language: str = "DEFAULT" +) -> List[Dict[str, Any]]: + """拉取类目树并返回扁平化列表""" + headers = { + "Client-Id": client_id, + "Api-Key": api_key, + "Content-Type": "application/json" + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/tree", + headers=headers, + json={"language": language} + ) + resp.raise_for_status() + data = resp.json() + + # 递归扁平化 + def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0): + flat = [] + for node in nodes: + flat.append({ + "description_category_id": node["description_category_id"], + "parent_id": parent_id, + "category_name": node["category_name"], + "type_id": node["type_id"], + "type_name": node["type_name"], + "disabled": node["disabled"], + "level": level, + "lang": language + }) + if node.get("children"): + flat.extend(flatten( + node["children"], + level + 1, + node["description_category_id"] + )) + return flat + + return flatten(data.get("result", [])) +``` + +### TypeScript(前端选择器) + +```typescript +interface CategoryNode { + description_category_id: number; + category_name: string; + type_id: number; + type_name: string; + disabled: boolean; + children: CategoryNode[]; +} + +async function fetchCategoryTree(language = 'RU'): Promise { + const resp = await fetch('/api/categories/tree?lang=' + language); + const data = await resp.json(); + return data.result; +} + +// 转为 antd Tree 数据结构 +function toTreeData(nodes: CategoryNode[]): any[] { + return nodes.map(node => ({ + key: `${node.description_category_id}-${node.type_id}`, + title: node.category_name, + disabled: node.disabled, // 父类目禁止选择 + children: node.children.length > 0 ? toTreeData(node.children) : undefined, + // 保存原始数据,选中时取用 + data: { + description_category_id: node.description_category_id, + type_id: node.type_id + } + })); +} +``` + +--- + +## 缓存策略 + +### 全局缓存(推荐) + +```python +# 类目树与店铺无关,所有店铺共用一份 +# 启动时拉取,存内存 + 数据库 +# TTL 24h(类目变化不频繁) + +from functools import lru_cache +from datetime import datetime, timedelta + +_category_tree_cache = None +_cache_time = None + +@lru_cache(maxsize=1) +async def get_category_tree_cached(language: str = "DEFAULT"): + global _category_tree_cache, _cache_time + + now = datetime.utcnow() + if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24): + return _category_tree_cache + + # 从任意店铺拉(类目树全局一致) + tree = await fetch_category_tree(any_client_id, any_api_key, language) + _category_tree_cache = tree + _cache_time = now + + # 同时写数据库 + await save_to_db(tree) + + return tree +``` + +### 按需更新 + +```python +# 用户反馈「找不到某类目」时手动刷新 +async def refresh_category_tree(): + global _category_tree_cache, _cache_time + _category_tree_cache = None + _cache_time = None + get_category_tree_cached.cache_clear() + return await get_category_tree_cached() +``` + +--- + +## 常见问题 + +### Q1: 类目树很大吗? + +**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。 + +### Q2: 多久更新一次? + +**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**。 + +### Q3: 不同语言的类目树结构一样吗? + +**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议: +- 服务端缓存 `DEFAULT`(英文,便于代码处理) +- 前端按用户语言拉取 `RU`(俄文,展示用) + +### Q4: `type_id=0` 是什么意思? + +**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。 + +--- + +## V2 项目集成 + +### API 层(已实现) + +```python +# server/api/categories.py + +@router.get("/categories/tree") +async def get_tree( + lang: str = Query("RU"), + shop_id: str = Query(...), # 需要店铺凭证 + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + tree = await ozon_client.get_category_tree( + shop.client_id_dec, + shop.api_key_dec, + lang + ) + return {"result": tree} +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/CategoryPicker.tsx + +import { Tree } from 'antd'; +import { useEffect, useState } from 'react'; + +export function CategoryPicker({ shopId, onChange }) { + const [treeData, setTreeData] = useState([]); + + useEffect(() => { + fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`) + .then(r => r.json()) + .then(data => setTreeData(toTreeData(data.result))); + }, [shopId]); + + return ( + { + if (!node.disabled) { + onChange(node.data); // { description_category_id, type_id } + } + }} + /> + ); +} +``` + +--- + +## 相关文档 + +- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性 +- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构 +- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略 diff --git a/docs/integrations/ozon-seller-api/03-category-attributes.md b/docs/integrations/ozon-seller-api/03-category-attributes.md new file mode 100644 index 0000000..b487fe2 --- /dev/null +++ b/docs/integrations/ozon-seller-api/03-category-attributes.md @@ -0,0 +1,660 @@ +# 类目属性与字典值 API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes + +--- + +## 1. 获取类目属性 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取指定类目的所有属性(发布商品时需填写) | + +--- + +### 请求 + +```json +{ + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU" +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) | +| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) | +| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 85, + "name": "Бренд", + "description": "Укажите бренд товара", + "type": "String", + "is_collection": false, + "is_required": true, + "is_aspect": false, + "max_value_count": 1, + "dictionary_id": 28732, + "category_dependent": false, + "group_id": 0, + "group_name": "" + }, + { + "id": 8229, + "name": "Цвет товара", + "description": "", + "type": "String", + "is_collection": false, + "is_required": false, + "is_aspect": true, + "max_value_count": 1, + "dictionary_id": 61405, + "category_dependent": false, + "group_id": 1, + "group_name": "Варианты" + }, + { + "id": 9048, + "name": "Объем", + "description": "Укажите объем в миллилитрах", + "type": "Integer", + "is_collection": false, + "is_required": true, + "is_aspect": false, + "max_value_count": 1, + "dictionary_id": 0, + "category_dependent": false, + "group_id": 2, + "group_name": "Основные" + } + ] +} +``` + +--- + +### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| **id** | integer | **属性 ID**(发布时填 `attributes[].id`) | +| **name** | string | 属性名称(如"品牌"、"颜色") | +| description | string | 属性说明(填写提示) | +| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` | +| **is_required** | boolean | **是否必填**。`true` = 必须填写,否则发布失败 | +| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 | +| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公") | +| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) | +| **dictionary_id** | integer | **字典 ID**。`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 | +| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) | +| group_id | integer | 属性分组 ID | +| group_name | string | 属性分组名(如"基本信息"、"变体") | + +--- + +### 关键字段组合 + +| 组合 | 含义 | 示例 | 填写方式 | +|---|---|---|---| +| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 | +| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id) | +| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 | +| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) | +| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` | + +--- + +## 2. 获取属性值字典 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute/values` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) | + +--- + +### 请求 + +```json +{ + "attribute_id": 85, + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU", + "limit": 1000, + "last_value_id": 0 +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| attribute_id | integer | ✅ 是 | 属性 ID | +| description_category_id | integer | ✅ 是 | 类目 ID | +| type_id | integer | ✅ 是 | 商品类型 ID | +| language | string | 可选 | 语言代码 | +| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 | +| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 971082156, + "value": "Thermos", + "info": "", + "picture": "" + }, + { + "id": 971317107, + "value": "Stanley", + "info": "", + "picture": "" + } + ], + "has_next": true +} +``` + +#### 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id`) | +| **value** | string | 字典值文本(如品牌名"Thermos") | +| info | string | 补充说明 | +| picture | string | 值配图 URL(部分属性有,如颜色) | +| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) | + +--- + +### 分页示例 + +```python +async def fetch_all_values(attribute_id, category_id, type_id): + all_values = [] + last_id = 0 + + while True: + resp = await client.post( + "https://api-seller.ozon.ru/v1/description-category/attribute/values", + json={ + "attribute_id": attribute_id, + "description_category_id": category_id, + "type_id": type_id, + "language": "RU", + "limit": 5000, + "last_value_id": last_id + } + ) + data = resp.json() + values = data.get("result", []) + all_values.extend(values) + + if not data.get("has_next") or not values: + break + + last_id = values[-1]["id"] + + return all_values +``` + +--- + +## 3. 按关键词搜索属性值 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/description-category/attribute/values/search` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | 模糊搜索字典值(避免拉全量字典) | + +--- + +### 请求 + +```json +{ + "attribute_id": 85, + "description_category_id": 17033876, + "type_id": 97114, + "language": "RU", + "value": "Ther", + "limit": 100 +} +``` + +#### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| attribute_id | integer | ✅ 是 | 属性 ID | +| description_category_id | integer | ✅ 是 | 类目 ID | +| type_id | integer | ✅ 是 | 商品类型 ID | +| language | string | 可选 | 语言代码 | +| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) | +| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 | + +--- + +### 响应 + +```json +{ + "result": [ + { + "id": 971082156, + "value": "Thermos", + "info": "", + "picture": "" + }, + { + "id": 971982345, + "value": "Thermocafe", + "info": "", + "picture": "" + } + ] +} +``` + +--- + +## 4. 属性映射工作流 + +### 场景:采集来的参数 → Ozon 属性 + +``` +采集原文(raw.params): +[ + { "key": "Материал", "value": "Нержавеющая сталь" }, + { "key": "Объем", "value": "500 мл" }, + { "key": "Бренд", "value": "Thermos" } +] + +↓ ① 自动匹配属性名 + +attributes = [ + { id: 8505, name: "Материал" }, // 材质 + { id: 9048, name: "Объем" }, // 容量 + { id: 85, name: "Бренд" } // 品牌 +] + +↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值 + +POST /attribute/values/search { + attribute_id: 85, // 品牌 + value: "Thermos" +} +→ { id: 971082156, value: "Thermos" } + +↓ ③ 组装最终 attributes + +products.attributes = [ + { + "complex_id": 0, + "id": 8505, + "values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填 + }, + { + "complex_id": 0, + "id": 9048, + "values": [{ "value": "500" }] // 容量是数值,提取数字 + }, + { + "complex_id": 0, + "id": 85, + "values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典 + } +] +``` + +--- + +## 5. 自动匹配策略 + +### 策略 A:归一化 + 模糊匹配 + +```python +import re +from difflib import SequenceMatcher + +def normalize(text: str) -> str: + """归一化:小写 + 去标点 + 词干""" + text = text.lower().strip() + text = re.sub(r'[^\w\s]', '', text) + # 俄文词干化(需 pymorphy2 库) + # text = morph.parse(text)[0].normal_form + return text + +def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8): + """模糊匹配:相似度 > 0.8 即认为匹配""" + norm_key = normalize(采集key) + best = None + best_score = 0 + + for attr in 属性列表: + norm_name = normalize(attr["name"]) + score = SequenceMatcher(None, norm_key, norm_name).ratio() + if score > best_score: + best = attr + best_score = score + + return best if best_score >= threshold else None +``` + +### 策略 B:关键词映射表 + +```python +# 预定义常见映射(中文采集 key → Ozon 属性 name) +KEYWORD_MAP = { + "品牌": ["Бренд", "Brand"], + "材质": ["Материал", "Material"], + "重量": ["Вес", "Weight"], + "尺寸": ["Размер", "Size"], + "颜色": ["Цвет", "Color"], + # ... 补充更多 +} + +def keyword_match(采集key: str, 属性列表: list): + for cn_key, ru_keys in KEYWORD_MAP.items(): + if cn_key in 采集key: + for attr in 属性列表: + if any(rk in attr["name"] for rk in ru_keys): + return attr + return None +``` + +--- + +## 6. 必填项校验 + +### 发布前校验 + +```python +async def validate_required_attributes( + category_id: int, + type_id: int, + attributes: list +) -> list[str]: + """返回缺失的必填属性名列表""" + # 获取该类目的所有属性 + attrs = await fetch_category_attributes(category_id, type_id) + + # 提取必填属性 + required = [a for a in attrs if a["is_required"]] + + # 已填写的属性 ID + filled_ids = {a["id"] for a in attributes} + + # 找出缺失的 + missing = [a["name"] for a in required if a["id"] not in filled_ids] + + return missing + +# 使用 +missing = await validate_required_attributes(17033876, 97114, product.attributes) +if missing: + raise ValueError(f"缺少必填属性:{', '.join(missing)}") +``` + +--- + +## 7. 缓存策略 + +### 属性列表缓存 + +```python +# 按 (category_id, type_id) 缓存 +# TTL 7 天(属性变化极少) + +from functools import lru_cache + +@lru_cache(maxsize=500) +async def get_attributes_cached(category_id: int, type_id: int): + # 先查数据库 + cached = await db.query(CategoryAttribute).filter_by( + description_category_id=category_id, + type_id=type_id + ).all() + + if cached: + return cached + + # 未缓存,调 API + attrs = await fetch_category_attributes(category_id, type_id) + + # 写入数据库 + await save_attributes_to_db(attrs) + + return attrs +``` + +### 字典值缓存(按需) + +```python +# 字典值可能很大(数万条),不全量缓存 +# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search) + +async def get_attribute_values(attr_id, category_id, type_id, keyword=None): + if keyword: + # 有关键词 → 搜索接口(limit 100) + return await search_values(attr_id, category_id, type_id, keyword) + else: + # 无关键词 → 拉全量(分页,存数据库) + return await fetch_all_values(attr_id, category_id, type_id) +``` + +--- + +## 8. 前端交互设计 + +### 属性映射 UI(推荐) + +``` +┌─────────────────────────────────────────────────┐ +│ 类目属性映射 │ +├─────────────────────────────────────────────────┤ +│ 采集属性 → Ozon 属性 │ +├─────────────────────────────────────────────────┤ +│ ✅ Материал (材质) → [自动] Материал (8505) │ +│ 值:Нержавеющая сталь │ +├─────────────────────────────────────────────────┤ +│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │ +│ 值:Thermos → 字典值:[选择 ▼] │ +│ ├ Thermos ✅ │ +│ ├ Stanley │ +│ └ ... │ +├─────────────────────────────────────────────────┤ +│ ⚠️ 未匹配:包装重量 │ +│ → [手动选择属性 ▼] │ +├─────────────────────────────────────────────────┤ +│ ❌ 缺少必填属性: │ +│ - Объем (容量) [+添加] │ +│ - Цвет (颜色) [+添加] │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 9. 常见问题 + +### Q1: 属性太多怎么办? + +**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略: +- 必填属性置顶 + 高亮 +- 已匹配属性展开,未匹配折叠 +- 提供搜索/筛选 + +### Q2: 字典值有多大? + +**A**: +- 小字典(颜色/材质):几十到几百条 +- 大字典(品牌):**数万条**(如品牌字典 > 50,000) +- 策略:**优先用 `/values/search`**,避免拉全量 + +### Q3: 自由输入的属性怎么填? + +**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`: + +```json +{ + "id": 9048, + "values": [{ "value": "500" }] // 容量,数值类型 +} +``` + +### Q4: 如何处理多值属性? + +**A**: `is_collection=true` 时传数组: + +```json +{ + "id": 10096, + "values": [ + { "value": "家用" }, + { "value": "办公" } + ] +} +``` + +--- + +## 10. V2 项目集成 + +### API 层(已实现) + +```python +# server/api/categories.py + +@router.get("/categories/{category_id}/attributes") +async def get_attributes( + category_id: int, + type_id: int = Query(...), + shop_id: str = Query(...), + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + attrs = await ozon_client.get_category_attributes( + shop.client_id_dec, + shop.api_key_dec, + category_id, + type_id + ) + return {"result": attrs} + +@router.get("/categories/attribute/{attribute_id}/values") +async def get_attribute_values( + attribute_id: int, + category_id: int = Query(...), + type_id: int = Query(...), + q: str = Query(None), # 搜索关键词 + shop_id: str = Query(...), + db: AsyncSession = Depends(get_db) +): + shop = await get_shop(db, shop_id) + + if q and len(q) >= 2: + # 搜索接口 + values = await ozon_client.search_attribute_values( + shop.client_id_dec, shop.api_key_dec, + attribute_id, category_id, type_id, q + ) + else: + # 全量拉取(分页) + values = await ozon_client.get_attribute_values( + shop.client_id_dec, shop.api_key_dec, + attribute_id, category_id, type_id + ) + + return {"result": values} +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/AttributeMapper.tsx + +import { Form, Select, Input, Tag } from 'antd'; + +export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) { + const [attributes, setAttributes] = useState([]); + + useEffect(() => { + fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`) + .then(r => r.json()) + .then(data => setAttributes(data.result)); + }, [categoryId, typeId]); + + // 自动匹配 + const autoMatch = () => { + const matched = rawParams.map(p => { + const attr = attributes.find(a => + normalize(a.name) === normalize(p.key) + ); + return attr ? { ...p, attrId: attr.id, attr } : p; + }); + return matched; + }; + + return ( +
+ {autoMatch().map((item, i) => ( + + {item.attr?.dictionary_id > 0 ? ( + + )} + + ))} +
+ ); +} +``` + +--- + +## 相关文档 + +- [02-category-tree.md](./02-category-tree.md) —— 获取类目树 +- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性) +- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构 +- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略 diff --git a/docs/integrations/ozon-seller-api/04-product-import.md b/docs/integrations/ozon-seller-api/04-product-import.md new file mode 100644 index 0000000..080c923 --- /dev/null +++ b/docs/integrations/ozon-seller-api/04-product-import.md @@ -0,0 +1,616 @@ +# 商品导入(创建/更新)API + +> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3 + +--- + +## 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v3/product/import` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | +| 用途 | **创建或更新商品**(Ozon 核心接口) | +| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 | + +--- + +## 1. 请求体结构 + +### 完整示例 + +```json +{ + "items": [ + { + "offer_id": "MY-THERMOS-001", + "name": "Термокружка Thermos из нержавеющей стали 500 мл", + "description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.", + "description_category_id": 17033876, + "type_id": 97114, + "price": "2990", + "old_price": "3490", + "currency_code": "RUB", + "vat": "0", + "depth": 80, + "width": 80, + "height": 200, + "dimension_unit": "mm", + "weight": 320, + "weight_unit": "g", + "images": [ + "https://cdn.example.com/thermos-main-1.jpg", + "https://cdn.example.com/thermos-main-2.jpg" + ], + "primary_image": "", + "images360": [], + "color_image": "", + "barcode": "", + "attributes": [ + { + "complex_id": 0, + "id": 85, + "values": [ + { + "dictionary_value_id": 971082156, + "value": "Thermos" + } + ] + }, + { + "complex_id": 0, + "id": 8505, + "values": [ + { + "value": "Нержавеющая сталь" + } + ] + } + ], + "complex_attributes": [] + } + ] +} +``` + +--- + +## 2. 字段说明 + +### 基本信息 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 | +| **name** | string | ✅ 是 | 商品名称。最长 500 字符 | +| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 | +| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) | +| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) | + +### 价格 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) | +| old_price | string | 可选 | 划线价(原价),用于展示折扣 | +| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 | +| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` | + +### 尺寸与重量 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** | +| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** | +| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** | +| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` | +| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** | +| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` | + +⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。 + +### 图片 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 | +| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 | +| images360 | array | 可选 | 360° 图片数组 | +| color_image | string | 可选 | 营销色图(部分类目支持) | + +⚠️ **硬约束**: +- 图片 URL 必须是 **https 公网直链**(http 会被拒绝) +- 图片需可访问(Ozon 服务器会主动拉取) +- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+ + +### 属性 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) | +| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` | +| attributes[].id | integer | ✅ 是 | 属性 ID | +| attributes[].values | array | ✅ 是 | 属性值数组 | +| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) | +| values[].value | string | ✅ 是 | 属性值文本 | + +### 复杂属性 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) | + +### 其他 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| barcode | string | 可选 | 条形码 | +| pdf_list | array | 可选 | PDF 文件 URL 列表 | + +--- + +## 3. 响应 + +### 成功响应(200) + +```json +{ + "result": { + "task_id": 123456789 + } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| task_id | integer | **任务 ID**(用于轮询状态,见下节) | + +⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。 + +--- + +## 4. 轮询任务状态 + +### 接口信息 + +| 项 | 值 | +|---|---| +| 方法 | POST | +| 路径 | `/v1/product/import/info` | +| 鉴权 | 需要 `Client-Id` + `Api-Key` | + +### 请求 + +```json +{ + "task_id": 123456789 +} +``` + +### 响应 + +```json +{ + "result": { + "items": [ + { + "offer_id": "MY-THERMOS-001", + "product_id": 987654321, + "status": "imported", + "errors": [] + } + ] + } +} +``` + +### 状态值 + +| status | 含义 | 处理 | +|---|---|---| +| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` | +| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) | +| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) | +| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) | +| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 | + +### 错误结构 + +```json +{ + "offer_id": "MY-THERMOS-001", + "product_id": 0, + "status": "failed", + "errors": [ + { + "code": "INVALID_ATTRIBUTE", + "message": "Attribute 'Бренд' is required", + "field": "attributes" + } + ] +} +``` + +--- + +## 5. 轮询策略 + +### 推荐策略 + +```python +import asyncio + +async def wait_for_import(task_id: int, timeout=300): + """轮询导入状态,最多等待 5 分钟""" + start = time.time() + interval = 5 # 初始间隔 5s + + while time.time() - start < timeout: + resp = await ozon_client.get_import_info(task_id) + item = resp["result"]["items"][0] + status = item["status"] + + if status == "imported": + return {"success": True, "product_id": item["product_id"]} + + if status == "failed": + return {"success": False, "errors": item["errors"]} + + if status == "moderation": + interval = 30 # 审核阶段降低频率 + + await asyncio.sleep(interval) + + # 超时:不算失败,标记为"审核中"继续后台轮询 + return {"success": None, "status": "timeout"} +``` + +### 后台轮询(推荐) + +```python +# 用户提交发布后立即返回,后台协程轮询 +# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新) + +async def background_poll_task(task_id: int, product_id: str): + """后台协程,轮询直到完成或失败""" + result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时 + + # 更新数据库 + await db.execute( + update(Product) + .where(Product.id == product_id) + .values( + stage="published" if result["success"] else "failed", + ozon_product_id=result.get("product_id"), + published_at=datetime.utcnow() if result["success"] else None + ) + ) + + # 记录任务结果 + await db.execute( + update(PublishTask) + .where(PublishTask.ozon_task_id == task_id) + .values( + status=result.get("status"), + errors=result.get("errors"), + completed_at=datetime.utcnow() + ) + ) +``` + +--- + +## 6. 创建 vs 更新 + +### 创建新商品 + +```json +{ + "offer_id": "NEW-PRODUCT-001", // 全新 offer_id + // ... 其他字段 +} +``` + +- 如果 `offer_id` 不存在 → 创建新商品 +- 如果 `offer_id` 已存在 → 返回 409 冲突 + +### 更新已有商品 + +```json +{ + "offer_id": "EXISTING-001", // 已存在的 offer_id + // ... 要更新的字段(可部分更新) +} +``` + +或使用 `product_id`: + +```json +{ + "product_id": 987654321, // Ozon 商品 ID + // ... 要更新的字段 +} +``` + +⚠️ **注意**: +- 更新时,未传的字段**保持原值**(非清空) +- 图片数组传空 `[]` 会清空图片(需小心) +- 建议更新前先读取当前值(`/v3/product/info/list`) + +--- + +## 7. 批量导入 + +单次请求最多 **100 个 item**: + +```json +{ + "items": [ + { "offer_id": "PROD-001", /* ... */ }, + { "offer_id": "PROD-002", /* ... */ }, + // ... 最多 100 个 + ] +} +``` + +响应包含每个 item 的状态: + +```json +{ + "result": { + "items": [ + { "offer_id": "PROD-001", "status": "imported", "product_id": 111 }, + { "offer_id": "PROD-002", "status": "failed", "errors": [...] } + ] + } +} +``` + +--- + +## 8. 常见错误 + +### 错误码速查 + +| code | message | 原因 | 解决 | +|---|---|---|---| +| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 | +| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 | +| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 | +| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 | +| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 | +| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 | +| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 | + +### 典型错误示例 + +#### 错误 1:尺寸为 0 + +```json +{ + "errors": [ + { + "code": "DIMENSION_REQUIRED", + "message": "Dimensions must be greater than 0", + "field": "weight" + } + ] +} +``` + +**解决**:确保 `depth/width/height/weight` 都 > 0。 + +#### 错误 2:缺少必填属性 + +```json +{ + "errors": [ + { + "code": "INVALID_ATTRIBUTE", + "message": "Required attribute 'Бренд' (id=85) is missing", + "field": "attributes" + } + ] +} +``` + +**解决**:补充缺失的必填属性。 + +#### 错误 3:图片 URL 不可访问 + +```json +{ + "errors": [ + { + "code": "INVALID_IMAGE", + "message": "Image URL is not accessible: https://...", + "field": "images[0]" + } + ] +} +``` + +**解决**: +1. 检查 URL 是 https(非 http) +2. 检查 URL 公网可访问(Ozon 服务器需能拉取) +3. 检查图片格式(支持 jpg/png/webp) + +--- + +## 9. 发布后操作 + +### 设置库存(必须) + +商品导入成功后**不会自动上架**,需设置库存才能开售: + +```http +POST /v2/products/stocks +``` + +```json +{ + "stocks": [ + { + "product_id": 987654321, + "offer_id": "MY-THERMOS-001", + "stock": 100, + "warehouse_id": 12345678 + } + ] +} +``` + +⚠️ 不设置库存 → 商品在后台但不可购买。 + +### 查询商品详情 + +```http +POST /v3/product/info/list +``` + +```json +{ + "offer_id": ["MY-THERMOS-001"] +} +``` + +返回商品完整信息(含审核状态、图片、属性)。 + +--- + +## 10. V2 项目集成 + +### API 层(已实现) + +```python +# server/api/publish.py + +@router.post("/products/{product_id}/publish") +async def publish_product( + product_id: str, + shop_id: str = Body(...), + db: AsyncSession = Depends(get_db) +): + # 1. 取商品数据 + product = await get_product(db, product_id) + + # 2. 校验必填项 + validate_required_fields(product) + + # 3. 组装 ImportProductsV3 请求体 + item = build_import_item(product) + + # 4. 调用 Ozon API + shop = await get_shop(db, shop_id) + resp = await ozon_client.import_products( + shop.client_id_dec, + shop.api_key_dec, + [item] + ) + + task_id = resp["result"]["task_id"] + + # 5. 记录发布任务 + task = PublishTask( + product_id=product_id, + shop_id=shop_id, + ozon_task_id=task_id, + status="pending", + request_payload=item + ) + db.add(task) + await db.commit() + + # 6. 启动后台轮询 + asyncio.create_task(background_poll_task(task_id, product_id)) + + return {"task_id": task_id} + +def build_import_item(product: Product) -> dict: + """组装 ImportProductsV3 items[0]""" + return { + "offer_id": product.offer_id, + "name": product.name, + "description": product.description, + "description_category_id": product.description_category_id, + "type_id": product.type_id, + "price": str(product.price), + "old_price": str(product.old_price) if product.old_price else "", + "currency_code": product.currency_code, + "vat": product.vat, + "depth": product.depth, + "width": product.width, + "height": product.height, + "dimension_unit": product.dimension_unit, + "weight": product.weight, + "weight_unit": product.weight_unit, + "images": product.images, # 七牛 URL 数组 + "primary_image": product.primary_image or "", + "images360": product.images360 or [], + "color_image": product.color_image or "", + "barcode": product.barcode or "", + "attributes": product.attributes or [], + "complex_attributes": product.complex_attributes or [] + } +``` + +### 前端(待实现) + +```tsx +// studio/src/pages/product/components/PublishPanel.tsx + +import { Button, Select, message } from 'antd'; + +export function PublishPanel({ productId }) { + const [shops, setShops] = useState([]); + const [publishing, setPublishing] = useState(false); + + const handlePublish = async (shopId) => { + setPublishing(true); + try { + const resp = await fetch(`/api/products/${productId}/publish`, { + method: 'POST', + body: JSON.stringify({ shop_id: shopId }) + }); + const data = await resp.json(); + + message.success('发布任务已提交,轮询中...'); + + // 轮询状态(或 WebSocket 推送) + pollPublishStatus(data.task_id); + } catch (err) { + message.error(`发布失败: ${err.message}`); + } finally { + setPublishing(false); + } + }; + + return ( +
+