feat: 方案设计、数据库和接口设计基本完成
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user