feat: 方案设计、数据库和接口设计基本完成

This commit is contained in:
R524809
2026-09-02 14:47:18 +08:00
parent a3fe066a5f
commit 04c94e75db
30 changed files with 3305 additions and 122 deletions
+156 -4
View File
@@ -19,14 +19,21 @@ CREATE TYPE rule_source AS ENUM ('manual_rule', 'competitor_capture', 'platform_
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 TYPE exchange_rate_source AS ENUM ('cbr', 'exchange_rate_api_open', 'frankfurter', 'manual');
CREATE TYPE exchange_rate_kind AS ENUM ('official_reference', 'market_reference', 'manual');
CREATE TYPE fx_rate_origin AS ENUM ('system_rate', 'policy_fixed', 'quote_override');
CREATE TYPE operation_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE media_upload_status AS ENUM ('pending', 'uploaded', 'validating', 'ready', 'failed', 'expired');
CREATE TYPE idempotency_request_status AS ENUM ('in_progress', 'completed');
CREATE TYPE outbox_event_status AS ENUM ('pending', 'dispatching', 'dispatched', 'dead_letter');
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}$'),
email CITEXT NOT NULL UNIQUE CHECK (length(email) <= 254 AND position('@' IN email) > 1),
password_hash TEXT NOT NULL,
display_name TEXT,
status account_status NOT NULL DEFAULT 'active',
phone_verified_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -87,6 +94,26 @@ CREATE TABLE platform_currency_support (
PRIMARY KEY (platform_id, currency_code)
);
-- Global immutable rate history. rate always means one unit of base_currency_code equals rate units of quote_currency_code.
CREATE TABLE exchange_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
base_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
quote_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
rate NUMERIC(24,10) NOT NULL CHECK (rate > 0),
kind exchange_rate_kind NOT NULL,
source exchange_rate_source NOT NULL,
source_reference TEXT,
effective_on DATE NOT NULL,
observed_at TIMESTAMPTZ,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (base_currency_code <> quote_currency_code),
UNIQUE (base_currency_code, quote_currency_code, kind, source, effective_on)
);
CREATE INDEX ix_exchange_rates_current_lookup
ON exchange_rates (base_currency_code, quote_currency_code, kind, effective_on DESC, fetched_at DESC);
CREATE TABLE stores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
@@ -99,12 +126,16 @@ CREATE TABLE stores (
status store_status NOT NULL DEFAULT 'verifying',
credential_status credential_status NOT NULL DEFAULT 'unknown',
credential_checked_at TIMESTAMPTZ,
last_error_code TEXT,
last_error_message TEXT,
last_error_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(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, id, platform_id),
UNIQUE (workspace_id, platform_id, platform_client_id)
@@ -153,9 +184,94 @@ CREATE TABLE store_warehouses (
UNIQUE (workspace_id, store_id, platform_warehouse_id)
);
-- Durable API-visible operation state. Queue job ids are auxiliary and never the only record.
CREATE TABLE operations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
kind TEXT NOT NULL,
status operation_status NOT NULL DEFAULT 'queued',
resource_type TEXT,
resource_id UUID,
result JSONB NOT NULL DEFAULT '{}'::jsonb,
error_code TEXT,
error_message TEXT,
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CHECK (completed_at IS NULL OR completed_at >= created_at),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_operations_workspace_status ON operations (workspace_id, status, created_at DESC);
-- Durable replay record for authenticated, retryable business writes.
-- request_fingerprint is an HMAC of canonical request content, never a raw request body.
CREATE TABLE idempotency_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
http_method TEXT NOT NULL CHECK (http_method IN ('POST', 'PUT', 'PATCH', 'DELETE')),
request_path TEXT NOT NULL CHECK (request_path ~ '^/api/'),
idempotency_key UUID NOT NULL,
request_fingerprint TEXT NOT NULL,
status idempotency_request_status NOT NULL DEFAULT 'in_progress',
response_status SMALLINT,
response_body JSONB,
resource_type TEXT,
resource_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '48 hours',
CHECK (expires_at > created_at),
CHECK (
(status = 'in_progress' AND completed_at IS NULL AND response_status IS NULL)
OR (status = 'completed' AND completed_at IS NOT NULL AND response_status BETWEEN 200 AND 599)
),
UNIQUE (workspace_id, user_id, http_method, request_path, idempotency_key),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_idempotency_requests_expiry
ON idempotency_requests (expires_at);
-- Transactional Outbox. A dispatcher makes these events available to BullMQ only after their business transaction commits.
-- event_payload contains identifiers and immutable snapshots only; it must not contain credentials or access URLs.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
deduplication_key TEXT,
status outbox_event_status NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by TEXT,
locked_until TIMESTAMPTZ,
dispatched_at TIMESTAMPTZ,
last_error_code TEXT,
last_error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(status = 'dispatching' AND locked_by IS NOT NULL AND locked_until IS NOT NULL)
OR (status <> 'dispatching' AND locked_by IS NULL AND locked_until IS NULL)
),
CHECK (dispatched_at IS NULL OR status = 'dispatched'),
UNIQUE (workspace_id, id)
);
CREATE INDEX ix_outbox_events_dispatch
ON outbox_events (workspace_id, status, next_attempt_at, created_at)
WHERE status IN ('pending', 'dispatching');
CREATE INDEX ix_outbox_events_aggregate
ON outbox_events (workspace_id, aggregate_type, aggregate_id, created_at DESC);
CREATE TABLE media_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
storage_provider TEXT NOT NULL, -- local_minio / qiniu_kodo; provider configuration is server-side only.
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size > 0),
@@ -164,10 +280,38 @@ CREATE TABLE media_assets (
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, storage_provider, bucket, object_key),
UNIQUE (workspace_id, checksum_sha256)
);
-- A short-lived, durable record for browser direct uploads. object_key is never returned as public file identity.
CREATE TABLE media_upload_intents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
purpose TEXT NOT NULL,
storage_provider TEXT NOT NULL,
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
expected_content_type TEXT NOT NULL,
expected_byte_size BIGINT NOT NULL CHECK (expected_byte_size > 0),
expected_checksum_sha256 TEXT NOT NULL,
status media_upload_status NOT NULL DEFAULT 'pending',
asset_id UUID,
error_code TEXT,
error_message TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
CHECK (expires_at > created_at),
CHECK (completed_at IS NULL OR completed_at >= created_at),
CHECK ((status = 'ready') = (asset_id IS NOT NULL)),
UNIQUE (workspace_id, id),
UNIQUE (storage_provider, bucket, object_key)
);
CREATE INDEX ix_media_upload_intents_expiry ON media_upload_intents (status, expires_at);
CREATE TABLE watermark_fonts (
code CITEXT PRIMARY KEY,
display_name TEXT NOT NULL,
@@ -185,6 +329,7 @@ CREATE TABLE watermark_templates (
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, name)
);
@@ -206,6 +351,7 @@ CREATE TABLE watermark_template_versions (
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, id),
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)
@@ -415,10 +561,12 @@ CREATE TABLE pricing_policies (
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),
allow_commission_override BOOLEAN NOT NULL DEFAULT false,
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(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
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),
@@ -455,6 +603,8 @@ CREATE TABLE pricing_quotes (
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),
fx_rate_origin fx_rate_origin NOT NULL DEFAULT 'system_rate',
exchange_rate_id UUID REFERENCES exchange_rates(id) ON DELETE SET NULL,
input_snapshot JSONB NOT NULL,
status quote_status NOT NULL DEFAULT 'draft',
selected_option_id UUID,
@@ -512,12 +662,14 @@ DO $$
DECLARE table_name TEXT;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'stores', 'store_secrets', 'store_warehouses', 'media_assets',
'stores', 'store_secrets', 'store_warehouses', 'operations', 'idempotency_requests', 'outbox_events',
'media_assets', 'media_upload_intents',
'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('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
EXECUTE format(
'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())',
table_name