Skip to main content

sz_rust_core/
config.rs

1//! 配置系统 — YAML 加载 + 环境变量覆盖 + 默认值
2//!
3//! 对齐 PHP `config/app.php` / `config/database.php` 等。
4//!
5//! ## 环境变量覆盖规则
6//!
7//! | 格式 | 示例 | 说明 |
8//! |------|------|------|
9//! | `SZ_{SECTION}__{KEY}` | `SZ_APP__DEFAULT_APP=api` | 标准格式,双下划线分隔层级 |
10//! | `SZ_DB_{CONN}_PASSWORD` | `SZ_DB_MYSQL_PASSWORD=xxx` | 数据库密码简写格式 |
11//!
12//! ## 默认值
13//!
14//! 所有配置项都有默认值(通过 serde `#[serde(default)]` 或默认函数),
15//! 即使 YAML 文件缺失或字段缺失也能正常加载。
16
17use serde::de::DeserializeOwned;
18use serde::Deserialize;
19use std::collections::HashMap;
20use std::path::Path;
21use thiserror::Error;
22
23/// 配置错误
24#[derive(Debug, Error)]
25pub enum ConfigError {
26    /// 配置文件读取失败
27    #[error("配置文件读取失败: {path} — {source}")]
28    FileRead {
29        /// 配置文件路径
30        path: String,
31        /// 底层 IO 错误
32        #[source]
33        source: std::io::Error,
34    },
35    /// 配置文件解析失败
36    #[error("配置文件解析失败: {path} — {source}")]
37    Parse {
38        /// 配置文件路径
39        path: String,
40        /// 底层解析错误
41        #[source]
42        source: serde_yml::Error,
43    },
44}
45
46/// 顶层应用配置(含 6 个 section)
47#[derive(Debug, Clone, Deserialize, Default)]
48pub struct AppConfig {
49    /// 应用配置段
50    #[serde(default)]
51    pub app: AppSection,
52    /// 数据库配置段
53    #[serde(default)]
54    pub database: DatabaseSection,
55    /// 缓存配置段
56    #[serde(default)]
57    pub cache: CacheSection,
58    /// 插件配置段
59    #[serde(default)]
60    pub addons: AddonsSection,
61    /// 日志配置段
62    #[serde(default)]
63    pub log: LogSection,
64    /// 服务器配置段(HTTP 监听地址与端口)
65    #[serde(default)]
66    pub server: ServerSection,
67}
68
69/// 应用配置段 — 对齐 PHP `config/app.php`
70#[derive(Debug, Clone, Deserialize)]
71pub struct AppSection {
72    /// 应用主机地址
73    #[serde(default)]
74    pub app_host: String,
75    /// 应用命名空间
76    #[serde(default)]
77    pub app_namespace: String,
78    /// 是否启用路由
79    #[serde(default = "default_true")]
80    pub with_route: bool,
81    /// 是否启用事件系统
82    #[serde(default = "default_true")]
83    pub with_event: bool,
84    /// 默认应用名
85    #[serde(default = "default_default_app")]
86    pub default_app: String,
87    /// 默认时区
88    #[serde(default = "default_timezone")]
89    pub default_timezone: String,
90    /// 是否启用多应用模式
91    #[serde(default = "default_true")]
92    pub auto_multi_app: bool,
93    /// 应用映射表(域名/路径 → 应用名)
94    #[serde(default = "default_app_map")]
95    pub app_map: HashMap<String, String>,
96    /// 禁止访问的应用列表
97    #[serde(default = "default_deny_app_list")]
98    pub deny_app_list: Vec<String>,
99}
100
101impl Default for AppSection {
102    fn default() -> Self {
103        Self {
104            app_host: String::new(),
105            app_namespace: String::new(),
106            with_route: true,
107            with_event: true,
108            default_app: default_default_app(),
109            default_timezone: default_timezone(),
110            auto_multi_app: true,
111            app_map: default_app_map(),
112            deny_app_list: default_deny_app_list(),
113        }
114    }
115}
116
117/// 数据库配置段 — 对齐 PHP `config/database.php`
118#[derive(Debug, Clone, Deserialize)]
119pub struct DatabaseSection {
120    /// 默认连接名
121    #[serde(default = "default_mysql")]
122    pub default: String,
123    /// 是否自动时间戳
124    #[serde(default = "default_true")]
125    pub auto_timestamp: bool,
126    /// 时间戳格式
127    #[serde(default = "default_datetime_format")]
128    pub datetime_format: String,
129    /// 数据库连接配置表(连接名 → 连接配置)
130    #[serde(default)]
131    pub connections: HashMap<String, DatabaseConnection>,
132}
133
134impl Default for DatabaseSection {
135    fn default() -> Self {
136        Self {
137            default: default_mysql(),
138            auto_timestamp: true,
139            datetime_format: default_datetime_format(),
140            connections: HashMap::new(),
141        }
142    }
143}
144
145/// 单个数据库连接配置
146#[derive(Debug, Clone, Deserialize)]
147pub struct DatabaseConnection {
148    /// 数据库类型(如 mysql)
149    #[serde(default = "default_mysql")]
150    pub r#type: String,
151    /// 主机名
152    #[serde(default)]
153    pub hostname: String,
154    /// 数据库名
155    #[serde(default)]
156    pub database: String,
157    /// 用户名
158    #[serde(default)]
159    pub username: String,
160    /// 密码
161    ///
162    /// 安全约束:即使未来为 `DatabaseConnection` 派生 `Serialize`,
163    /// 密码也绝不应出现在序列化输出中(防止日志/响应泄露)。
164    #[serde(default, skip_serializing)]
165    pub password: String,
166    /// 主机端口
167    #[serde(default = "default_port_8802")]
168    pub hostport: u16,
169    /// 字符集
170    #[serde(default = "default_charset_utf8mb4")]
171    pub charset: String,
172    /// 表前缀
173    #[serde(default)]
174    pub prefix: String,
175    /// 部署模式(0=集中式 1=分布式)
176    #[serde(default)]
177    pub deploy: u8,
178    /// 是否读写分离
179    #[serde(default)]
180    pub rw_separate: bool,
181    /// 是否严格字段校验
182    #[serde(default = "default_true")]
183    pub fields_strict: bool,
184    /// 是否断线重连
185    #[serde(default = "default_true")]
186    pub break_reconnect: bool,
187}
188
189/// 缓存配置段 — 对齐 PHP `think-cache`
190#[derive(Debug, Clone, Deserialize, Default)]
191pub struct CacheSection {
192    /// 默认缓存存储名
193    #[serde(default = "default_cache_memory")]
194    pub default: String,
195    /// 缓存存储配置表(存储名 → 存储配置)
196    #[serde(default)]
197    pub stores: HashMap<String, CacheStore>,
198}
199
200/// 单个缓存存储配置
201#[derive(Debug, Clone, Deserialize, Default)]
202pub struct CacheStore {
203    /// 存储类型(如 memory)
204    #[serde(default)]
205    pub r#type: String,
206    /// 容量上限
207    #[serde(default)]
208    pub capacity: usize,
209    /// 分层级别列表
210    #[serde(default)]
211    pub levels: Vec<String>,
212}
213
214/// 插件配置段 — 对齐 PHP `addons/`
215#[derive(Debug, Clone, Deserialize, Default)]
216pub struct AddonsSection {
217    /// 插件目录路径
218    #[serde(default = "default_addons_path")]
219    pub addons_path: String,
220    /// 插件优先级配置
221    #[serde(default)]
222    pub priority: AddonsPriority,
223}
224
225/// 插件优先级配置
226#[derive(Debug, Clone, Deserialize, Default)]
227pub struct AddonsPriority {
228    /// 优先级 P0 插件列表(最高)
229    #[serde(default)]
230    pub p0: Vec<String>,
231    /// 优先级 P1 插件列表
232    #[serde(default)]
233    pub p1: Vec<String>,
234    /// 优先级 P2 插件列表(最低)
235    #[serde(default)]
236    pub p2: Vec<String>,
237}
238
239/// 日志配置段 — 对齐 PHP `think-logger`
240#[derive(Debug, Clone, Deserialize, Default)]
241pub struct LogSection {
242    /// 默认日志通道名
243    #[serde(default = "default_log_file")]
244    pub default: String,
245    /// 日志通道配置表(通道名 → 通道配置)
246    #[serde(default)]
247    pub channels: HashMap<String, LogChannel>,
248}
249
250/// 单个日志通道配置
251#[derive(Debug, Clone, Deserialize, Default)]
252pub struct LogChannel {
253    /// 通道类型(如 file)
254    #[serde(default)]
255    pub r#type: String,
256    /// 日志文件路径
257    #[serde(default)]
258    pub path: String,
259    /// 日志级别
260    #[serde(default = "default_log_level")]
261    pub level: String,
262    /// 最大保留文件数
263    #[serde(default)]
264    pub max_files: u32,
265    /// 日志格式
266    #[serde(default)]
267    pub format: String,
268}
269
270// ============================================================================
271// 默认值函数
272// ============================================================================
273
274fn default_true() -> bool {
275    true
276}
277
278fn default_default_app() -> String {
279    "index".to_string()
280}
281
282fn default_timezone() -> String {
283    "Asia/Shanghai".to_string()
284}
285
286fn default_app_map() -> HashMap<String, String> {
287    let mut map = HashMap::new();
288    map.insert("oapc".to_string(), "oapc".to_string());
289    map.insert("admin".to_string(), "admin".to_string());
290    map.insert("api".to_string(), "api".to_string());
291    map.insert("farm".to_string(), "farm".to_string());
292    map.insert("oapi".to_string(), "oapi".to_string());
293    map.insert("cashier".to_string(), "cashier".to_string());
294    map.insert("scene".to_string(), "scene".to_string());
295    map
296}
297
298fn default_deny_app_list() -> Vec<String> {
299    vec!["common".to_string()]
300}
301
302/// 服务器配置段 — HTTP 监听地址与端口
303///
304/// 对齐 PHP `think-swoole` 的 `config/swoole.php` 中 server.host / server.port 配置。
305/// 默认监听 `0.0.0.0:8080`,可通过 `config/server.yml` 或环境变量 `SZ_SERVER__PORT` 覆盖。
306#[derive(Debug, Clone, Deserialize)]
307pub struct ServerSection {
308    /// 监听地址(默认 `0.0.0.0`,对所有网卡开放)
309    #[serde(default = "default_server_host")]
310    pub host: String,
311    /// 监听端口(默认 `8080`)
312    #[serde(default = "default_server_port")]
313    pub port: u16,
314}
315
316impl Default for ServerSection {
317    fn default() -> Self {
318        Self {
319            host: default_server_host(),
320            port: default_server_port(),
321        }
322    }
323}
324
325fn default_server_host() -> String {
326    "0.0.0.0".to_string()
327}
328
329fn default_server_port() -> u16 {
330    8080
331}
332
333fn default_mysql() -> String {
334    "mysql".to_string()
335}
336
337fn default_datetime_format() -> String {
338    "Y-m-d H:i:s".to_string()
339}
340
341fn default_port_8802() -> u16 {
342    8802
343}
344
345fn default_charset_utf8mb4() -> String {
346    "utf8mb4".to_string()
347}
348
349fn default_cache_memory() -> String {
350    "memory".to_string()
351}
352
353fn default_addons_path() -> String {
354    "addons".to_string()
355}
356
357fn default_log_file() -> String {
358    "file".to_string()
359}
360
361fn default_log_level() -> String {
362    "info".to_string()
363}
364
365// ============================================================================
366// 加载与环境变量覆盖
367// ============================================================================
368
369impl AppConfig {
370    /// 从配置目录加载所有配置文件
371    ///
372    /// 目录结构:
373    /// ```text
374    /// config/
375    /// ├── app.yml
376    /// ├── database.yml
377    /// ├── cache.yml
378    /// ├── addons.yml
379    /// ├── log.yml
380    /// └── server.yml
381    /// ```
382    #[tracing::instrument(skip_all)]
383    pub fn load_from_dir(config_dir: impl AsRef<Path>) -> Result<Self, ConfigError> {
384        let dir = config_dir.as_ref();
385
386        // 逐个加载 section(文件不存在时用默认值,不报错)
387        let mut config = AppConfig {
388            app: load_section(&dir.join("app.yml"), AppSection::default())?,
389            database: load_section(&dir.join("database.yml"), DatabaseSection::default())?,
390            cache: load_section(&dir.join("cache.yml"), CacheSection::default())?,
391            addons: load_section(&dir.join("addons.yml"), AddonsSection::default())?,
392            log: load_section(&dir.join("log.yml"), LogSection::default())?,
393            server: load_section(&dir.join("server.yml"), ServerSection::default())?,
394        };
395
396        // 应用环境变量覆盖
397        config.apply_env_overrides();
398
399        Ok(config)
400    }
401
402    /// 应用环境变量覆盖
403    ///
404    /// 支持以下环境变量格式:
405    /// 1. `SZ_DB_{CONN}_PASSWORD` → `database.connections.{conn}.password`
406    /// 2. `SZ_DB_{CONN}_HOSTNAME` → `database.connections.{conn}.hostname`
407    /// 3. `SZ_DB_{CONN}_HOSTPORT` → `database.connections.{conn}.hostport`
408    /// 4. `SZ_APP__{KEY}` → `app.{key}`(标准格式,未来扩展)
409    #[tracing::instrument(skip(self))]
410    pub fn apply_env_overrides(&mut self) {
411        // 数据库连接环境变量覆盖:SZ_DB_{CONN}_{FIELD}
412        for (conn_name, conn) in &mut self.database.connections {
413            let prefix = format!("SZ_DB_{}", conn_name.to_uppercase());
414
415            // 密码
416            let env_key = format!("{}_PASSWORD", prefix);
417            if let Ok(password) = std::env::var(&env_key) {
418                if !password.is_empty() {
419                    conn.password = password;
420                }
421            }
422
423            // 主机名(支持通过环境变量覆盖内网 IP,避免硬编码)
424            let env_key = format!("{}_HOSTNAME", prefix);
425            if let Ok(hostname) = std::env::var(&env_key) {
426                if !hostname.is_empty() {
427                    conn.hostname = hostname;
428                }
429            }
430
431            // 端口
432            let env_key = format!("{}_HOSTPORT", prefix);
433            if let Ok(hostport_str) = std::env::var(&env_key) {
434                if !hostport_str.is_empty() {
435                    if let Ok(hostport) = hostport_str.parse() {
436                        conn.hostport = hostport;
437                    }
438                }
439            }
440        }
441    }
442
443    /// 获取默认数据库连接
444    pub fn default_connection(&self) -> Option<&DatabaseConnection> {
445        self.database.connections.get(&self.database.default)
446    }
447}
448
449/// 从 YAML 文件加载单个 section(文件不存在时返回默认值)
450fn load_section<T: DeserializeOwned + Default>(path: &Path, default: T) -> Result<T, ConfigError> {
451    if !path.exists() {
452        return Ok(default);
453    }
454    let content = std::fs::read_to_string(path).map_err(|e| ConfigError::FileRead {
455        path: path.display().to_string(),
456        source: e,
457    })?;
458    serde_yml::from_str(&content).map_err(|e| ConfigError::Parse {
459        path: path.display().to_string(),
460        source: e,
461    })
462}
463
464// ============================================================================
465// 单元测试
466// ============================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// env 变量测试互斥锁:避免并行测试时 `SZ_DB_MYSQL_PASSWORD` 被多个测试同时设置/读取
473    /// 造成状态污染(参见 R5: 测试必须覆盖 DML 操作序列以检测状态污染 bug)
474    static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
475
476    /// 测试默认值:所有 section 都有合理的默认值
477    #[test]
478    fn test_default_config() {
479        let config = AppConfig::default();
480        assert!(config.app.auto_multi_app);
481        assert!(config.app.with_route);
482        assert_eq!(config.app.default_app, "index");
483        assert_eq!(config.app.default_timezone, "Asia/Shanghai");
484        assert_eq!(config.app.app_map.len(), 7);
485        assert!(config.app.app_map.contains_key("oapc"));
486        assert_eq!(config.app.deny_app_list, vec!["common"]);
487
488        assert_eq!(config.database.default, "mysql");
489        assert!(config.database.auto_timestamp);
490        assert_eq!(config.database.datetime_format, "Y-m-d H:i:s");
491
492        // 验证 server 默认值
493        assert_eq!(config.server.host, "0.0.0.0");
494        assert_eq!(config.server.port, 8080);
495    }
496
497    /// 测试从 YAML 字符串加载
498    #[test]
499    fn test_load_from_yaml_string() {
500        let yaml = r#"
501app_host: "https://example.com"
502default_app: "api"
503auto_multi_app: true
504app_map:
505  oapc: oapc
506  admin: admin
507"#;
508        let app: AppSection = serde_yml::from_str(yaml).unwrap();
509        assert_eq!(app.app_host, "https://example.com");
510        assert_eq!(app.default_app, "api");
511        assert!(app.auto_multi_app);
512        assert_eq!(app.app_map.len(), 2);
513    }
514
515    /// 测试从目录加载(使用项目实际的 config/ 目录)
516    #[test]
517    fn test_load_from_dir() {
518        // config/ 目录位于 workspace 根
519        let config_dir = std::env::current_dir().ok().and_then(|d| {
520            // 测试运行时 cwd 可能是 packages/sz-rust-core
521            // 向上查找直到找到 config/ 目录
522            let mut current = d.clone();
523            for _ in 0..5 {
524                if current.join("config").exists() {
525                    return Some(current.join("config"));
526                }
527                if let Some(parent) = current.parent() {
528                    current = parent.to_path_buf();
529                } else {
530                    break;
531                }
532            }
533            None
534        });
535
536        if let Some(config_dir) = config_dir {
537            let config = AppConfig::load_from_dir(&config_dir).unwrap();
538            // 验证 app.yml 加载
539            assert_eq!(config.app.default_app, "index");
540            assert!(config.app.auto_multi_app);
541            assert_eq!(config.app.app_map.len(), 7);
542            assert_eq!(config.app.deny_app_list, vec!["common"]);
543
544            // 验证 database.yml 加载
545            assert_eq!(config.database.default, "mysql");
546            assert_eq!(config.database.connections.len(), 5);
547            assert!(config.database.connections.contains_key("mysql"));
548            assert!(config.database.connections.contains_key("njszjt"));
549            assert!(config.database.connections.contains_key("ljclz"));
550            assert!(config.database.connections.contains_key("food"));
551            assert!(config.database.connections.contains_key("oceanbase"));
552
553            // 验证 mysql 连接(hostname 已改用 localhost,实际地址通过环境变量注入)
554            let mysql = config.database.connections.get("mysql").unwrap();
555            assert_eq!(mysql.hostname, "localhost");
556            assert_eq!(mysql.hostport, 8802);
557            assert_eq!(mysql.charset, "utf8mb4");
558            assert_eq!(mysql.prefix, "sz_");
559
560            // 验证 ljclz 连接(charset=utf8, prefix=ims_)
561            let ljclz = config.database.connections.get("ljclz").unwrap();
562            assert_eq!(ljclz.charset, "utf8");
563            assert_eq!(ljclz.prefix, "ims_");
564
565            // 验证 oceanbase 连接(hostport=2881,hostname 同样改用 localhost)
566            let oceanbase = config.database.connections.get("oceanbase").unwrap();
567            assert_eq!(oceanbase.hostport, 2881);
568            assert_eq!(oceanbase.hostname, "localhost");
569
570            // 验证 cache.yml 加载
571            assert_eq!(config.cache.default, "memory");
572            assert!(config.cache.stores.contains_key("memory"));
573
574            // 验证 addons.yml 加载
575            assert_eq!(config.addons.addons_path, "addons");
576            assert_eq!(config.addons.priority.p0.len(), 3);
577
578            // 验证 log.yml 加载
579            assert_eq!(config.log.default, "file");
580            assert!(config.log.channels.contains_key("file"));
581
582            // 验证 server.yml 加载
583            assert_eq!(config.server.host, "0.0.0.0");
584            assert_eq!(config.server.port, 8080);
585        }
586    }
587
588    /// 测试文件不存在时使用默认值
589    #[test]
590    fn test_load_missing_file_uses_default() {
591        let temp_dir = std::env::temp_dir().join("sz_rust_config_test_missing");
592        let _ = std::fs::create_dir_all(&temp_dir);
593        // 目录存在但无任何 yml 文件
594        let config = AppConfig::load_from_dir(&temp_dir).unwrap();
595        assert!(config.app.auto_multi_app);
596        assert_eq!(config.database.default, "mysql");
597        let _ = std::fs::remove_dir_all(&temp_dir);
598    }
599
600    /// 测试环境变量覆盖数据库密码
601    #[test]
602    fn test_env_override_password() {
603        // 获取 env 测试锁,确保与 test_env_override_empty_ignored 串行运行
604        let _env_guard = ENV_TEST_LOCK.lock().unwrap();
605        // 清理可能残留的 env 变量(防御性:避免被先前测试残留状态污染)
606        std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
607
608        let mut config = AppConfig::default();
609        config.database.connections.insert(
610            "mysql".to_string(),
611            DatabaseConnection {
612                r#type: "mysql".to_string(),
613                hostname: "localhost".to_string(),
614                database: "test".to_string(),
615                username: "root".to_string(),
616                password: String::new(),
617                hostport: 3306,
618                charset: "utf8mb4".to_string(),
619                prefix: "sz_".to_string(),
620                deploy: 0,
621                rw_separate: false,
622                fields_strict: true,
623                break_reconnect: true,
624            },
625        );
626
627        // 设置环境变量
628        std::env::set_var("SZ_DB_MYSQL_PASSWORD", "secret123");
629
630        // 应用覆盖
631        config.apply_env_overrides();
632
633        // 验证密码被覆盖
634        assert_eq!(config.database.connections["mysql"].password, "secret123");
635
636        // 清理环境变量
637        std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
638    }
639
640    /// 测试环境变量为空时不覆盖
641    #[test]
642    fn test_env_override_empty_ignored() {
643        // 获取 env 测试锁,确保与 test_env_override_password 串行运行
644        let _env_guard = ENV_TEST_LOCK.lock().unwrap();
645        // 清理可能残留的 env 变量(防御性:避免被先前测试残留状态污染)
646        std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
647
648        let mut config = AppConfig::default();
649        config.database.connections.insert(
650            "mysql".to_string(),
651            DatabaseConnection {
652                r#type: "mysql".to_string(),
653                hostname: "localhost".to_string(),
654                database: "test".to_string(),
655                username: "root".to_string(),
656                password: "existing".to_string(),
657                hostport: 3306,
658                charset: "utf8mb4".to_string(),
659                prefix: "sz_".to_string(),
660                deploy: 0,
661                rw_separate: false,
662                fields_strict: true,
663                break_reconnect: true,
664            },
665        );
666
667        // 设置空环境变量
668        std::env::set_var("SZ_DB_MYSQL_PASSWORD", "");
669
670        config.apply_env_overrides();
671
672        // 空环境变量不应覆盖现有密码
673        assert_eq!(config.database.connections["mysql"].password, "existing");
674
675        std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
676    }
677
678    /// 测试获取默认连接
679    #[test]
680    fn test_default_connection() {
681        let mut config = AppConfig::default();
682        config.database.default = "mysql".to_string();
683        config.database.connections.insert(
684            "mysql".to_string(),
685            DatabaseConnection {
686                r#type: "mysql".to_string(),
687                hostname: "localhost".to_string(),
688                database: "test".to_string(),
689                username: "root".to_string(),
690                password: String::new(),
691                hostport: 3306,
692                charset: "utf8mb4".to_string(),
693                prefix: "sz_".to_string(),
694                deploy: 0,
695                rw_separate: false,
696                fields_strict: true,
697                break_reconnect: true,
698            },
699        );
700
701        let conn = config.default_connection();
702        assert!(conn.is_some());
703        assert_eq!(conn.unwrap().hostname, "localhost");
704    }
705
706    /// 测试默认连接不存在时返回 None
707    #[test]
708    fn test_default_connection_missing() {
709        let config = AppConfig::default();
710        assert!(config.default_connection().is_none());
711    }
712
713    /// 测试环境变量覆盖 hostname(P3-18:清理内网 IP 硬编码)
714    ///
715    /// 场景:YAML 默认 hostname=localhost,生产环境通过
716    /// `SZ_DB_{CONN}_HOSTNAME` 注入实际内网地址。
717    #[test]
718    fn test_env_override_hostname() {
719        let _env_guard = ENV_TEST_LOCK.lock().unwrap();
720        std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
721
722        let mut config = AppConfig::default();
723        config.database.connections.insert(
724            "mysql".to_string(),
725            DatabaseConnection {
726                r#type: "mysql".to_string(),
727                hostname: "localhost".to_string(),
728                database: "test".to_string(),
729                username: "root".to_string(),
730                password: String::new(),
731                hostport: 3306,
732                charset: "utf8mb4".to_string(),
733                prefix: "sz_".to_string(),
734                deploy: 0,
735                rw_separate: false,
736                fields_strict: true,
737                break_reconnect: true,
738            },
739        );
740
741        std::env::set_var("SZ_DB_MYSQL_HOSTNAME", "10.0.0.5");
742        config.apply_env_overrides();
743
744        assert_eq!(config.database.connections["mysql"].hostname, "10.0.0.5");
745
746        std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
747    }
748
749    /// 测试环境变量覆盖 hostport(P3-18:端口可注入)
750    #[test]
751    fn test_env_override_hostport() {
752        let _env_guard = ENV_TEST_LOCK.lock().unwrap();
753        std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
754
755        let mut config = AppConfig::default();
756        config.database.connections.insert(
757            "mysql".to_string(),
758            DatabaseConnection {
759                r#type: "mysql".to_string(),
760                hostname: "localhost".to_string(),
761                database: "test".to_string(),
762                username: "root".to_string(),
763                password: String::new(),
764                hostport: 3306,
765                charset: "utf8mb4".to_string(),
766                prefix: "sz_".to_string(),
767                deploy: 0,
768                rw_separate: false,
769                fields_strict: true,
770                break_reconnect: true,
771            },
772        );
773
774        std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "8802");
775        config.apply_env_overrides();
776
777        assert_eq!(config.database.connections["mysql"].hostport, 8802);
778
779        std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
780    }
781
782    /// 测试 hostport 环境变量为非数字时保持原值(防御性)
783    #[test]
784    fn test_env_override_hostport_invalid_ignored() {
785        let _env_guard = ENV_TEST_LOCK.lock().unwrap();
786        std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
787
788        let mut config = AppConfig::default();
789        config.database.connections.insert(
790            "mysql".to_string(),
791            DatabaseConnection {
792                r#type: "mysql".to_string(),
793                hostname: "localhost".to_string(),
794                database: "test".to_string(),
795                username: "root".to_string(),
796                password: String::new(),
797                hostport: 3306,
798                charset: "utf8mb4".to_string(),
799                prefix: "sz_".to_string(),
800                deploy: 0,
801                rw_separate: false,
802                fields_strict: true,
803                break_reconnect: true,
804            },
805        );
806
807        std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "not-a-number");
808        config.apply_env_overrides();
809
810        // 非数字解析失败,保持原值 3306
811        assert_eq!(config.database.connections["mysql"].hostport, 3306);
812
813        std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
814    }
815
816    /// 测试 YAML 解析错误
817    #[test]
818    fn test_parse_error() {
819        let bad_yaml = "default: mysql\n  bad: : : indent";
820        let result: Result<DatabaseSection, _> = serde_yml::from_str(bad_yaml);
821        // 无效 YAML 应该返回错误(或被 serde 宽容处理)
822        // 这里只验证不 panic
823        let _ = result;
824    }
825}