feat(project): init
This commit is contained in:
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[registries.kellnr]
|
||||
index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/"
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
.env
|
||||
43
Cargo.toml
Normal file
43
Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "common-telemetry"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Microservice infrastructure library"
|
||||
|
||||
publish = ["kellnr"]
|
||||
|
||||
[features]
|
||||
# 默认开启所有功能
|
||||
default = ["full"]
|
||||
full = ["error", "telemetry"]
|
||||
|
||||
# --- Error 模块依赖 ---
|
||||
# 开启 error feature 将引入 thiserror, axum, serde
|
||||
error = ["dep:thiserror", "dep:axum", "dep:serde", "dep:serde_json"]
|
||||
|
||||
# --- Telemetry 模块依赖 ---
|
||||
# 开启 telemetry feature 将引入 tracing 全家桶
|
||||
telemetry = ["dep:tracing", "dep:tracing-subscriber", "dep:tracing-appender"]
|
||||
|
||||
[dependencies]
|
||||
# Error 相关
|
||||
thiserror = { version = "2.0.18", optional = true }
|
||||
axum = { version = "0.8.8", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
|
||||
# Telemetry 相关
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = [
|
||||
"env-filter",
|
||||
"json",
|
||||
], optional = true }
|
||||
tracing-appender = { version = "0.2", optional = true }
|
||||
|
||||
# --- 新增测试依赖 ---
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = { version = "0.5.3", features = ["util"] } # 用于模拟 Web 请求
|
||||
http = "1.0"
|
||||
http-body-util = "0.1" # 用于读取 Body
|
||||
tempfile = "3.8" # 用于创建临时日志目录
|
||||
203
README.md
Normal file
203
README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Microservice Common Lib (Rust)
|
||||
|
||||
这是微服务架构的通用基础库,旨在统一所有服务的错误处理标准、日志格式以及分布式链路追踪。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
* **统一错误处理 (Error)**:
|
||||
* 基于 `thiserror` 和 `anyhow` 的最佳实践。
|
||||
* **双 Token 支持**: 明确区分 `AccessTokenExpired` (20001) 和 `RefreshTokenExpired` (20002)。
|
||||
* **Axum 集成**: 实现了 `IntoResponse`,自动将错误转换为标准 JSON 格式并设置正确的 HTTP 状态码。
|
||||
* **可观测性 (Telemetry)**:
|
||||
* 基于 `tracing` 生态。
|
||||
* 支持 **JSON 结构化日志** (适配 ELK/Loki)。
|
||||
* 支持 **控制台 + 文件双写**。
|
||||
* 支持 **非阻塞 (Non-blocking)** 异步日志写入与按天滚动。
|
||||
* **模块化设计**: 通过 Feature Flags 按需引入。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 发布指南
|
||||
|
||||
你可以选择将此库发布到私有 Cargo 仓库 (Kellnr),或者直接作为 Git 依赖发布到 Gitea。
|
||||
|
||||
### 方法一:发布到 Kellnr (推荐)
|
||||
|
||||
Kellnr 是一个私有的 Crates.io 镜像与仓库。
|
||||
|
||||
#### 1. 配置本地 Cargo
|
||||
在本项目根目录(或全局 `~/.cargo/config.toml`)创建/修改 `.cargo/config.toml`,注册你的私有仓库:
|
||||
|
||||
```toml
|
||||
# .cargo/config.toml
|
||||
[registries.kellnr]
|
||||
# 注册表名称自定义,这里叫 "kellnr"
|
||||
index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/"
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
```
|
||||
|
||||
#### 2. 登录认证
|
||||
使用你的 Kellnr 账户 Token 进行登录(只需执行一次):
|
||||
|
||||
```bash
|
||||
cargo login --registry kellnr <YOUR_AUTH_TOKEN>
|
||||
```
|
||||
|
||||
#### 3. 修改 Cargo.toml
|
||||
确保 `Cargo.toml` 中配置了禁止发布到公网,并指定了私有仓库:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "common-telemetry"
|
||||
version = "0.1.0"
|
||||
# ...
|
||||
publish = ["kellnr"] # 关键:防止误发到 crates.io
|
||||
```
|
||||
|
||||
#### 4. 执行发布
|
||||
```bash
|
||||
cargo publish --registry kellnr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方法二:推送到 Gitea (Git 依赖)
|
||||
|
||||
如果你不想走 Cargo Registry 流程,可以直接作为 Git 仓库使用。
|
||||
|
||||
```bash
|
||||
# 初始化 git (如果尚未初始化)
|
||||
git init
|
||||
git branch -M main
|
||||
|
||||
# 添加远程仓库 (替换为你的实际仓库地址)
|
||||
git remote add origin ssh://git@gitea.shay7sev.site:2222/admin/common-telemetry.git
|
||||
|
||||
# 推送代码
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 如何在其他服务中使用
|
||||
|
||||
假设你要在 `user-service` 中使用此库。
|
||||
|
||||
### 1. 引入依赖
|
||||
|
||||
#### 方式 A: 通过 Kellnr 引入 (如果已发布)
|
||||
在 `user-service` 的 `Cargo.toml` 中:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# 指定 registry
|
||||
common-telemetry = { version = "0.1", registry = "kellnr", features = ["full"] }
|
||||
```
|
||||
*注意:使用此方式,`user-service` 项目也需要在 `.cargo/config.toml` 中配置 registry 地址。*
|
||||
|
||||
#### 方式 B: 通过 Gitea (Git) 引入 (简单直接)
|
||||
在 `user-service` 的 `Cargo.toml` 中:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# 指定 git 地址
|
||||
common-telemetry = { git = "ssh://git@gitea.shay7sev.site:2222/admin/common-telemetry.git", branch = "main", features = ["full"] }
|
||||
```
|
||||
|
||||
### 2. 代码集成示例
|
||||
|
||||
#### A. 初始化日志 (main.rs)
|
||||
|
||||
```rust
|
||||
use common_lib::telemetry::{self, TelemetryConfig};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 1. 配置
|
||||
let config = TelemetryConfig {
|
||||
service_name: "user-service".into(),
|
||||
log_level: "info".into(), // 或 "debug,sqlx=error"
|
||||
log_to_file: true, // 生产环境建议开启
|
||||
log_dir: Some("./logs".into()),
|
||||
log_file: Some("user.log".into()),
|
||||
};
|
||||
|
||||
// 2. 初始化 Tracing
|
||||
// !!! 警告:必须将 guard 赋值给一个变量 (_guard),并保持它在 main 函数整个生命周期内存活
|
||||
// 否则日志文件写入线程会被立即销毁!
|
||||
let _guard = telemetry::init(config);
|
||||
|
||||
tracing::info!("User Service started success!");
|
||||
|
||||
// ... 启动 Axum ...
|
||||
}
|
||||
```
|
||||
|
||||
#### B. 错误处理与响应 (Handler)
|
||||
|
||||
```rust
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use common_lib::AppError; // 引入统一错误
|
||||
|
||||
async fn get_profile(token: String) -> Result<Json<UserProfile>, AppError> {
|
||||
// 模拟:Token 过期
|
||||
if token_is_expired(&token) {
|
||||
// 直接返回枚举,库会自动处理为 JSON Response (Code: 20001)
|
||||
// 并且会自动打印 Error 级别的日志
|
||||
return Err(AppError::AccessTokenExpired);
|
||||
}
|
||||
|
||||
// 模拟:数据库错误
|
||||
let user = db::find_user().await
|
||||
.map_err(|e| AppError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(Json(user))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 功能模块说明 (Feature Flags)
|
||||
|
||||
为了减少编译时间和依赖大小,你可以按需开启功能:
|
||||
|
||||
| Feature | 说明 | 包含依赖 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`default`** | 默认开启所有功能 | `full` |
|
||||
| **`full`** | 全功能集合 | `error`, `telemetry` |
|
||||
| **`error`** | 仅使用错误处理与 HTTP 响应 | `thiserror`, `axum`, `serde` |
|
||||
| **`telemetry`** | 仅使用日志与链路追踪 | `tracing`, `tracing-subscriber`, `tracing-appender` |
|
||||
|
||||
**示例 (只用日志模块):**
|
||||
```toml
|
||||
common-telemetry = { version = "0.1", default-features = false, features = ["telemetry"] }
|
||||
```
|
||||
|
||||
## 📝 错误码对照表
|
||||
|
||||
前端开发请参考以下业务状态码 (Code):
|
||||
|
||||
| Code | 枚举 | 含义 | 前端建议动作 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `0` | `Success` | 成功 | - |
|
||||
| `10000` | `ServerError` | 服务器内部错误 | 提示“系统繁忙” |
|
||||
| `10001` | `BadRequest` | 请求参数错误 | 提示错误信息 |
|
||||
| `20000` | `Unauthorized` | 未授权/签名无效 | 跳转登录 |
|
||||
| **`20001`** | **`AccessTokenExpired`** | **Access Token 过期** | **使用 Refresh Token 静默刷新** |
|
||||
| **`20002`** | **`RefreshTokenExpired`** | **Refresh Token 过期** | **强制登出,跳转登录页** |
|
||||
| `20003` | `PermissionDenied` | 权限不足 | 提示无权访问 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠 开发注意事项
|
||||
|
||||
1. **Cargo.lock**: 本项目已将 `Cargo.lock` 加入 `.gitignore`,这是作为 Library 的最佳实践。
|
||||
2. **测试**: 运行集成测试以验证全链路功能。
|
||||
```bash
|
||||
# 使用 --nocapture 查看详细步骤打印
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
126
src/error.rs
Normal file
126
src/error.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// 业务状态码 (Business Code)
|
||||
/// 前端根据此 Code 进行逻辑跳转
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[repr(u32)]
|
||||
pub enum BizCode {
|
||||
Success = 0,
|
||||
|
||||
// 10xxx: 服务端通用错误
|
||||
ServerError = 10000,
|
||||
BadRequest = 10001,
|
||||
|
||||
// 20xxx: 认证授权相关 (双 Token 核心逻辑)
|
||||
Unauthorized = 20000, // 通用未授权/签名错误
|
||||
AccessTokenExpired = 20001, // 前端捕获 -> 用 RefreshToken 换新 AccessToken (静默)
|
||||
RefreshTokenExpired = 20002, // 前端捕获 -> 强制退出到登录页
|
||||
PermissionDenied = 20003,
|
||||
}
|
||||
|
||||
/// 全局应用错误枚举
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
// --- 基础设施错误 ---
|
||||
#[error("Database error: {0}")]
|
||||
DbError(String), // 实际建议: DbError(#[from] sqlx::Error)
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
// --- 业务逻辑错误 ---
|
||||
#[error("Resource not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid parameters: {0}")]
|
||||
InvalidParam(String),
|
||||
|
||||
// --- 认证相关 ---
|
||||
#[error("Authentication failed")]
|
||||
AuthError,
|
||||
|
||||
#[error("Access token expired")]
|
||||
AccessTokenExpired,
|
||||
|
||||
#[error("Refresh token expired")]
|
||||
RefreshTokenExpired,
|
||||
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
}
|
||||
|
||||
/// 响应给前端的 JSON 结构
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub code: u32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>, // 可选:返回 trace_id 方便排查
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
// 映射 HTTP 状态码 (给网关/浏览器看)
|
||||
fn http_status(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::DbError(_) | AppError::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::InvalidParam(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::AuthError | AppError::AccessTokenExpired | AppError::RefreshTokenExpired => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
AppError::PermissionDenied(_) => StatusCode::FORBIDDEN,
|
||||
}
|
||||
}
|
||||
|
||||
// 映射业务状态码 (给前端代码看)
|
||||
fn biz_code(&self) -> BizCode {
|
||||
match self {
|
||||
AppError::DbError(_) | AppError::IoError(_) => BizCode::ServerError,
|
||||
AppError::NotFound(_) | AppError::InvalidParam(_) => BizCode::BadRequest,
|
||||
AppError::AuthError => BizCode::Unauthorized,
|
||||
AppError::AccessTokenExpired => BizCode::AccessTokenExpired, // 关键
|
||||
AppError::RefreshTokenExpired => BizCode::RefreshTokenExpired, // 关键
|
||||
AppError::PermissionDenied(_) => BizCode::PermissionDenied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 核心:实现 Axum 的 IntoResponse
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.http_status();
|
||||
let biz_code = self.biz_code();
|
||||
let message = self.to_string();
|
||||
|
||||
// 尝试获取当前的 Trace ID (如果有 telemetry 功能)
|
||||
// 这里只是简单的占位,实际需要配合 opentelemetry 获取 span id
|
||||
let trace_id = None;
|
||||
|
||||
// 1. 自动记录错误日志 (利用 tracing)
|
||||
// 这样业务代码里只需要 return Err(...),不需要手动 error!(...)
|
||||
#[cfg(feature = "telemetry")]
|
||||
{
|
||||
tracing::error!(
|
||||
%status,
|
||||
code = ?biz_code,
|
||||
error = %message,
|
||||
"Request failed"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 构建 JSON
|
||||
let body = Json(ErrorResponse {
|
||||
code: biz_code as u32,
|
||||
message,
|
||||
trace_id,
|
||||
});
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// 只有开启了 'error' feature 才会编译 error 模块
|
||||
#[cfg(feature = "error")]
|
||||
pub mod error;
|
||||
|
||||
// 只有开启了 'telemetry' feature 才会编译 telemetry 模块
|
||||
#[cfg(feature = "telemetry")]
|
||||
pub mod telemetry;
|
||||
|
||||
// 方便外部直接 use common_lib::AppError;
|
||||
#[cfg(feature = "error")]
|
||||
pub use error::{AppError, BizCode};
|
||||
81
src/telemetry.rs
Normal file
81
src/telemetry.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
/// 遥测配置
|
||||
pub struct TelemetryConfig {
|
||||
pub service_name: String, // 服务名 (如 "user-service")
|
||||
pub log_level: String, // 日志级别 (如 "info" 或 "debug,sqlx=error")
|
||||
pub log_to_file: bool, // 是否输出到文件
|
||||
pub log_dir: Option<String>, // 日志目录
|
||||
pub log_file: Option<String>, // 日志文件名
|
||||
}
|
||||
|
||||
impl Default for TelemetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
service_name: "unknown-service".into(),
|
||||
log_level: "info".into(),
|
||||
log_to_file: false,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化 Tracing 系统
|
||||
///
|
||||
/// # 返回值
|
||||
/// 返回 `Option<WorkerGuard>`。
|
||||
/// **重要**:这个 Guard 必须一直存活到 main 函数结束。如果被丢弃,文件日志将无法写入。
|
||||
pub fn init(config: TelemetryConfig) -> Option<WorkerGuard> {
|
||||
// 1. 设置过滤器 (RUST_LOG 环境变量优先,否则用 config)
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log_level));
|
||||
|
||||
// 2. 基础的 Console 输出层
|
||||
let stdout_layer = fmt::layer()
|
||||
.json() // 生产环境通常用 json
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(std::io::stdout);
|
||||
|
||||
// 3. 准备注册表
|
||||
let subscriber = Registry::default().with(env_filter);
|
||||
|
||||
// 4. 判断是否需要文件输出
|
||||
if config.log_to_file {
|
||||
let dir = config.log_dir.unwrap_or_else(|| "logs".into());
|
||||
let file = config.log_file.unwrap_or_else(|| "app.log".into());
|
||||
|
||||
// 设置滚动日志 (每天轮转)
|
||||
let file_appender = tracing_appender::rolling::daily(&dir, &file);
|
||||
|
||||
// 设置非阻塞写入 (关键性能点)
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.json()
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false); // 文件不需要颜色
|
||||
|
||||
// 组合:Console + File
|
||||
subscriber.with(stdout_layer).with(file_layer).init();
|
||||
|
||||
tracing::info!(
|
||||
"Telemetry initialized (Console + File) for {}",
|
||||
config.service_name
|
||||
);
|
||||
Some(guard)
|
||||
} else {
|
||||
// 组合:Console Only
|
||||
subscriber.with(stdout_layer).init();
|
||||
|
||||
tracing::info!(
|
||||
"Telemetry initialized (Console Only) for {}",
|
||||
config.service_name
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
155
tests/integration_test.rs
Normal file
155
tests/integration_test.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use axum::{Router, body::Body, routing::get};
|
||||
use common_telemetry::{
|
||||
error::AppError,
|
||||
telemetry::{self, TelemetryConfig},
|
||||
};
|
||||
use http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::{fs, time::Duration};
|
||||
use tower::ServiceExt; // for oneshot
|
||||
|
||||
// --- 模拟业务 Handler ---
|
||||
|
||||
// 模拟场景1:Access Token 过期
|
||||
async fn handler_access_expired() -> Result<String, AppError> {
|
||||
// 模拟业务逻辑判断...
|
||||
Err(AppError::AccessTokenExpired)
|
||||
}
|
||||
|
||||
// 模拟场景2:Refresh Token 过期
|
||||
async fn handler_refresh_expired() -> Result<String, AppError> {
|
||||
Err(AppError::RefreshTokenExpired)
|
||||
}
|
||||
|
||||
// 模拟场景3:正常成功
|
||||
async fn handler_success() -> Result<String, AppError> {
|
||||
// 这里打印一条日志,测试 Tracing 是否工作
|
||||
tracing::info!(user_id = 10086, "User accessed success handler");
|
||||
Ok("Success Data".to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_flow_error_and_logging() {
|
||||
println!(">>> [Step 0] Test Initializing...");
|
||||
// 1. 准备环境:创建一个临时目录存放日志,防止污染项目
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let log_dir_path = temp_dir.path().to_str().unwrap().to_string();
|
||||
let log_filename = "test_app.log";
|
||||
|
||||
// 2. 初始化 Tracing (模拟生产环境配置)
|
||||
let config = TelemetryConfig {
|
||||
service_name: "test-service".into(),
|
||||
log_level: "info".into(),
|
||||
log_to_file: true, // 开启文件写入
|
||||
log_dir: Some(log_dir_path.clone()),
|
||||
log_file: Some(log_filename.to_string()),
|
||||
};
|
||||
|
||||
// !!! 必须持有 guard,否则日志不会写入
|
||||
let _guard = telemetry::init(config);
|
||||
|
||||
// 给一点时间让 tracing 系统初始化
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// 3. 构建 Axum Router
|
||||
let app = Router::new()
|
||||
.route("/access-expired", get(handler_access_expired))
|
||||
.route("/refresh-expired", get(handler_refresh_expired))
|
||||
.route("/success", get(handler_success));
|
||||
|
||||
println!(">>> [Step 1] Running Case A: Access Token Expired...");
|
||||
// --- 测试用例 A: Access Token 过期 ---
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/access-expired")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 验证 HTTP 状态码 (401)
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// 验证 JSON Body
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
|
||||
// 核心验证:code 必须是 20001 (BizCode::AccessTokenExpired)
|
||||
// 前端根据这个 code 进行静默刷新
|
||||
assert_eq!(body["code"], 20001);
|
||||
|
||||
// --- 测试用例 B: Refresh Token 过期 ---
|
||||
println!(">>> [Step 2] Running Case B: Refresh Token Expired...");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/refresh-expired")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
|
||||
// 核心验证:code 必须是 20002 (BizCode::RefreshTokenExpired)
|
||||
// 前端根据这个 code 强制登出
|
||||
assert_eq!(body["code"], 20002);
|
||||
|
||||
// --- 测试用例 C: 验证日志文件是否生成 ---
|
||||
println!(">>> [Step 3] Running Case C: Log File Verification...");
|
||||
// 先请求一次成功接口,触发 tracing::info!
|
||||
let _ = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/success")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 稍微等待异步日志写入磁盘
|
||||
println!(" Waiting for logs to flush...");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// 验证文件
|
||||
let mut found_file = false;
|
||||
let paths = fs::read_dir(temp_dir.path()).unwrap();
|
||||
|
||||
for path in paths {
|
||||
let entry = path.unwrap();
|
||||
let file_name = entry.file_name().into_string().unwrap();
|
||||
|
||||
println!(" Found file in temp dir: {}", file_name);
|
||||
|
||||
// tracing-appender 滚动日志通常格式为: filename.YYYY-MM-DD
|
||||
if file_name.starts_with(log_filename) {
|
||||
found_file = true;
|
||||
let content = fs::read_to_string(entry.path()).unwrap();
|
||||
println!(" Log Content Preview: {}", content.trim());
|
||||
|
||||
assert!(
|
||||
content.contains("User accessed success handler"),
|
||||
"日志内容丢失!"
|
||||
);
|
||||
assert!(content.contains("\"user_id\":10086"), "结构化字段丢失!");
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found_file, "未在临时目录找到日志文件!");
|
||||
println!(" [Success] Case C Passed (Log file verified)");
|
||||
|
||||
// temp_dir 会在作用域结束时自动删除清理
|
||||
}
|
||||
Reference in New Issue
Block a user