From 85140edf1c50119279b6b0f6249584da03d954c8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 20:46:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E8=BF=81=E7=A7=BB=20000021=20p?= =?UTF-8?q?ay=5Fpurchases=20=E5=8F=B0=E8=B4=A6=20+=20subscriptions.source?= =?UTF-8?q?=20=E6=89=A9=20pay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- .../internal/store/codes_lib_migrate_test.go | 6 ++++ server/internal/store/sqlite_migrate_test.go | 5 +-- .../mysql/000021_pay_purchases.down.sql | 3 ++ .../mysql/000021_pay_purchases.up.sql | 21 +++++++++++ .../sqlite/000021_pay_purchases.down.sql | 16 +++++++++ .../sqlite/000021_pay_purchases.up.sql | 35 +++++++++++++++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 server/migrations/mysql/000021_pay_purchases.down.sql create mode 100644 server/migrations/mysql/000021_pay_purchases.up.sql create mode 100644 server/migrations/sqlite/000021_pay_purchases.down.sql create mode 100644 server/migrations/sqlite/000021_pay_purchases.up.sql diff --git a/server/internal/store/codes_lib_migrate_test.go b/server/internal/store/codes_lib_migrate_test.go index 4476512..5fed542 100644 --- a/server/internal/store/codes_lib_migrate_test.go +++ b/server/internal/store/codes_lib_migrate_test.go @@ -104,6 +104,12 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { // the reviewer's repro hit: the lib's `codes` table collides with the // name 000020's down script renames legacy_codes back to. m := newSQLiteStepper(t, db) + // 000021 (pay_purchases/source-enum) now sits on top of 000020 and is + // unrelated to this collision — step it back down first so we land + // exactly on the 000020 boundary the test targets. + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000021 down: %v", err) + } if err := m.Steps(-1); err != nil { t.Fatalf("step 000020 down: %v (this is the reviewer-reported collision — "+ "000020 down must DROP the codes-lib tables before renaming legacy_* back)", err) diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 7efd837..01fae41 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 20 { - t.Errorf("version = %d, want 20", v) + if v != 21 { + t.Errorf("version = %d, want 21", v) } // 2. Core tables exist. @@ -39,6 +39,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) { "usage_daily", "audit_log", "providers", "nodes", "node_events", "directory_version", "provision_idempotency", "replacements", "admins", "connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly", + "pay_purchases", } { var name string err := db.QueryRow( diff --git a/server/migrations/mysql/000021_pay_purchases.down.sql b/server/migrations/mysql/000021_pay_purchases.down.sql new file mode 100644 index 0000000..a0a67d9 --- /dev/null +++ b/server/migrations/mysql/000021_pay_purchases.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS pay_purchases; +-- 注意:若已存在 source='pay' 的行,该 MODIFY 会失败——down 前需人工清理(标准 enum 收窄语义)。 +ALTER TABLE subscriptions MODIFY source ENUM('trial','code') NOT NULL; diff --git a/server/migrations/mysql/000021_pay_purchases.up.sql b/server/migrations/mysql/000021_pay_purchases.up.sql new file mode 100644 index 0000000..8eb7f4e --- /dev/null +++ b/server/migrations/mysql/000021_pay_purchases.up.sql @@ -0,0 +1,21 @@ +-- pay v2 接入:订阅来源扩 'pay' + 购买台账(biz_ref↔out_trade_no 映射 + webhook 幂等消费) +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; + +CREATE TABLE pay_purchases ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + biz_ref CHAR(36) NOT NULL, -- users.uuid 冗余(webhook 兜底定位) + sku VARCHAR(64) NOT NULL, -- pay products.biz_code + out_trade_no VARCHAR(64) NOT NULL UNIQUE, -- pay 订单号 = 幂等键 + method VARCHAR(32) NOT NULL, -- alipay | crypto(retry 换渠道时更新) + status ENUM('created','paid','canceled') NOT NULL DEFAULT 'created', + amount_minor BIGINT NOT NULL DEFAULT 0, -- webhook 回填,最小单位 + currency VARCHAR(16) NOT NULL DEFAULT '', + channel VARCHAR(32) NOT NULL DEFAULT '', + sub_id BIGINT UNSIGNED NULL, -- 开通/延长的 subscriptions.id + paid_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + INDEX idx_pay_user (user_id, created_at), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/migrations/sqlite/000021_pay_purchases.down.sql b/server/migrations/sqlite/000021_pay_purchases.down.sql new file mode 100644 index 0000000..a43dbdc --- /dev/null +++ b/server/migrations/sqlite/000021_pay_purchases.down.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS pay_purchases; +CREATE TABLE subscriptions_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_old (id, user_id, plan_id, expires_at, source, created_at) + SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_old RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); diff --git a/server/migrations/sqlite/000021_pay_purchases.up.sql b/server/migrations/sqlite/000021_pay_purchases.up.sql new file mode 100644 index 0000000..16aacc3 --- /dev/null +++ b/server/migrations/sqlite/000021_pay_purchases.up.sql @@ -0,0 +1,35 @@ +-- 订阅 source 扩 'pay':重建表(CHECK 约束不可 ALTER) +CREATE TABLE subscriptions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_new (id, user_id, plan_id, expires_at, source, created_at) + SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_new RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); + +CREATE TABLE pay_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + biz_ref TEXT NOT NULL, + sku TEXT NOT NULL, + out_trade_no TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created','paid','canceled')), + amount_minor INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL DEFAULT '', + sub_id INTEGER NULL, + paid_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX idx_pay_user ON pay_purchases (user_id, created_at);