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)]
352pub struct RedisConfig {
353 #[serde(default = "default_redis_url")]
355 pub url: String,
356 #[serde(default = "default_redis_key_prefix")]
358 pub key_prefix: String,
359 #[serde(default = "default_redis_ttl_secs")]
361 pub default_ttl_secs: u64,
362}
363
364fn default_redis_url() -> String {
366 "redis://127.0.0.1:6379/0".into()
367}
368
369fn default_redis_key_prefix() -> String {
371 "tdm".into()
372}
373
374fn default_redis_ttl_secs() -> u64 {
376 60
377}
378
379fn 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#[derive(Debug, Clone, Deserialize)]
390pub struct AliyunConfig {
391 pub endpoint: String,
393 pub access_key_id: String,
395 pub access_key_secret: String,
397 pub bucket_name: String,
399}
400
401#[derive(Debug, Clone, Deserialize)]
406pub struct AppConfig {
407 pub profile: String,
409 pub server: ServerConfig,
411 pub database: DatabaseConfig,
413 pub multipart: MultipartConfig,
415 pub mybatis: MybatisConfig,
417 pub jwt: JwtConfig,
419 pub folder: FolderConfig,
421 pub rss: RssConfig,
423 pub sentry: SentryConfig,
425 #[serde(default = "default_springdoc")]
427 pub springdoc: SpringdocConfig,
428 #[serde(default = "default_dev_console")]
430 pub dev_console: DevConsoleConfig,
431 #[serde(default = "default_telemetry")]
433 pub telemetry: TelemetryConfig,
434 pub tencent: TencentConfig,
436 pub aliyun: AliyunConfig,
438 #[serde(default = "default_redis")]
440 pub redis: RedisConfig,
441}
442
443fn 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
454fn default_db_ssl_mode() -> String {
456 "require".into()
457}
458
459fn 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
467fn 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
476fn 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
535pub 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
557pub 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 #[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 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}