feat: 初始化项目,项目需求、方案文档

This commit is contained in:
R524809
2026-09-01 17:35:48 +08:00
commit 5b77ba72b5
23 changed files with 5173 additions and 0 deletions
@@ -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 = '<uuid>'`;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 与导入程序。
+526
View File
@@ -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 $$;
@@ -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。