Skip to main content

tdm_server_rust/config/
mod.rs

1//! 配置加载模块 (Configuration)
2//!
3//! 从 TOML 配置文件加载应用配置,支持环境变量覆盖敏感项。
4//!
5//! ## 配置加载流程
6//!
7//! 1. [`resolve_config_dir()`] 确定配置目录(环境变量 > 部署路径 > 源码目录)
8//! 2. [`load()`] 读取 `base.toml` + `{profile}.toml` 合并
9//! 3. 环境变量覆盖敏感字段(`DATABASE_URL`, `SECRET_ID`, `SECRET_KEY` 等)
10//!
11//! ## 配置段
12//!
13//! | 结构体 | 对应 TOML 段 | 说明 |
14//! |--------|-------------|------|
15//! | [`ServerConfig`] | `[server]` | HTTP 监听地址/端口/SSL |
16//! | [`DatabaseConfig`] | `[database]` | MySQL 连接参数 |
17//! | [`JwtConfig`] | `[jwt]` | JWT 签名密钥与过期时间 |
18//! | [`TencentConfig`] | `[tencent]` | 腾讯云 COS / CDN |
19//! | [`AliyunConfig`] | `[aliyun]` | 阿里云 OSS |
20//! | [`FolderConfig`] | `[folder]` | 文件存储路径 |
21//! | [`RssConfig`] | `[rss]` | RSS 站点 URL |
22//! | [`AppConfig`] | 根配置 | 聚合以上所有配置段 |
23
24use serde::Deserialize;
25use std::path::PathBuf;
26
27/// 服务端配置
28#[derive(Debug, Clone, Deserialize)]
29pub struct ServerConfig {
30    /// 监听地址
31    pub host: String,
32    /// 监听端口
33    pub port: u16,
34    /// 是否启用 SSL(对应 server.ssl.enabled;启用后 ALPN 协商 HTTP/2)
35    pub ssl_enabled: bool,
36    /// TLS 证书 PEM 路径(相对 config 目录或绝对路径)
37    #[serde(default)]
38    pub tls_cert: Option<String>,
39    /// TLS 私钥 PEM 路径
40    #[serde(default)]
41    pub tls_key: Option<String>,
42}
43
44/// 数据库配置
45#[derive(Debug, Clone, Deserialize)]
46pub struct DatabaseConfig {
47    /// JDBC 驱动类名(Java 兼容字段)
48    pub driver: String,
49    /// 主机
50    pub host: String,
51    /// 端口
52    pub port: u16,
53    /// 库名
54    pub name: String,
55    /// 用户名
56    pub username: String,
57    /// 密码
58    pub password: String,
59    /// 最大连接数
60    pub max_connections: u32,
61    /// MySQL SSL 模式(sqlx 连接参数 ssl-mode,本地库建议 disabled)
62    #[serde(default = "default_db_ssl_mode")]
63    pub ssl_mode: String,
64    /// sqlx 连接 URL(加载后组装,非 toml 字段)
65    #[serde(default)]
66    pub url: String,
67}
68
69/// 文件上传限制
70#[derive(Debug, Clone, Deserialize)]
71pub struct MultipartConfig {
72    /// 单文件上限(如 100MB)
73    pub max_file_size: String,
74    /// 整次请求上限
75    pub max_request_size: String,
76}
77
78/// MyBatis 配置(Java 兼容字段)
79#[derive(Debug, Clone, Deserialize)]
80pub struct MybatisConfig {
81    /// SQL 日志实现类
82    pub log_impl: String,
83    /// 下划线转驼峰
84    pub map_underscore_to_camel_case: bool,
85}
86
87/// JWT 配置
88#[derive(Debug, Clone, Deserialize)]
89pub struct JwtConfig {
90    /// 签名密钥
91    pub sign_key: String,
92    /// 过期毫秒数
93    pub expire_ms: i64,
94}
95
96/// 文件夹配置
97#[derive(Debug, Clone, Deserialize)]
98pub struct FolderConfig {
99    /// 前端/RSS 文件根路径(对齐 Java folder.path.base)
100    pub base: String,
101    /// 后端稿件存储路径
102    pub base2: String,
103}
104
105/// RSS 订阅配置
106#[derive(Debug, Clone, Deserialize)]
107pub struct RssConfig {
108    /// 站点根 URL(对齐 Java RssServiceImpl.baseURL)
109    pub site_base_url: String,
110}
111
112/// Sentry 配置
113#[derive(Debug, Clone, Deserialize)]
114pub struct SentryConfig {
115    /// DSN
116    pub dsn: String,
117    /// 是否发送默认 PII
118    pub send_default_pii: bool,
119}
120
121/// Swagger UI 配置
122#[derive(Debug, Clone, Deserialize)]
123pub struct SpringdocSwaggerUiConfig {
124    /// UI 路径
125    pub path: String,
126    /// 操作排序
127    pub operations_sorter: String,
128    /// OpenAPI JSON 路径
129    pub url: String,
130}
131
132/// Springdoc 配置
133#[derive(Debug, Clone, Deserialize)]
134pub struct SpringdocConfig {
135    /// Swagger UI 子配置
136    pub swagger_ui: SpringdocSwaggerUiConfig,
137}
138
139/// dev 控制台配置(仅 dev 使用,pro 用默认值)
140#[derive(Debug, Clone, Deserialize)]
141pub struct DevConsoleConfig {
142    /// error.log 目录
143    #[serde(default = "default_dev_log_dir")]
144    pub log_dir: String,
145    /// 进程 stdout 日志文件
146    #[serde(default = "default_dev_app_log")]
147    pub app_log: String,
148}
149
150/// SkyWalking 原生 Agent 配置
151#[derive(Debug, Clone, Deserialize)]
152pub struct TelemetrySkywalkingConfig {
153    /// OAP gRPC 地址(env `SW_AGENT_COLLECTOR_BACKEND_SERVICES` 优先)
154    #[serde(default = "default_sw_endpoint")]
155    pub endpoint: String,
156    /// 实例名(空则 hostname)
157    #[serde(default)]
158    pub instance_name: String,
159    /// 是否上报原生 Segment
160    #[serde(default = "default_true")]
161    pub export_traces: bool,
162    /// 是否上报 SW 原生 Log
163    #[serde(default = "default_true")]
164    pub export_native_logs: bool,
165}
166
167fn default_sw_endpoint() -> String {
168    "http://127.0.0.1:11800".into()
169}
170
171impl Default for TelemetrySkywalkingConfig {
172    fn default() -> Self {
173        Self {
174            endpoint: default_sw_endpoint(),
175            instance_name: String::new(),
176            export_traces: true,
177            export_native_logs: true,
178        }
179    }
180}
181
182/// 可观测性 / OTel 配置
183#[derive(Debug, Clone, Deserialize)]
184pub struct TelemetryConfig {
185    /// 是否启用可观测性导出
186    #[serde(default)]
187    pub enabled: bool,
188    /// 服务名(OTel resource / SW service)
189    #[serde(default = "default_telemetry_service_name")]
190    pub service_name: String,
191    /// OTLP gRPC 端点(仅 Log)
192    #[serde(default)]
193    pub otlp_endpoint: String,
194    /// Trace 采样率 0.0~1.0(SW segment)
195    #[serde(default = "default_sample_ratio")]
196    pub sample_ratio: f64,
197    /// 已废弃:勿开启 per-request flush
198    #[serde(default)]
199    pub export_on_request_end: bool,
200    /// LocalSpan 最低级别:debug / info
201    #[serde(default = "default_span_level")]
202    pub span_level: String,
203    /// 日志 EnvFilter 级别
204    #[serde(default = "default_log_level")]
205    pub log_level: String,
206    /// 是否 OTLP 导出日志
207    #[serde(default)]
208    pub export_logs: bool,
209    /// 是否 OTLP 导出 trace(默认关,用 SW 原生 Segment)
210    #[serde(default)]
211    pub export_otlp_traces: bool,
212    /// SkyWalking 原生配置
213    #[serde(default)]
214    pub skywalking: TelemetrySkywalkingConfig,
215    /// SkyWalking UI 外链
216    #[serde(default)]
217    pub ui: TelemetryUiConfig,
218    /// pro 异步日志文件
219    #[serde(default)]
220    pub log_file: TelemetryLogFileConfig,
221}
222
223/// SkyWalking UI 配置
224#[derive(Debug, Clone, Deserialize, Default)]
225pub struct TelemetryUiConfig {
226    /// Horizon UI 地址(/dev/console.html 302 目标)
227    #[serde(default)]
228    pub skywalking: String,
229}
230
231/// pro 异步日志落盘配置
232#[derive(Debug, Clone, Deserialize, Default)]
233pub struct TelemetryLogFileConfig {
234    /// 日志文件路径
235    #[serde(default = "default_log_file_path")]
236    pub path: String,
237    /// 轮转策略(daily 等,由 tracing-appender 实现)
238    #[serde(default = "default_log_rotation")]
239    pub rotation: String,
240    /// 保留文件数(文档用途,daily 由 appender 管理)
241    #[serde(default = "default_log_max_files")]
242    pub max_files: u32,
243    /// 是否非阻塞写入
244    #[serde(default = "default_true")]
245    pub non_blocking: bool,
246}
247
248fn default_log_rotation() -> String {
249    "daily".into()
250}
251
252fn default_log_max_files() -> u32 {
253    14
254}
255
256fn default_telemetry_service_name() -> String {
257    "tdm-server-rust".into()
258}
259
260fn default_sample_ratio() -> f64 {
261    1.0
262}
263
264fn default_span_level() -> String {
265    "info".into()
266}
267
268fn default_log_level() -> String {
269    "info".into()
270}
271
272fn default_log_file_path() -> String {
273    "./logs/app.log".into()
274}
275
276fn default_true() -> bool {
277    true
278}
279
280fn default_telemetry() -> TelemetryConfig {
281    TelemetryConfig {
282        enabled: false,
283        service_name: default_telemetry_service_name(),
284        otlp_endpoint: String::new(),
285        sample_ratio: default_sample_ratio(),
286        export_on_request_end: false,
287        span_level: default_span_level(),
288        log_level: default_log_level(),
289        export_logs: false,
290        export_otlp_traces: false,
291        skywalking: TelemetrySkywalkingConfig::default(),
292        ui: TelemetryUiConfig::default(),
293        log_file: TelemetryLogFileConfig {
294            path: default_log_file_path(),
295            rotation: default_log_rotation(),
296            max_files: default_log_max_files(),
297            non_blocking: true,
298        },
299    }
300}
301
302fn default_dev_console() -> DevConsoleConfig {
303    DevConsoleConfig {
304        log_dir: default_dev_log_dir(),
305        app_log: default_dev_app_log(),
306    }
307}
308
309fn default_dev_log_dir() -> String {
310    "./logs".into()
311}
312
313fn default_dev_app_log() -> String {
314    "./app.log".into()
315}
316
317/// 腾讯云 OSS 配置
318#[derive(Debug, Clone, Deserialize)]
319pub struct TencentConfig {
320    /// 区域
321    pub region: String,
322    /// STS 有效期秒数
323    pub duration_seconds: u64,
324    /// 文件最大 MB
325    pub max_file_size: u64,
326    /// 允许上传的后缀白名单
327    pub ext_whitelist: Vec<String>,
328    /// 图片最大 MB
329    pub image_max_file_size: u64,
330    /// 允许上传的图片后缀白名单
331    pub image_ext_whitelist: Vec<String>,
332    /// SecretId
333    pub secret_id: String,
334    /// SecretKey
335    pub secret_key: String,
336    /// 存储桶
337    pub bucket: String,
338    /// CDN 域名
339    pub cdn_domain: String,
340    /// CDN 密钥
341    pub cdn_key: String,
342    /// 图片桶
343    pub image_bucket: String,
344    /// 图片 CDN 域名
345    pub image_cdn_domain: String,
346}
347
348/// 阿里云 OSS 配置
349#[derive(Debug, Clone, Deserialize)]
350pub struct AliyunConfig {
351    /// 端点
352    pub endpoint: String,
353    /// AccessKeyId
354    pub access_key_id: String,
355    /// AccessKeySecret
356    pub access_key_secret: String,
357    /// 桶名
358    pub bucket_name: String,
359}
360
361/// 应用总配置
362///
363/// 对应 TOML 配置文件的根结构,聚合所有配置段。
364/// 通过 [`load()`] 从 `base.toml` + `{profile}.toml` 加载。
365#[derive(Debug, Clone, Deserialize)]
366pub struct AppConfig {
367    /// 配置档名称(对应 spring.profiles.active)
368    pub profile: String,
369    /// 服务配置
370    pub server: ServerConfig,
371    /// 数据库配置
372    pub database: DatabaseConfig,
373    /// 文件上传配置
374    pub multipart: MultipartConfig,
375    /// MyBatis 配置
376    pub mybatis: MybatisConfig,
377    /// JWT 配置
378    pub jwt: JwtConfig,
379    /// 文件夹配置
380    pub folder: FolderConfig,
381    /// RSS 配置
382    pub rss: RssConfig,
383    /// Sentry 配置
384    pub sentry: SentryConfig,
385    /// Springdoc 配置(pro 段无此项时使用默认值)
386    #[serde(default = "default_springdoc")]
387    pub springdoc: SpringdocConfig,
388    /// dev 控制台配置
389    #[serde(default = "default_dev_console")]
390    pub dev_console: DevConsoleConfig,
391    /// 可观测性 / OTel 配置
392    #[serde(default = "default_telemetry")]
393    pub telemetry: TelemetryConfig,
394    /// 腾讯云配置
395    pub tencent: TencentConfig,
396    /// 阿里云配置
397    pub aliyun: AliyunConfig,
398}
399
400/// pro 环境无 springdoc 段时的默认占位
401fn default_springdoc() -> SpringdocConfig {
402    SpringdocConfig {
403        swagger_ui: SpringdocSwaggerUiConfig {
404            path: String::new(),
405            operations_sorter: String::new(),
406            url: String::new(),
407        },
408    }
409}
410
411/// 默认 MySQL SSL 模式(本地 127.0.0.1 与 rustls 握手易失败)
412fn default_db_ssl_mode() -> String {
413    "disabled".into()
414}
415
416/// 由 database 各字段组装 sqlx URL
417fn build_database_url(db: &DatabaseConfig) -> String {
418    format!(
419        "mysql://{}:{}@{}:{}/{}?ssl-mode={}",
420        db.username, db.password, db.host, db.port, db.name, db.ssl_mode
421    )
422}
423
424/// 为连接串补全 ssl-mode(DATABASE_URL 未带该参数时)
425fn ensure_database_ssl_mode(url: &str, ssl_mode: &str) -> String {
426    if url.contains("ssl-mode=") {
427        return url.to_string();
428    }
429    let sep = if url.contains('?') { '&' } else { '?' };
430    format!("{url}{sep}ssl-mode={ssl_mode}")
431}
432
433/// 应用环境变量覆盖
434fn apply_env_overrides(cfg: &mut AppConfig) {
435    if let Ok(url) = std::env::var("DATABASE_URL") {
436        cfg.database.url = ensure_database_ssl_mode(&url, &cfg.database.ssl_mode);
437    } else {
438        if let Ok(user) = std::env::var("DB_USER") {
439            cfg.database.username = user;
440        }
441        if let Ok(pass) = std::env::var("DB_PASSWORD") {
442            cfg.database.password = pass;
443        }
444        cfg.database.url = build_database_url(&cfg.database);
445    }
446
447    if let Ok(id) = std::env::var("SECRET_ID") {
448        cfg.tencent.secret_id = id.trim().to_string();
449    }
450    if let Ok(key) = std::env::var("SECRET_KEY") {
451        cfg.tencent.secret_key = key.trim().to_string();
452    }
453    if let Ok(key) = std::env::var("CDN_KEY") {
454        cfg.tencent.cdn_key = key;
455    }
456    if let Ok(dsn) = std::env::var("SENTRY_DSN") {
457        cfg.sentry.dsn = dsn;
458    }
459    if let Ok(ep) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
460        let trimmed = ep.trim();
461        if !trimmed.is_empty() {
462            cfg.telemetry.otlp_endpoint = trimmed.to_string();
463            cfg.telemetry.enabled = true;
464        }
465    }
466    if let Ok(name) = std::env::var("OTEL_SERVICE_NAME") {
467        let trimmed = name.trim();
468        if !trimmed.is_empty() {
469            cfg.telemetry.service_name = trimmed.to_string();
470        }
471    }
472}
473
474/// 解析配置目录:TDM_CONFIG_DIR > 部署目录 {APP}/bin/exe -> {APP}/config > manifest/config
475pub fn resolve_config_dir() -> PathBuf {
476    if let Ok(dir) = std::env::var("TDM_CONFIG_DIR") {
477        if !dir.trim().is_empty() {
478            return PathBuf::from(dir);
479        }
480    }
481    if let Ok(exe) = std::env::current_exe() {
482        if let Some(bin_dir) = exe.parent() {
483            if bin_dir.file_name().and_then(|n| n.to_str()) == Some("bin") {
484                if let Some(app_root) = bin_dir.parent() {
485                    let dir = app_root.join("config");
486                    if dir.join("base.toml").is_file() {
487                        return dir;
488                    }
489                }
490            }
491        }
492    }
493    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config")
494}
495
496/// 加载配置:合并 base.toml + profile.toml,环境变量覆盖敏感项
497///
498/// ## 加载顺序
499///
500/// 1. 加载 `.env` 文件(manifest 目录和当前工作目录)
501/// 2. 合并 `base.toml` 和 `{profile}.toml`
502/// 3. 环境变量覆盖(`DATABASE_URL`, `SECRET_ID`, `SECRET_KEY`, `CDN_KEY`, `SENTRY_DSN` 等)
503///
504/// ## 示例
505///
506/// ```rust,ignore
507/// let config = AppConfig::load("dev")?;
508/// assert_eq!(config.server.port, 8090);
509/// ```
510pub fn load(profile: &str) -> anyhow::Result<AppConfig> {
511    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
512    dotenvy::from_path(manifest.join(".env")).ok();
513    dotenvy::dotenv().ok();
514    let config_dir = resolve_config_dir();
515    let builder = config::Config::builder()
516        .add_source(config::File::from(config_dir.join("base.toml")))
517        .add_source(config::File::from(config_dir.join(format!("{profile}.toml"))))
518        .add_source(config::Environment::default().separator("__"));
519    let mut cfg: AppConfig = builder.build()?.try_deserialize()?;
520    cfg.profile = profile.to_string();
521    if cfg.database.url.is_empty() {
522        cfg.database.url = build_database_url(&cfg.database);
523    }
524    apply_env_overrides(&mut cfg);
525    Ok(cfg)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    /// 校验 dev 配置可加载且数据库 URL 正确
533    #[test]
534    fn load_dev_config() {
535        for key in [
536            "DATABASE_URL",
537            "DB_USER",
538            "DB_PASSWORD",
539            "DATABASE__HOST",
540            "DATABASE__PORT",
541            "DATABASE__NAME",
542            "DATABASE__USERNAME",
543            "DATABASE__PASSWORD",
544        ] {
545            // SAFETY: 单线程单元测试,临时清除 CI 集成任务注入的数据库环境变量
546            unsafe { std::env::remove_var(key) };
547        }
548        let cfg = load("dev").expect("dev 配置应能加载");
549        assert_eq!(cfg.profile, "dev");
550        assert_eq!(cfg.server.port, 8090);
551        assert!(cfg.database.url.contains("3307"));
552        assert!(
553            cfg.database.url.contains("ssl-mode=disabled"),
554            "本地 MySQL 应禁用 SSL: {}",
555            cfg.database.url
556        );
557        assert_eq!(cfg.multipart.max_file_size, "100MB");
558        assert!(!cfg.tencent.ext_whitelist.is_empty());
559        assert!(!cfg.tencent.secret_id.is_empty(), "secret_id 未加载");
560        assert!(!cfg.tencent.secret_key.is_empty(), "secret_key 未加载,检查 TdmServerRust/.env");
561        assert_eq!(cfg.tencent.image_bucket, "image-dev-1317356496");
562        assert_eq!(
563            cfg.tencent.secret_id, "AKIDRt8A4oLitqa7QaXHrGTUHrEOQJ0Tjla1",
564            "TDM OSS 应使用 manga-trans SecretId,见 keychain_20260427/keys.txt"
565        );
566    }
567}