mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
e4996ade03
Schema is now managed by a separate Go CLI at tools/migrate/, which embeds
the SQL files copied from server/initialize/migrate/database/ and tracks
state in the shared schema_migrations table. Rust no longer owns migrations
at startup.
Rust startup:
1. Connect to DB
2. Probe for the `user` table (schema marker)
3. If missing, fork-exec `ppanel-migrate up` to bring schema to v2131
4. Create admin if not present
5. Continue with redis/queue/scheduler
Verified:
- Empty DB → ppanel-migrate applies all 39 migrations in ~0.4s, admin created
- Go server's ppanel_go DB → schema detected, migrate skipped, clean start
Changes:
- Add tools/migrate/ — Go module wrapping golang-migrate
* migrate/migrate.go: Migrate() + RunUp() (RunUp bypasses the iofs vs
os.ErrNotExist sentinel mismatch that breaks Up() on already-migrated DBs)
* migrate/sql/{postgres,mysql}/: SQL files copied from server
* cmd/migrate/main.go: CLI (up/down/version/force/drop)
- Widen password column from varchar(100) → varchar(255) in
00001_init_schema.up.sql to fit Rust's 112-char PBKDF2 hash
(Rust uses 16-byte salt; Go's 8-byte salt fits in 100 but Rust's doesn't)
- Rewrite src/migration.rs:
* Remove sqlx::migrate!, run_migrations()
* Add ensure_schema(db, cfg): probe + invoke ppanel-migrate subprocess
- Fix src/db.rs::build_dsn: previously used cfg.config verbatim even when
the default config string was for the wrong dialect (PG got MySQL's
charset=utf8mb4 string and crashed on connect)
- Delete old migrations/{postgres,mysql}/ (no longer used)
Co-Authored-By: Claude <noreply@anthropic.com>
121 lines
4.1 KiB
Rust
121 lines
4.1 KiB
Rust
//! Database connection initialisation.
|
|
//!
|
|
//! Reads the [`DatabaseConfig`] and creates a native `Pool<Postgres>` or
|
|
//! `Pool<MySql>` wrapped in [`Db`]. Using concrete pool types (instead of
|
|
//! `AnyPool`) lets each repository impl write native SQL with `$N` / `?`
|
|
//! placeholders and use `FromRow` against the correct row type.
|
|
|
|
use std::time::Duration;
|
|
|
|
use crate::config::DatabaseConfig;
|
|
use crate::repository::{Db, Dialect};
|
|
|
|
/// Build a pool + dialect wrapper from the config subsection.
|
|
pub async fn init_pool(cfg: &DatabaseConfig) -> Result<Db, sqlx::Error> {
|
|
let dsn = build_dsn(cfg);
|
|
let dialect = detect_dialect(cfg);
|
|
|
|
let max_conn = cfg.max_open_conns.max(1) as u32;
|
|
|
|
match dialect {
|
|
Dialect::Postgres => {
|
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(max_conn)
|
|
.acquire_timeout(Duration::from_secs(10))
|
|
.connect(&dsn)
|
|
.await?;
|
|
Ok(Db::new_pg(pool))
|
|
}
|
|
Dialect::Mysql => {
|
|
let pool = sqlx::mysql::MySqlPoolOptions::new()
|
|
.max_connections(max_conn)
|
|
.acquire_timeout(Duration::from_secs(10))
|
|
.connect(&dsn)
|
|
.await?;
|
|
Ok(Db::new_mysql(pool))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── DSN builder ─────────────────────────────────────────────────────────
|
|
|
|
pub(crate) fn build_dsn(cfg: &DatabaseConfig) -> String {
|
|
let dialect = detect_dialect(cfg);
|
|
let addr = cfg
|
|
.addr
|
|
.as_deref()
|
|
.filter(|a| !a.is_empty())
|
|
.unwrap_or(match dialect {
|
|
Dialect::Postgres => "localhost:5432",
|
|
Dialect::Mysql => "localhost:3306",
|
|
});
|
|
let password = url_encode_password(&cfg.password);
|
|
let query = pick_query(cfg, dialect);
|
|
|
|
match dialect {
|
|
Dialect::Postgres => format!(
|
|
"postgres://{}:{}@{}/{}?{}",
|
|
cfg.username, password, addr, cfg.dbname, query,
|
|
),
|
|
Dialect::Mysql => format!(
|
|
"mysql://{}:{}@{}/{}?{}",
|
|
cfg.username, password, addr, cfg.dbname, query,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn detect_dialect(cfg: &DatabaseConfig) -> Dialect {
|
|
Dialect::from_driver(&cfg.driver)
|
|
}
|
|
|
|
/// Pick the URL query string for the DSN.
|
|
///
|
|
/// `DatabaseConfig.config` defaults to a MySQL-flavoured string
|
|
/// (`charset=utf8mb4&parseTime=true&loc=...`), so an unset YAML field
|
|
/// silently gives the wrong params to a Postgres connection. To stay
|
|
/// backwards-compatible we only honour `cfg.config` when it looks
|
|
/// compatible with the active dialect; otherwise fall back to
|
|
/// `default_query`.
|
|
fn pick_query(cfg: &DatabaseConfig, dialect: Dialect) -> String {
|
|
let raw = cfg.config.trim();
|
|
if !raw.is_empty() && query_matches_dialect(raw, dialect) {
|
|
return raw.to_string();
|
|
}
|
|
default_query(dialect).to_string()
|
|
}
|
|
|
|
fn query_matches_dialect(query: &str, dialect: Dialect) -> bool {
|
|
// Heuristic — MySQL uses `charset=` / `parseTime=` / `loc=`; Postgres
|
|
// uses `sslmode=` / `TimeZone=` / `channel_binding=`. Treat any MySQL-
|
|
// specific key under a Postgres dialect as a mismatch, and vice versa.
|
|
let q = query.to_ascii_lowercase();
|
|
match dialect {
|
|
Dialect::Postgres => {
|
|
!q.contains("charset=")
|
|
&& !q.contains("parsetime=")
|
|
&& !q.contains("&loc=")
|
|
&& !q.starts_with("loc=")
|
|
}
|
|
Dialect::Mysql => {
|
|
!(q.contains("sslmode=") || q.contains("timezone=") || q.contains("channel_binding="))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_query(dialect: Dialect) -> &'static str {
|
|
match dialect {
|
|
Dialect::Postgres => "sslmode=disable&TimeZone=Asia/Shanghai",
|
|
Dialect::Mysql => "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
|
|
}
|
|
}
|
|
|
|
fn url_encode_password(s: &str) -> String {
|
|
s.chars()
|
|
.map(|c| match c {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
|
|
' ' => "%20".into(),
|
|
other => format!("%{:02X}", other as u8),
|
|
})
|
|
.collect()
|
|
}
|