feat(server/db): 数据层多库支持(1/4)— 连接分派 + 双方言迁移管线

- config 增 DB_DRIVER(mysql 默认 | sqlite);DSN 对 sqlite 为文件路径
- db.OpenDriver 按驱动分派:sqlite 用 modernc(纯 Go 免 CGO)+ WAL/
  busy_timeout/foreign_keys/_txlock=immediate;mysql 路径不变
- store.Open 分派;mysql 保留 UTC/collation 断言,sqlite 跳过
- 迁移拆 migrations/{mysql,sqlite}/ 双套,embed 双 FS,migrate 按驱动选源
  与 golang-migrate 驱动;修复 m.Close() 误关调用方 *sql.DB 的坑
- cmd/migrate 串入 DB_DRIVER;集成测试 MigrateUp 签名更新
- 新增 SQLite 时间往返 smoke 测试与端到端迁移测试(免 docker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-18 00:01:03 +08:00
parent 9a028ab907
commit f3471ae139
63 changed files with 583 additions and 82 deletions
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS devices;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,22 @@
-- 账户与设备(SQLite 方言)
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL, -- argon2id
dp_uuid TEXT NOT NULL, -- 数据面凭证 UUID(可轮换)
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','banned')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios','android','windows','macos')),
last_seen DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_devices_user ON devices (user_id);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS subscriptions;
DROP TABLE IF EXISTS plans;
@@ -0,0 +1,20 @@
-- 套餐与订阅
CREATE TABLE plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE CHECK (code IN ('free','pro','team')),
max_devices INTEGER NOT NULL,
daily_minutes INTEGER NULL,
ad_gate INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE subscriptions (
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)
);
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS codes;
DROP TABLE IF EXISTS code_batches;
@@ -0,0 +1,22 @@
-- 激活码
CREATE TABLE code_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL CHECK (channel IN ('store','tg','line','manual')),
created_by TEXT NOT NULL,
note TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code_hash TEXT NOT NULL UNIQUE, -- SHA-256
plan_id INTEGER NOT NULL,
duration_days INTEGER NOT NULL,
batch_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'unused' CHECK (status IN ('unused','redeemed','void')),
redeemed_by INTEGER NULL,
redeemed_at DATETIME NULL,
FOREIGN KEY (plan_id) REFERENCES plans(id),
FOREIGN KEY (batch_id) REFERENCES code_batches(id)
);
CREATE INDEX idx_codes_status ON codes (status);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS usage_daily;
@@ -0,0 +1,21 @@
-- 用量(仅字节/分钟,无目的地)
CREATE TABLE usage_daily (
user_id INTEGER NOT NULL,
date DATE NOT NULL,
bytes_up INTEGER NOT NULL DEFAULT 0,
bytes_down INTEGER NOT NULL DEFAULT 0,
minutes_used INTEGER NOT NULL DEFAULT 0,
ad_unlocked_at DATETIME NULL,
PRIMARY KEY (user_id, date)
);
-- 审计
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
meta TEXT NULL, -- JSON
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_at ON audit_log (at);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS nodes;
DROP TABLE IF EXISTS providers;
@@ -0,0 +1,33 @@
-- VPS 厂商池
CREATE TABLE providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
api_kind TEXT NOT NULL,
regions TEXT NOT NULL, -- JSON
pool TEXT NOT NULL CHECK (pool IN ('consumable','premium')),
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT NULL
);
-- 节点。status CHECK 预置最终全集(含 blocked_*,使 000010 在 SQLite 成为 no-op)。
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
region TEXT NOT NULL,
name_zh TEXT NOT NULL,
name_en TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'entry' CHECK (role IN ('entry','relay','exit')),
tier TEXT NOT NULL CHECK (tier IN ('free','pro')),
endpoint TEXT NOT NULL,
hy2_port INTEGER NULL,
reality_pbk TEXT NOT NULL,
reality_sni TEXT NOT NULL,
provider_id INTEGER NOT NULL,
tags TEXT NULL, -- JSON
status TEXT NOT NULL DEFAULT 'provisioning'
CHECK (status IN ('provisioning','probing','up','draining','down','destroyed','blocked_suspect','blocked_confirmed')),
weight INTEGER NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (provider_id) REFERENCES providers(id)
);
CREATE INDEX idx_nodes_status_tier ON nodes (status, tier);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS directory_version;
DROP TABLE IF EXISTS node_events;
@@ -0,0 +1,16 @@
-- 节点事件。event CHECK 预置最终全集(含 ip_rotated,使 000008 的 enum 扩展在 SQLite 成为 no-op)。
CREATE TABLE node_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
event TEXT NOT NULL CHECK (event IN ('provisioned','probe_pass','probe_fail','marked_up','draining','blocked_suspect','blocked_confirmed','replaced','destroyed','ip_rotated')),
detail TEXT NULL, -- JSON
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (node_id) REFERENCES nodes(id)
);
CREATE INDEX idx_node_events_node_at ON node_events (node_id, at);
-- 目录版本单例
CREATE TABLE directory_version (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
version INTEGER NOT NULL
);
@@ -0,0 +1,2 @@
DELETE FROM directory_version WHERE id = 1;
DELETE FROM plans WHERE code IN ('free', 'pro', 'team');
@@ -0,0 +1,13 @@
-- plans seedfree/pro/team
INSERT INTO plans (code, max_devices, daily_minutes, ad_gate) VALUES
('free', 1, 10, 1),
('pro', 5, NULL, 0),
('team', 10, NULL, 0)
ON CONFLICT(code) DO UPDATE SET
max_devices = excluded.max_devices,
daily_minutes = excluded.daily_minutes,
ad_gate = excluded.ad_gate;
-- directory_version 单例初始化
INSERT INTO directory_version (id, version) VALUES (1, 1)
ON CONFLICT(id) DO UPDATE SET version = excluded.version;
@@ -0,0 +1,5 @@
DROP TRIGGER IF EXISTS trg_replacements_updated_at;
DROP TABLE IF EXISTS replacements;
DROP TABLE IF EXISTS provision_idempotency;
ALTER TABLE nodes DROP COLUMN elastic_ip_id;
ALTER TABLE nodes DROP COLUMN provider_instance_id;
@@ -0,0 +1,33 @@
-- 弹性节点基建。附加式变更。
ALTER TABLE nodes ADD COLUMN provider_instance_id TEXT NULL;
ALTER TABLE nodes ADD COLUMN elastic_ip_id TEXT NULL;
-- node_events.event 的 'ip_rotated' 已在 SQLite 基线(000006)预置,无需扩展。
CREATE TABLE provision_idempotency (
idempotency_key TEXT NOT NULL PRIMARY KEY,
node_uuid TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE replacements (
uuid TEXT NOT NULL PRIMARY KEY,
old_node_id INTEGER NOT NULL,
new_node_id INTEGER NULL,
pool TEXT NOT NULL CHECK (pool IN ('consumable','premium')),
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','failed')),
step TEXT NOT NULL DEFAULT 'open_new',
detail TEXT NULL, -- JSON
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_repl_status ON replacements (status);
CREATE INDEX idx_repl_old_node ON replacements (old_node_id);
-- 等价 MySQL 的 ON UPDATE CURRENT_TIMESTAMPrecursive_triggers 默认关,不会自递归)。
CREATE TRIGGER trg_replacements_updated_at
AFTER UPDATE ON replacements
FOR EACH ROW
BEGIN
UPDATE replacements SET updated_at = CURRENT_TIMESTAMP WHERE uuid = NEW.uuid;
END;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS admins;
@@ -0,0 +1,10 @@
-- 管理端账户(密码 argon2idTOTP 密钥 AES-GCM 后以 BLOB 存储)
CREATE TABLE admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pw_hash TEXT NOT NULL,
totp_secret BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
);
@@ -0,0 +1 @@
-- no-opSQLite):SQLite 无法收紧 CHECK;保留预置全集。
@@ -0,0 +1 @@
-- no-opSQLite):nodes.status 的 blocked_suspect/blocked_confirmed 已在基线 000005 的 CHECK 中预置。
@@ -0,0 +1,2 @@
ALTER TABLE nodes DROP COLUMN reality_short_id;
ALTER TABLE nodes DROP COLUMN reality_prk;
@@ -0,0 +1,2 @@
ALTER TABLE nodes ADD COLUMN reality_prk TEXT NOT NULL DEFAULT '';
ALTER TABLE nodes ADD COLUMN reality_short_id TEXT NOT NULL DEFAULT '';
@@ -0,0 +1 @@
DROP TABLE IF EXISTS connect_credentials;
@@ -0,0 +1,11 @@
CREATE TABLE connect_credentials (
node_id INTEGER NOT NULL,
dp_uuid TEXT NOT NULL,
protocol INTEGER NOT NULL DEFAULT 3, -- agentv1.ProtocolBoth = 3
flow TEXT NOT NULL DEFAULT 'xtls-rprx-vision',
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (node_id, dp_uuid),
CONSTRAINT fk_cc_node FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX idx_cc_node_expires ON connect_credentials (node_id, expires_at);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_users_sub_token;
ALTER TABLE users DROP COLUMN totp_enabled;
ALTER TABLE users DROP COLUMN totp_secret_enc;
ALTER TABLE users DROP COLUMN sub_token;
@@ -0,0 +1,5 @@
-- Web 用户中心字段。sub_token 唯一(SQLite 需 ADD COLUMN 后再建唯一索引)。
ALTER TABLE users ADD COLUMN sub_token TEXT NULL;
ALTER TABLE users ADD COLUMN totp_secret_enc BLOB NULL;
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX idx_users_sub_token ON users (sub_token);