feat(media-supply): add media resource supply marketplace
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s

Introduce an end-to-end media-supply feature: tenant-side resource sync
service/worker backed by a Meijiequan supplier client, ops-side management
APIs, and admin/ops web views for resources, orders, favorites and
submission. Adds a shared digitocr helper, MediaSupply config blocks for
tenant and ops, shared types, and migrations for supplier media resources,
price overrides, customer visibility and order refunds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 23:17:01 +08:00
parent 607a3fffe7
commit 9d6181260a
121 changed files with 14909 additions and 36 deletions
@@ -0,0 +1,7 @@
DROP TABLE IF EXISTS media_supply_sync_jobs;
DROP TABLE IF EXISTS media_supply_wallet_ledgers;
DROP TABLE IF EXISTS media_supply_order_items;
DROP TABLE IF EXISTS media_supply_orders;
DROP TABLE IF EXISTS media_supply_user_wallets;
DROP TABLE IF EXISTS supplier_media_price_overrides;
DROP TABLE IF EXISTS supplier_media_resources;
@@ -0,0 +1,173 @@
CREATE TABLE IF NOT EXISTS supplier_media_resources (
id BIGSERIAL PRIMARY KEY,
supplier VARCHAR(32) NOT NULL,
supplier_resource_id VARCHAR(64) NOT NULL,
model_id INT NOT NULL,
name VARCHAR(500) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
cost_price_cents BIGINT NOT NULL DEFAULT 0,
cost_prices_json JSONB NOT NULL DEFAULT '{}'::jsonb,
sale_price_label VARCHAR(64),
resource_url TEXT,
baidu_weight INT,
resource_remark TEXT,
customer_visible BOOLEAN NOT NULL DEFAULT TRUE,
channel_type VARCHAR(128),
region VARCHAR(128),
inclusion_effect VARCHAR(128),
link_type VARCHAR(128),
publish_rate VARCHAR(64),
delivery_speed VARCHAR(128),
supplier_updated_at TIMESTAMPTZ,
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_supplier_media_resources_supplier_id UNIQUE (supplier, supplier_resource_id)
);
CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_model_status
ON supplier_media_resources (supplier, model_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_name_trgm_fallback
ON supplier_media_resources (supplier, name);
CREATE TABLE IF NOT EXISTS supplier_media_price_overrides (
id BIGSERIAL PRIMARY KEY,
resource_id BIGINT NOT NULL REFERENCES supplier_media_resources(id) ON DELETE CASCADE,
price_type VARCHAR(64) NOT NULL DEFAULT 'price',
sell_price_cents BIGINT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_supplier_media_price_override_non_negative CHECK (sell_price_cents >= 0),
CONSTRAINT uk_supplier_media_price_overrides_resource_type UNIQUE (resource_id, price_type)
);
CREATE INDEX IF NOT EXISTS idx_supplier_media_price_overrides_resource
ON supplier_media_price_overrides (resource_id, enabled);
CREATE TABLE IF NOT EXISTS media_supply_user_wallets (
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
balance_cents BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, user_id),
CONSTRAINT chk_media_supply_user_wallet_balance_non_negative CHECK (balance_cents >= 0)
);
CREATE TABLE IF NOT EXISTS media_supply_orders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
user_id BIGINT NOT NULL REFERENCES users(id),
article_id BIGINT REFERENCES articles(id),
supplier VARCHAR(32) NOT NULL,
model_id INT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
title VARCHAR(500) NOT NULL,
content_snapshot TEXT NOT NULL,
remark TEXT,
order_brand VARCHAR(255),
external_order_id VARCHAR(128),
external_order_code VARCHAR(128),
supplier_status VARCHAR(64),
cost_total_cents BIGINT NOT NULL DEFAULT 0,
sell_total_cents BIGINT NOT NULL DEFAULT 0,
wallet_debit_cents BIGINT NOT NULL DEFAULT 0,
wallet_debited_at TIMESTAMPTZ,
wallet_refunded_at TIMESTAMPTZ,
request_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
response_payload_json JSONB,
error_message TEXT,
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT chk_media_supply_order_totals_non_negative CHECK (cost_total_cents >= 0 AND sell_total_cents >= 0),
CONSTRAINT chk_media_supply_order_no_loss CHECK (sell_total_cents >= cost_total_cents),
CONSTRAINT chk_media_supply_order_wallet_debit_non_negative CHECK (wallet_debit_cents >= 0)
);
CREATE INDEX IF NOT EXISTS idx_media_supply_orders_user_created
ON media_supply_orders (tenant_id, user_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_media_supply_orders_status_next
ON media_supply_orders (supplier, status, next_attempt_at, created_at)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_media_supply_orders_article
ON media_supply_orders (tenant_id, article_id, created_at DESC)
WHERE article_id IS NOT NULL AND deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS media_supply_order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES media_supply_orders(id) ON DELETE CASCADE,
resource_id BIGINT NOT NULL REFERENCES supplier_media_resources(id),
supplier_resource_id VARCHAR(64) NOT NULL,
price_type VARCHAR(64) NOT NULL DEFAULT 'price',
resource_name_snapshot VARCHAR(500) NOT NULL,
locked_cost_price_cents BIGINT NOT NULL,
locked_sell_price_cents BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
external_article_url TEXT,
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_media_supply_order_item_prices_non_negative CHECK (locked_cost_price_cents >= 0 AND locked_sell_price_cents >= 0),
CONSTRAINT chk_media_supply_order_item_no_loss CHECK (locked_sell_price_cents >= locked_cost_price_cents)
);
CREATE INDEX IF NOT EXISTS idx_media_supply_order_items_order
ON media_supply_order_items (order_id, id);
CREATE TABLE IF NOT EXISTS media_supply_wallet_ledgers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
order_id BIGINT REFERENCES media_supply_orders(id) ON DELETE SET NULL,
delta_cents BIGINT NOT NULL,
balance_after_cents BIGINT NOT NULL,
reason VARCHAR(128) NOT NULL,
note TEXT,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_media_supply_wallet_ledger_balance_non_negative CHECK (balance_after_cents >= 0)
);
CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_user_created
ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_user_recent
ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_order
ON media_supply_wallet_ledgers (order_id)
WHERE order_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS media_supply_sync_jobs (
id BIGSERIAL PRIMARY KEY,
supplier VARCHAR(32) NOT NULL,
model_id INT,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
requested_by BIGINT REFERENCES users(id),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error_message TEXT,
attempt_count INT NOT NULL DEFAULT 0,
stats_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_media_supply_sync_jobs_status_created
ON media_supply_sync_jobs (supplier, status, created_at);
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_supplier_media_resources_remark;
ALTER TABLE supplier_media_resources
DROP COLUMN IF EXISTS resource_remark,
DROP COLUMN IF EXISTS baidu_weight,
DROP COLUMN IF EXISTS resource_url;
@@ -0,0 +1,17 @@
ALTER TABLE supplier_media_resources
ADD COLUMN IF NOT EXISTS resource_url TEXT,
ADD COLUMN IF NOT EXISTS baidu_weight INT,
ADD COLUMN IF NOT EXISTS resource_remark TEXT;
UPDATE supplier_media_resources
SET resource_url = NULLIF(BTRIM(COALESCE(raw_json->>'meitianli', raw_json->>'resource_url', raw_json->>'url', raw_json->>'site_url', raw_json->>'link')), ''),
baidu_weight = CASE
WHEN BTRIM(COALESCE(raw_json->>'meitiquanzhong', raw_json->>'baidu_weight', raw_json->>'weight', raw_json->>'quanzhong')) ~ '^[0-9]+$'
THEN BTRIM(COALESCE(raw_json->>'meitiquanzhong', raw_json->>'baidu_weight', raw_json->>'weight', raw_json->>'quanzhong'))::INT
ELSE NULL
END,
resource_remark = NULLIF(BTRIM(COALESCE(raw_json->>'meitibeizhu', raw_json->>'remark', raw_json->>'beizhu', raw_json->>'note', raw_json->>'description')), '')
WHERE resource_url IS NULL OR baidu_weight IS NULL OR resource_remark IS NULL;
CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_remark
ON supplier_media_resources (supplier, resource_remark);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_supplier_media_resources_customer_visible;
ALTER TABLE supplier_media_resources
DROP COLUMN IF EXISTS customer_visible;
@@ -0,0 +1,6 @@
ALTER TABLE supplier_media_resources
ADD COLUMN IF NOT EXISTS customer_visible BOOLEAN NOT NULL DEFAULT TRUE;
CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_customer_visible
ON supplier_media_resources (supplier, model_id, customer_visible, updated_at DESC)
WHERE deleted_at IS NULL;
@@ -0,0 +1 @@
-- Irreversible data repair: refunded terminal media supply orders should not be re-debited.
@@ -0,0 +1,88 @@
WITH terminal_orders AS (
SELECT id,
tenant_id,
user_id,
wallet_debit_cents,
title,
error_message
FROM media_supply_orders
WHERE supplier = 'meijiequan'
AND status IN ('queued', 'submitting')
AND wallet_debited_at IS NOT NULL
AND wallet_refunded_at IS NULL
AND wallet_debit_cents > 0
AND deleted_at IS NULL
AND (
error_message ILIKE 'failed to refresh supplier price%'
OR error_message ILIKE 'meijiequan create order failed:%'
OR error_message ILIKE 'supplier cost increased above locked sell price%'
)
),
wallet_snapshot AS (
SELECT o.*,
w.balance_cents AS balance_before,
SUM(o.wallet_debit_cents) OVER (
PARTITION BY o.tenant_id, o.user_id
ORDER BY o.id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_refund
FROM terminal_orders o
JOIN media_supply_user_wallets w
ON w.tenant_id = o.tenant_id
AND w.user_id = o.user_id
),
refund_totals AS (
SELECT tenant_id,
user_id,
SUM(wallet_debit_cents) AS total_refund
FROM terminal_orders
GROUP BY tenant_id, user_id
),
updated_wallets AS (
UPDATE media_supply_user_wallets w
SET balance_cents = w.balance_cents + rt.total_refund,
updated_at = NOW()
FROM refund_totals rt
WHERE w.tenant_id = rt.tenant_id
AND w.user_id = rt.user_id
RETURNING w.tenant_id, w.user_id
),
updated_orders AS (
UPDATE media_supply_orders o
SET status = 'failed',
wallet_refunded_at = NOW(),
next_attempt_at = NOW(),
updated_at = NOW()
FROM terminal_orders t
WHERE o.id = t.id
RETURNING o.id
),
updated_items AS (
UPDATE media_supply_order_items i
SET status = 'failed',
updated_at = NOW()
FROM terminal_orders t
WHERE i.order_id = t.id
AND i.status IN ('queued', 'submitting')
RETURNING i.id
)
INSERT INTO media_supply_wallet_ledgers (
tenant_id,
user_id,
order_id,
delta_cents,
balance_after_cents,
reason,
note,
created_by
)
SELECT ws.tenant_id,
ws.user_id,
ws.id,
ws.wallet_debit_cents,
ws.balance_before + ws.running_refund,
'order_refund',
COALESCE(NULLIF(ws.error_message, ''), ws.title),
ws.user_id
FROM wallet_snapshot ws
JOIN updated_orders uo ON uo.id = ws.id;