feat(payment): add complete payment system with multi-provider support

Add a full payment and subscription system supporting EasyPay (Alipay/WeChat),
Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
erio
2026-04-10 21:08:51 +08:00
parent 00c08c574e
commit 63d1860dc0
166 changed files with 42743 additions and 220 deletions

View File

@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
user_email VARCHAR(255) NOT NULL DEFAULT '',
user_name VARCHAR(100) NOT NULL DEFAULT '',
user_notes TEXT,
amount DECIMAL(20,2) NOT NULL,
pay_amount DECIMAL(20,2) NOT NULL,
fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0,
recharge_code VARCHAR(64) NOT NULL DEFAULT '',
payment_type VARCHAR(30) NOT NULL DEFAULT '',
payment_trade_no VARCHAR(128) NOT NULL DEFAULT '',
pay_url TEXT,
qr_code TEXT,
qr_code_img TEXT,
order_type VARCHAR(20) NOT NULL DEFAULT 'balance',
plan_id BIGINT,
subscription_group_id BIGINT,
subscription_days INT,
provider_instance_id VARCHAR(64),
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0,
refund_reason TEXT,
refund_at TIMESTAMPTZ,
force_refund BOOLEAN NOT NULL DEFAULT FALSE,
refund_requested_at TIMESTAMPTZ,
refund_request_reason TEXT,
refund_requested_by VARCHAR(20),
expires_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failed_reason TEXT,
client_ip VARCHAR(50) NOT NULL DEFAULT '',
src_host VARCHAR(255) NOT NULL DEFAULT '',
src_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type);

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS payment_audit_logs (
id BIGSERIAL PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
action VARCHAR(50) NOT NULL,
detail TEXT NOT NULL DEFAULT '',
operator VARCHAR(100) NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id);

View File

@@ -0,0 +1,4 @@
-- Migration 092: payment_channels table was removed before release.
-- This file is a no-op placeholder to maintain migration numbering continuity.
-- The payment system now uses the existing channels table (migration 081).
SELECT 1;

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS subscription_plans (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL DEFAULT '',
price DECIMAL(20,2) NOT NULL,
original_price DECIMAL(20,2),
validity_days INT NOT NULL DEFAULT 30,
validity_unit VARCHAR(10) NOT NULL DEFAULT 'day',
features TEXT NOT NULL DEFAULT '',
product_name VARCHAR(100) NOT NULL DEFAULT '',
for_sale BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS payment_provider_instances (
id BIGSERIAL PRIMARY KEY,
provider_key VARCHAR(30) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
config TEXT NOT NULL,
supported_types VARCHAR(200) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
limits TEXT NOT NULL DEFAULT '',
refund_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled);

View File

@@ -0,0 +1,70 @@
-- 096_migrate_purchase_subscription_to_custom_menu.sql
--
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
-- After migration, purchase_subscription_enabled is set to "false" and
-- purchase_subscription_url is cleared.
--
-- Idempotent: skips if custom_menu_items already contains
-- "migrated_purchase_subscription".
DO $$
DECLARE
v_enabled text;
v_url text;
v_raw text;
v_items jsonb;
v_new_item jsonb;
BEGIN
-- Read legacy settings
SELECT value INTO v_enabled
FROM settings WHERE key = 'purchase_subscription_enabled';
SELECT value INTO v_url
FROM settings WHERE key = 'purchase_subscription_url';
-- Skip if not enabled or URL is empty
IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN
RETURN;
END IF;
-- Read current custom_menu_items
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
v_items := '[]'::jsonb;
ELSE
v_items := v_raw::jsonb;
END IF;
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(v_items) elem
WHERE elem ->> 'id' = 'migrated_purchase_subscription'
) THEN
RETURN;
END IF;
-- Build the new menu item
v_new_item := jsonb_build_object(
'id', 'migrated_purchase_subscription',
'label', 'Purchase',
'icon_svg', '',
'url', TRIM(v_url),
'visibility', 'user',
'sort_order', 100
);
-- Append to array
v_items := v_items || jsonb_build_array(v_new_item);
-- Upsert custom_menu_items
INSERT INTO settings (key, value)
VALUES ('custom_menu_items', v_items::text)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- Clear legacy settings
UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled';
UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url';
RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url;
END $$;

View File

@@ -0,0 +1,17 @@
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE settings
SET value = array_to_string(
array_remove(
string_to_array(value, ','),
'easypay'
), ','
)
WHERE key = 'ENABLED_PAYMENT_TYPES'
AND value LIKE '%easypay%';

View File

@@ -0,0 +1,16 @@
-- Add payment_mode field to payment_provider_instances
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT '';
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
UPDATE payment_provider_instances
SET payment_mode = 'redirect',
supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(
supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', ''))
WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%';
-- EasyPay instances without 'easypay' in supported_types → api mode
UPDATE payment_provider_instances
SET payment_mode = 'api'
WHERE provider_key = 'easypay' AND payment_mode = '';

View File

@@ -0,0 +1,6 @@
-- 100_add_out_trade_no_to_payment_orders.sql
-- Adds out_trade_no column for external order ID used with payment providers.
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no);