Skip to main content

tdm_server_rust/utils/
error_log.rs

1//! 错误日志记录 (Error Log)
2//!
3//! 统一将 HTTP 错误响应和业务异常写入 `logs/error.log` 文件,
4//! 同时在 dev 环境下写入内存环形缓冲供控制台查询。
5//!
6//! ## 两条写入路径
7//!
8//! | 函数 | 触发场景 | 格式 |
9//! |------|----------|------|
10//! | [`log_http_error`] | HTTP 4xx/5xx 响应 | `[ts] HTTP METHOD /path status=N member=N body=...` |
11//! | [`log_app_error`] | 业务 AppError | `[ts] APP kind=xxx code=N msg=... detail=...` |
12//!
13//! ## 初始化
14//!
15//! 在 `AppState::new()` 中调用 `init()` 设置日志文件路径,
16//! 未初始化时回退到 `{CARGO_MANIFEST_DIR}/logs/error.log`。
17
18use crate::dev::error_log as dev_error_log;
19use std::fs::OpenOptions;
20use std::io::Write;
21use std::path::PathBuf;
22use std::sync::{OnceLock, RwLock};
23
24/// 单条日志 body 最大展示长度(超出截断)
25const MAX_BODY_LEN: usize = 512;
26
27/// 运行时 error.log 路径持有者
28struct ErrorLogRuntime {
29    /// error.log 文件的完整路径
30    error_log_path: PathBuf,
31}
32
33static RUNTIME: OnceLock<RwLock<ErrorLogRuntime>> = OnceLock::new();
34
35/// 初始化错误日志路径
36///
37/// 可重复调用,仅首次生效。若路径的父目录不存在会自动创建。
38///
39/// # 参数
40///
41/// - `error_log_path`: error.log 文件的完整路径
42pub fn init(error_log_path: PathBuf) {
43    if RUNTIME.get().is_some() {
44        return;
45    }
46    ensure_log_file(&error_log_path);
47    let _ = RUNTIME.set(RwLock::new(ErrorLogRuntime { error_log_path }));
48}
49
50/// 确保日志目录和 error.log 文件存在(启动时预创建空文件)
51fn ensure_log_file(path: &PathBuf) {
52    if let Some(parent) = path.parent() {
53        let _ = std::fs::create_dir_all(parent);
54    }
55    let _ = OpenOptions::new()
56        .create(true)
57        .append(true)
58        .open(path);
59}
60
61fn runtime() -> Option<&'static RwLock<ErrorLogRuntime>> {
62    RUNTIME.get()
63}
64
65/// 默认 error.log 路径(未调用 [`init`] 时使用)
66fn fallback_error_log_path() -> PathBuf {
67    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
68        .join("logs")
69        .join("error.log")
70}
71
72/// 截断超长文本,追加 `...(len=N)` 后缀
73fn truncate(text: &str, max: usize) -> String {
74    if text.len() <= max {
75        return text.to_string();
76    }
77    format!("{}...(len={})", &text[..max], text.len())
78}
79
80/// 追加一行到指定日志文件
81fn append_line(path: &PathBuf, line: &str) {
82    if let Some(parent) = path.parent() {
83        let _ = std::fs::create_dir_all(parent);
84    }
85    if let Ok(mut file) = OpenOptions::new()
86        .create(true)
87        .append(true)
88        .open(path)
89    {
90        let _ = writeln!(file, "{line}");
91    }
92}
93
94/// 记录 HTTP 4xx/5xx 响应
95///
96/// 由 `error_log_middleware` 在每次响应 status >= 400 时调用。
97///
98/// # 参数
99///
100/// - `method`: HTTP 方法
101/// - `path`: 请求路径(经 `api_request_path()` 还原后的完整路径)
102/// - `status`: HTTP 状态码
103/// - `body`: 响应 body(会被截断至 512 字符)
104/// - `member_id`: 当前登录组员 ID(未登录为 None)
105pub fn log_http_error(
106    method: &str,
107    path: &str,
108    status: u16,
109    body: &str,
110    member_id: Option<i32>,
111) {
112    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
113    let member = member_id
114        .map(|id| id.to_string())
115        .unwrap_or_else(|| "-".to_string());
116    let line = format!(
117        "[{ts}] HTTP {method} {path} status={status} member={member} body={}",
118        truncate(body, MAX_BODY_LEN)
119    );
120
121    if let Some(rt) = runtime() {
122        if let Ok(guard) = rt.read() {
123            append_line(&guard.error_log_path, &line);
124        }
125    } else {
126        append_line(&fallback_error_log_path(), &line);
127    }
128    dev_error_log::try_persist_http_error(method, path, status, body, member_id);
129    crate::telemetry::log_error_event(&format!(
130        "HTTP {method} {path} status={status}"
131    ));
132    tracing::warn!("{line}");
133}
134
135/// 记录业务层 AppError
136///
137/// 由 `AppError::into_response()` 在各变体转换时调用。
138///
139/// # 参数
140///
141/// - `kind`: 错误类别标识(如 "business"、"login_expired"、"oss")
142/// - `code`: 错误码
143/// - `msg`: 错误消息(会被截断至 512 字符)
144/// - `detail`: 详细错误栈(可选,会被截断至 512 字符)
145pub fn log_app_error(kind: &str, code: i32, msg: &str, detail: Option<&str>) {
146    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
147    let extra = detail.unwrap_or("");
148    let line = format!(
149        "[{ts}] APP kind={kind} code={code} msg={} detail={}",
150        truncate(msg, MAX_BODY_LEN),
151        truncate(extra, MAX_BODY_LEN)
152    );
153
154    if let Some(rt) = runtime() {
155        if let Ok(guard) = rt.read() {
156            append_line(&guard.error_log_path, &line);
157        }
158    } else {
159        append_line(&fallback_error_log_path(), &line);
160    }
161    dev_error_log::try_persist_app_error(kind, code, msg, detail);
162    crate::telemetry::log_error_event(&format!("APP kind={kind} code={code} msg={msg}"));
163    tracing::warn!("{line}");
164}