1use serde::Deserialize;
25use std::path::PathBuf;
26
27#[derive(Debug, Clone, Deserialize)]
29pub struct ServerConfig {
30 pub host: String,
32 pub port: u16,
34 pub ssl_enabled: bool,
36 #[serde(default)]
38 pub tls_cert: Option<String>,
39 #[serde(default)]
41 pub tls_key: Option<String>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
46pub struct DatabaseConfig {
47 pub driver: String,
49 pub host: String,
51 pub port: u16,
53 pub name: String,
55 pub username: String,
57 pub password: String,
59 pub max_connections: u32,
61 #[serde(default = "default_db_ssl_mode")]
63 pub ssl_mode: String,
64 #[serde(default)]
66 pub url: String,
67}
68
69#[derive(Debug, Clone, Deserialize)]
71pub struct MultipartConfig {
72 pub max_file_size: String,
74 pub max_request_size: String,
76}
77
78#[derive(Debug, Clone, Deserialize)]
80pub struct MybatisConfig {
81 pub log_impl: String,
83 pub map_underscore_to_camel_case: bool,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct JwtConfig {
90 pub sign_key: String,
92 pub expire_ms: i64,
94}
95
96#[derive(Debug, Clone, Deserialize)]
98pub struct FolderConfig {
99 pub base: String,
101 pub base2: String,
103}
104
105#[derive(Debug, Clone, Deserialize)]
107pub struct RssConfig {
108 pub site_base_url: String,
110}
111
112#[derive(Debug, Clone, Deserialize)]
114pub struct SentryConfig {
115 pub dsn: String,
117 pub send_default_pii: bool,
119}
120
121#[derive(Debug, Clone, Deserialize)]
123pub struct SpringdocSwaggerUiConfig {
124 pub path: String,
126 pub operations_sorter: String,
128 pub url: String,
130}
131
132#[derive(Debug, Clone, Deserialize)]
134pub struct SpringdocConfig {
135 pub swagger_ui: SpringdocSwaggerUiConfig,
137}
138
139#[derive(Debug, Clone, Deserialize)]
141pub struct DevConsoleConfig {
142 #[serde(default = "default_dev_log_dir")]
144 pub log_dir: String,
145 #[serde(default = "default_dev_app_log")]
147 pub app_log: String,
148}
149
150#[derive(Debug, Clone, Deserialize)]
152pub struct TelemetrySkywalkingConfig {
153 #[serde(default = "default_sw_endpoint")]
155 pub endpoint: String,
156 #[serde(default)]
158 pub instance_name: String,
159 #[serde(default = "default_true")]
161 pub export_traces: bool,
162 #[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#[derive(Debug, Clone, Deserialize)]
184pub struct TelemetryConfig {
185 #[serde(default)]
187 pub enabled: bool,
188 #[serde(default = "default_telemetry_service_name")]
190 pub service_name: String,
191 #[serde(default)]
193 pub otlp_endpoint: String,
194 #[serde(default = "default_sample_ratio")]
196 pub sample_ratio: f64,
197 #[serde(default)]
199 pub export_on_request_end: bool,
200 #[serde(default = "default_span_level")]
202 pub span_level: String,
203 #[serde(default = "default_log_level")]
205 pub log_level: String,
206 #[serde(default)]
208 pub export_logs: bool,
209 #[serde(default)]
211 pub export_otlp_traces: bool,
212 #[serde(default)]
214 pub skywalking: TelemetrySkywalkingConfig,
215 #[serde(default)]
217 pub ui: TelemetryUiConfig,
218 #[serde(default)]
220 pub log_file: TelemetryLogFileConfig,
221}
222
223#[derive(Debug, Clone, Deserialize, Default)]
225pub struct TelemetryUiConfig {
226 #[serde(default)]
228 pub skywalking: String,
229}
230
231#[derive(Debug, Clone, Deserialize, Default)]
233pub struct TelemetryLogFileConfig {
234 #[serde(default = "default_log_file_path")]
236 pub path: String,
237 #[serde(default = "default_log_rotation")]
239 pub rotation: String,
240 #[serde(default = "default_log_max_files")]
242 pub max_files: u32,
243 #[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#[derive(Debug, Clone, Deserialize)]
319pub struct TencentConfig {
320 pub region: String,
322 pub duration_seconds: u64,
324 pub max_file_size: u64,
326 pub ext_whitelist: Vec<String>,
328 pub image_max_file_size: u64,
330 pub image_ext_whitelist: Vec<String>,
332 pub secret_id: String,
334 pub secret_key: String,
336 pub bucket: String,
338 pub cdn_domain: String,
340 pub cdn_key: String,
342 pub image_bucket: String,
344 pub image_cdn_domain: String,
346}
347
348#[derive(Debug, Clone, Deserialize)]
350pub struct AliyunConfig {
351 pub endpoint: String,
353 pub access_key_id: String,
355 pub access_key_secret: String,
357 pub bucket_name: String,
359}
360
361#[derive(Debug, Clone, Deserialize)]
366pub struct AppConfig {
367 pub profile: String,
369 pub server: ServerConfig,
371 pub database: DatabaseConfig,
373 pub multipart: MultipartConfig,
375 pub mybatis: MybatisConfig,
377 pub jwt: JwtConfig,
379 pub folder: FolderConfig,
381 pub rss: RssConfig,
383 pub sentry: SentryConfig,
385 #[serde(default = "default_springdoc")]
387 pub springdoc: SpringdocConfig,
388 #[serde(default = "default_dev_console")]
390 pub dev_console: DevConsoleConfig,
391 #[serde(default = "default_telemetry")]
393 pub telemetry: TelemetryConfig,
394 pub tencent: TencentConfig,
396 pub aliyun: AliyunConfig,
398}
399
400fn 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
411fn default_db_ssl_mode() -> String {
413 "disabled".into()
414}
415
416fn 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
424fn 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
433fn 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
474pub 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
496pub 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 #[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 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}