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    /// PostgreSQL SSL 模式(sqlx 连接参数 sslmode,本地库建议 disable)
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/// Redis 缓存配置
349///
350/// 后端缓存与协作状态统一落 Redis。启动时必须连通,连接失败直接退出进程。
351#[derive(Debug, Clone, Deserialize)]
352pub struct RedisConfig {
353    /// 连接 URL(如 `redis://127.0.0.1:6379/0`),由 `REDIS_URL` 环境变量覆盖
354    #[serde(default = "default_redis_url")]
355    pub url: String,
356    /// 所有缓存键统一前缀,便于多环境隔离
357    #[serde(default = "default_redis_key_prefix")]
358    pub key_prefix: String,
359    /// 列表/统计类 JSON 缓存默认 TTL 秒数
360    #[serde(default = "default_redis_ttl_secs")]
361    pub default_ttl_secs: u64,
362}
363
364/// 默认 Redis 连接 URL
365fn default_redis_url() -> String {
366    "redis://127.0.0.1:6379/0".into()
367}
368
369/// 默认 Redis 键前缀
370fn default_redis_key_prefix() -> String {
371    "tdm".into()
372}
373
374/// 默认 Redis JSON 缓存 TTL 秒数
375fn default_redis_ttl_secs() -> u64 {
376    60
377}
378
379/// Redis 段默认值(base.toml 未配置时使用)
380fn default_redis() -> RedisConfig {
381    RedisConfig {
382        url: default_redis_url(),
383        key_prefix: default_redis_key_prefix(),
384        default_ttl_secs: default_redis_ttl_secs(),
385    }
386}
387
388/// 阿里云 OSS 配置
389#[derive(Debug, Clone, Deserialize)]
390pub struct AliyunConfig {
391    /// 端点
392    pub endpoint: String,
393    /// AccessKeyId
394    pub access_key_id: String,
395    /// AccessKeySecret
396    pub access_key_secret: String,
397    /// 桶名
398    pub bucket_name: String,
399}
400
401/// 应用总配置
402///
403/// 对应 TOML 配置文件的根结构,聚合所有配置段。
404/// 通过 [`load()`] 从 `base.toml` + `{profile}.toml` 加载。
405#[derive(Debug, Clone, Deserialize)]
406pub struct AppConfig {
407    /// 配置档名称(对应 spring.profiles.active)
408    pub profile: String,
409    /// 服务配置
410    pub server: ServerConfig,
411    /// 数据库配置
412    pub database: DatabaseConfig,
413    /// 文件上传配置
414    pub multipart: MultipartConfig,
415    /// MyBatis 配置
416    pub mybatis: MybatisConfig,
417    /// JWT 配置
418    pub jwt: JwtConfig,
419    /// 文件夹配置
420    pub folder: FolderConfig,
421    /// RSS 配置
422    pub rss: RssConfig,
423    /// Sentry 配置
424    pub sentry: SentryConfig,
425    /// Springdoc 配置(pro 段无此项时使用默认值)
426    #[serde(default = "default_springdoc")]
427    pub springdoc: SpringdocConfig,
428    /// dev 控制台配置
429    #[serde(default = "default_dev_console")]
430    pub dev_console: DevConsoleConfig,
431    /// 可观测性 / OTel 配置
432    #[serde(default = "default_telemetry")]
433    pub telemetry: TelemetryConfig,
434    /// 腾讯云配置
435    pub tencent: TencentConfig,
436    /// 阿里云配置
437    pub aliyun: AliyunConfig,
438    /// Redis 配置(缓存与协作状态)
439    #[serde(default = "default_redis")]
440    pub redis: RedisConfig,
441}
442
443/// pro 环境无 springdoc 段时的默认占位
444fn default_springdoc() -> SpringdocConfig {
445    SpringdocConfig {
446        swagger_ui: SpringdocSwaggerUiConfig {
447            path: String::new(),
448            operations_sorter: String::new(),
449            url: String::new(),
450        },
451    }
452}
453
454/// 默认 PostgreSQL SSL 模式
455fn default_db_ssl_mode() -> String {
456    "require".into()
457}
458
459/// 由 database 各字段组装 sqlx URL
460fn build_database_url(db: &DatabaseConfig) -> String {
461    format!(
462        "postgres://{}:{}@{}:{}/{}?sslmode={}",
463        db.username, db.password, db.host, db.port, db.name, db.ssl_mode
464    )
465}
466
467/// 为连接串补全 sslmode(DATABASE_URL 未带该参数时)
468fn ensure_database_ssl_mode(url: &str, ssl_mode: &str) -> String {
469    if url.contains("sslmode=") {
470        return url.to_string();
471    }
472    let sep = if url.contains('?') { '&' } else { '?' };
473    format!("{url}{sep}sslmode={ssl_mode}")
474}
475
476/// 应用环境变量覆盖
477fn apply_env_overrides(cfg: &mut AppConfig) {
478    if let Ok(url) = std::env::var("DATABASE_URL") {
479        cfg.database.url = ensure_database_ssl_mode(&url, &cfg.database.ssl_mode);
480    } else {
481        let db_user = |key| {
482            std::env::var(key)
483                .map(|v| v.trim().to_string())
484                .ok()
485                .filter(|v| !v.is_empty())
486        };
487        if let Some(user) = db_user("DB_USER").or_else(|| db_user("PG_APP_USER")) {
488            cfg.database.username = user;
489        }
490        let db_pass = |key| {
491            std::env::var(key)
492                .map(|v| v.trim().to_string())
493                .ok()
494                .filter(|v| !v.is_empty())
495        };
496        if let Some(pass) = db_pass("DB_PASSWORD").or_else(|| db_pass("PG_APP_PASSWORD")) {
497            cfg.database.password = pass;
498        }
499        cfg.database.url = build_database_url(&cfg.database);
500    }
501
502    if let Ok(id) = std::env::var("SECRET_ID") {
503        cfg.tencent.secret_id = id.trim().to_string();
504    }
505    if let Ok(key) = std::env::var("SECRET_KEY") {
506        cfg.tencent.secret_key = key.trim().to_string();
507    }
508    if let Ok(key) = std::env::var("CDN_KEY") {
509        cfg.tencent.cdn_key = key;
510    }
511    if let Ok(dsn) = std::env::var("SENTRY_DSN") {
512        cfg.sentry.dsn = dsn;
513    }
514    if let Ok(url) = std::env::var("REDIS_URL") {
515        let trimmed = url.trim();
516        if !trimmed.is_empty() {
517            cfg.redis.url = trimmed.to_string();
518        }
519    }
520    if let Ok(ep) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
521        let trimmed = ep.trim();
522        if !trimmed.is_empty() {
523            cfg.telemetry.otlp_endpoint = trimmed.to_string();
524            cfg.telemetry.enabled = true;
525        }
526    }
527    if let Ok(name) = std::env::var("OTEL_SERVICE_NAME") {
528        let trimmed = name.trim();
529        if !trimmed.is_empty() {
530            cfg.telemetry.service_name = trimmed.to_string();
531        }
532    }
533}
534
535/// 解析配置目录:TDM_CONFIG_DIR > 部署目录 {APP}/bin/exe -> {APP}/config > manifest/config
536pub fn resolve_config_dir() -> PathBuf {
537    if let Ok(dir) = std::env::var("TDM_CONFIG_DIR") {
538        if !dir.trim().is_empty() {
539            return PathBuf::from(dir);
540        }
541    }
542    if let Ok(exe) = std::env::current_exe() {
543        if let Some(bin_dir) = exe.parent() {
544            if bin_dir.file_name().and_then(|n| n.to_str()) == Some("bin") {
545                if let Some(app_root) = bin_dir.parent() {
546                    let dir = app_root.join("config");
547                    if dir.join("base.toml").is_file() {
548                        return dir;
549                    }
550                }
551            }
552        }
553    }
554    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config")
555}
556
557/// 加载配置:合并 base.toml + profile.toml,环境变量覆盖敏感项
558///
559/// ## 加载顺序
560///
561/// 1. 加载 `.env` 文件(manifest 目录和当前工作目录)
562/// 2. 合并 `base.toml` 和 `{profile}.toml`
563/// 3. 环境变量覆盖(`DATABASE_URL`, `SECRET_ID`, `SECRET_KEY`, `CDN_KEY`, `SENTRY_DSN` 等)
564///
565/// ## 示例
566///
567/// ```rust,ignore
568/// let config = AppConfig::load("dev")?;
569/// assert_eq!(config.server.port, 8090);
570/// ```
571pub fn load(profile: &str) -> anyhow::Result<AppConfig> {
572    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
573    dotenvy::from_path(manifest.join(".env")).ok();
574    dotenvy::dotenv().ok();
575    let config_dir = resolve_config_dir();
576    let builder = config::Config::builder()
577        .add_source(config::File::from(config_dir.join("base.toml")))
578        .add_source(config::File::from(
579            config_dir.join(format!("{profile}.toml")),
580        ))
581        .add_source(config::Environment::default().separator("__"));
582    let mut cfg: AppConfig = builder.build()?.try_deserialize()?;
583    cfg.profile = profile.to_string();
584    if cfg.database.url.is_empty() {
585        cfg.database.url = build_database_url(&cfg.database);
586    }
587    apply_env_overrides(&mut cfg);
588    Ok(cfg)
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    /// 校验 dev 配置可加载且数据库 URL 正确
596    #[test]
597    fn load_dev_config() {
598        for key in [
599            "DATABASE_URL",
600            "DB_USER",
601            "DB_PASSWORD",
602            "DATABASE__HOST",
603            "DATABASE__PORT",
604            "DATABASE__NAME",
605            "DATABASE__SSL_MODE",
606            "DATABASE__USERNAME",
607            "DATABASE__PASSWORD",
608        ] {
609            // SAFETY: 单线程单元测试,临时清除 CI 集成任务注入的数据库环境变量
610            unsafe { std::env::remove_var(key) };
611        }
612        let cfg = load("dev").expect("dev 配置应能加载");
613        assert_eq!(cfg.profile, "dev");
614        assert_eq!(cfg.server.port, 8090);
615        assert!(cfg.database.url.contains("5434"));
616        assert!(
617            cfg.database.url.contains("sslmode=require"),
618            "PostgreSQL 默认应启用 SSL: {}",
619            cfg.database.url
620        );
621        assert_eq!(cfg.multipart.max_file_size, "100MB");
622        assert!(!cfg.tencent.ext_whitelist.is_empty());
623        assert!(!cfg.tencent.secret_id.is_empty(), "secret_id 未加载");
624        assert!(
625            !cfg.tencent.secret_key.is_empty(),
626            "secret_key 未加载,检查 TdmServerRust/.env"
627        );
628        assert_eq!(cfg.tencent.image_bucket, "image-dev-1317356496");
629        assert_eq!(
630            cfg.tencent.secret_id, "AKIDRt8A4oLitqa7QaXHrGTUHrEOQJ0Tjla1",
631            "TDM OSS 应使用 manga-trans SecretId,见 keychain_20260427/keys.txt"
632        );
633    }
634}