diff --git a/server/internal/store/codes_lib_migrate_test.go b/server/internal/store/codes_lib_migrate_test.go index 5fed542..214082b 100644 --- a/server/internal/store/codes_lib_migrate_test.go +++ b/server/internal/store/codes_lib_migrate_test.go @@ -15,7 +15,7 @@ import ( ) // TestApplyCodesLibMigrations verifies the shared codes library's tables are -// created on top of a fully-migrated pangolin sqlite DB (post-000020, the +// created on top of a fully-migrated pangolin sqlite DB (post-000022, the // legacy tables are renamed away so the lib's `codes` table name is free). func TestApplyCodesLibMigrations(t *testing.T) { ctx := context.Background() @@ -49,7 +49,7 @@ func TestApplyCodesLibMigrations(t *testing.T) { // newSQLiteStepper builds a golang-migrate instance identical to the one // internal/store.MigrateUp/MigrateDown use internally (same embedded FS, // same sqlite driver), except it exposes Steps() so the test can land -// exactly on the 000020 boundary instead of only being able to invoke a +// exactly on the 000022 boundary instead of only being able to invoke a // full up-to-latest / down-to-zero round trip like the public API does. // Mirrors internal/store/migrate.go's newMigrator — kept in the test // because that constructor is unexported. @@ -71,18 +71,18 @@ func newSQLiteStepper(t *testing.T, database *sql.DB) *migrate.Migrate { } // TestCodesLibMigrateRoundTrip exercises the real wired path end-to-end: -// MigrateUp -> ApplyCodesLibMigrations -> step 000020 back down -> assert +// MigrateUp -> ApplyCodesLibMigrations -> step 000022 back down -> assert // legacy restored/lib gone -> step back up -> ApplyCodesLibMigrations again // -> finally the full `store.MigrateDown` (== cmd/migrate's "down" command) // all the way to version 0, which is the exact call the reviewer reported // as failing hard. // -// Reproduces the reviewer-found collision: 000020's down does +// Reproduces the reviewer-found collision: 000022's down does // legacy_codes -> codes renames, but the codes-lib's own `codes` table // (created by ApplyCodesLibMigrations, untracked by golang-migrate) is // still sitting there, so the rename used to fail with "table already // exists" (sqlite) / "ALTER TABLE ... table already exists" (mysql -// RENAME TABLE semantics). 000020's down script must DROP the lib-owned +// RENAME TABLE semantics). 000022's down script must DROP the lib-owned // tables before renaming legacy_* back. func TestCodesLibMigrateRoundTrip(t *testing.T) { ctx := context.Background() @@ -100,19 +100,23 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { t.Fatalf("ApplyCodesLibMigrations: %v", err) } - // 2. Step exactly 000020 back down (19 <- 20). This is the precise seam + // 2. Step exactly 000022 back down (21 <- 22). This is the precise seam // the reviewer's repro hit: the lib's `codes` table collides with the - // name 000020's down script renames legacy_codes back to. + // name 000022'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. + // 000024 (invite_rewards) and 000023 (pay_purchases/source-enum) now sit + // on top of 000022 (codes_lib_legacy_rename) and are unrelated to this + // collision — step them back down first so we land exactly on the + // 000022 boundary the test targets. if err := m.Steps(-1); err != nil { - t.Fatalf("step 000021 down: %v", err) + t.Fatalf("step 000024 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) + t.Fatalf("step 000023 down: %v", err) + } + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000022 down: %v (this is the reviewer-reported collision — "+ + "000022 down must DROP the codes-lib tables before renaming legacy_* back)", err) } // 3. Legacy tables restored to their original pangolin names/shape; @@ -136,16 +140,16 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl, ).Scan(&name) if err == nil { - t.Errorf("lib table %q should have been dropped by the 000020 down step, still present", tbl) + t.Errorf("lib table %q should have been dropped by the 000022 down step, still present", tbl) } else if err != sql.ErrNoRows { t.Errorf("checking lib table %q gone: %v", tbl, err) } } - // 4. Step 000020 back up + reapply the lib migrations: idempotent, must + // 4. Step 000022 back up + reapply the lib migrations: idempotent, must // stay green. if err := m.Steps(1); err != nil { - t.Fatalf("step 000020 up (2nd round-trip): %v", err) + t.Fatalf("step 000022 up (2nd round-trip): %v", err) } if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil { t.Fatalf("ApplyCodesLibMigrations (2nd round-trip): %v", err) diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 5e9ee09..7f37920 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 != 23 { - t.Errorf("version = %d, want 23", v) + if v != 24 { + t.Errorf("version = %d, want 24", v) } // 2. Core tables exist. @@ -39,7 +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", + "pay_purchases", "referrals", "reward_claims", } { var name string err := db.QueryRow( diff --git a/server/migrations/mysql/000024_invite_rewards.down.sql b/server/migrations/mysql/000024_invite_rewards.down.sql new file mode 100644 index 0000000..3911751 --- /dev/null +++ b/server/migrations/mysql/000024_invite_rewards.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; +DROP TABLE reward_claims; +DROP TABLE referrals; +ALTER TABLE users + DROP INDEX ux_users_invite_code, + DROP COLUMN first_paid_at, + DROP COLUMN invite_code; diff --git a/server/migrations/mysql/000024_invite_rewards.up.sql b/server/migrations/mysql/000024_invite_rewards.up.sql new file mode 100644 index 0000000..7062aa3 --- /dev/null +++ b/server/migrations/mysql/000024_invite_rewards.up.sql @@ -0,0 +1,32 @@ +ALTER TABLE users + ADD COLUMN invite_code VARCHAR(16) NULL, + ADD COLUMN first_paid_at DATETIME(6) NULL, + ADD UNIQUE KEY ux_users_invite_code (invite_code); + +CREATE TABLE referrals ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + inviter_id BIGINT UNSIGNED NOT NULL, + invitee_id BIGINT UNSIGNED NOT NULL UNIQUE, + device_uuid VARCHAR(64) NULL, + status ENUM('bound','reg_rewarded','paid_rewarded','rejected') NOT NULL DEFAULT 'bound', + reg_rewarded_at DATETIME(6) NULL, + paid_rewarded_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + INDEX ix_referrals_inviter (inviter_id, created_at), + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE reward_claims ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + task_key VARCHAR(32) NOT NULL, + external_ref VARCHAR(64) NOT NULL DEFAULT '', + granted_days INT NOT NULL, + granted_at DATETIME(6) NOT NULL, + UNIQUE KEY ux_claim_user_task (user_id, task_key), + UNIQUE KEY ux_claim_task_ref (task_key, external_ref), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay','invite','task') NOT NULL; diff --git a/server/migrations/sqlite/000024_invite_rewards.down.sql b/server/migrations/sqlite/000024_invite_rewards.down.sql new file mode 100644 index 0000000..bc06792 --- /dev/null +++ b/server/migrations/sqlite/000024_invite_rewards.down.sql @@ -0,0 +1,21 @@ +-- 还原 subscriptions.source CHECK +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','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_old SELECT id,user_id,plan_id,expires_at,source,created_at + FROM subscriptions WHERE source IN ('trial','code','pay'); +DROP TABLE subscriptions; +ALTER TABLE subscriptions_old RENAME TO subscriptions; + +DROP TABLE reward_claims; +DROP TABLE referrals; +DROP INDEX ux_users_invite_code; +ALTER TABLE users DROP COLUMN first_paid_at; +ALTER TABLE users DROP COLUMN invite_code; diff --git a/server/migrations/sqlite/000024_invite_rewards.up.sql b/server/migrations/sqlite/000024_invite_rewards.up.sql new file mode 100644 index 0000000..3088246 --- /dev/null +++ b/server/migrations/sqlite/000024_invite_rewards.up.sql @@ -0,0 +1,49 @@ +-- users 加两列 +ALTER TABLE users ADD COLUMN invite_code TEXT; +ALTER TABLE users ADD COLUMN first_paid_at DATETIME; +CREATE UNIQUE INDEX ux_users_invite_code ON users(invite_code); + +-- 邀请关系(一对一绑定) +CREATE TABLE referrals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL UNIQUE, + device_uuid TEXT, + status TEXT NOT NULL DEFAULT 'bound' + CHECK (status IN ('bound','reg_rewarded','paid_rewarded','rejected')), + reg_rewarded_at DATETIME, + paid_rewarded_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +); +CREATE INDEX ix_referrals_inviter ON referrals(inviter_id, created_at); + +-- 通用一次性任务领取 +CREATE TABLE reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_key TEXT NOT NULL, + external_ref TEXT NOT NULL DEFAULT '', + granted_days INTEGER NOT NULL, + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE UNIQUE INDEX ux_claim_user_task ON reward_claims(user_id, task_key); +CREATE UNIQUE INDEX ux_claim_task_ref ON reward_claims(task_key, external_ref); + +-- subscriptions.source 扩容(SQLite 需重建表) +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','invite','task')), + 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;