feat(mod): add response
This commit is contained in:
14
Cargo.toml
14
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "common-telemetry"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
edition = "2024"
|
||||
description = "Microservice infrastructure library"
|
||||
|
||||
@@ -10,7 +10,7 @@ publish = ["kellnr"]
|
||||
# 默认开启所有功能
|
||||
default = ["full"]
|
||||
full = [
|
||||
"error",
|
||||
"response",
|
||||
"telemetry",
|
||||
"with-sqlx",
|
||||
"with-redis",
|
||||
@@ -18,9 +18,11 @@ full = [
|
||||
"with-validator",
|
||||
]
|
||||
|
||||
# --- Error 模块依赖 ---
|
||||
# 开启 error feature 将引入 thiserror, axum, serde
|
||||
error = ["dep:thiserror", "dep:axum", "dep:serde", "dep:serde_json"]
|
||||
# --- Response 模块依赖 ---
|
||||
# 开启 response feature 将引入 thiserror, axum, serde
|
||||
response = ["dep:thiserror", "dep:axum", "dep:serde", "dep:serde_json"]
|
||||
|
||||
error = ["response"]
|
||||
|
||||
# --- Telemetry 模块依赖 ---
|
||||
# 开启 telemetry feature 将引入 tracing 全家桶
|
||||
@@ -34,7 +36,7 @@ with-anyhow = ["dep:anyhow"]
|
||||
with-validator = ["dep:validator"]
|
||||
|
||||
[dependencies]
|
||||
# Error 相关
|
||||
# Response 相关
|
||||
thiserror = { version = "2.0.18", optional = true }
|
||||
axum = { version = "0.8.8", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
|
||||
95
README.md
95
README.md
@@ -1,15 +1,16 @@
|
||||
# Microservice Common Lib (Rust)
|
||||
|
||||
这是微服务架构的通用基础库 (`common-telemetry`),旨在统一所有服务的错误处理标准、日志格式以及分布式链路追踪。
|
||||
这是微服务架构的通用基础库 (`common-telemetry`),用于统一服务的响应结构(成功/错误)、错误处理标准、日志格式以及分布式链路追踪。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
* **统一错误处理 (Error)**:
|
||||
* 基于 `thiserror` 和 `anyhow` 的最佳实践。
|
||||
* **双 Token 支持**: 明确区分 `AccessTokenExpired` (20001) 和 `RefreshTokenExpired` (20002)。
|
||||
* **Axum 集成**: 实现了 `IntoResponse`,自动将错误转换为标准 JSON 格式并设置正确的 HTTP 状态码。
|
||||
* **第三方库适配**: 提供了 `sqlx`, `redis`, `validator` 的可选集成。
|
||||
* *智能转换*: 例如 `sqlx::Error::RowNotFound` 会自动转换为 **404 Not Found**,而不是 500 Database Error。
|
||||
* **统一 API 响应 (Response)**:
|
||||
* **成功响应**:`AppResponse<T>` 自动映射 HTTP 状态码(200/201/202)并返回统一 JSON 结构 `{ code, message, data?, trace_id? }`。
|
||||
* **错误响应**:`AppError` 实现 `IntoResponse`,自动映射 HTTP 状态码并返回统一 JSON 结构 `{ code, message, details?, trace_id? }`。
|
||||
* **业务码 (BizCode)**:前端可根据 `code` 做稳定分支;成功恒为 `0`。
|
||||
* **双 Token 场景**:明确区分 `AccessTokenExpired` (20001) 与 `RefreshTokenExpired` (20002)。
|
||||
* **第三方库适配**:可选集成 `sqlx` / `redis` / `validator` 的错误转换。
|
||||
* 例如:`sqlx::Error::RowNotFound` 自动转换为 **404 Not Found**。
|
||||
* **可观测性 (Telemetry)**:
|
||||
* 基于 `tracing` 生态。
|
||||
* 支持 **JSON 结构化日志** (适配 ELK/Loki)。
|
||||
@@ -92,12 +93,15 @@ git push -u origin main
|
||||
### 1. 引入依赖
|
||||
|
||||
#### 方式 A: 通过 Kellnr 引入 (如果已发布)
|
||||
在 `user-service` 的 `Cargo.toml` 中,你可以根据需要开启 `sqlx` 或 `redis` 支持:
|
||||
在 `user-service` 的 `Cargo.toml` 中,你可以按需选择“全功能”或“最小依赖”。
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# 引入基础功能 + SQLX 支持
|
||||
common-telemetry = { version = "0.1", registry = "kellnr", features = ["with-sqlx", "with-validator"] }
|
||||
# 方案 1:默认全功能(等价于 features = ["full"])
|
||||
common-telemetry = { version = "0.1", registry = "kellnr" }
|
||||
|
||||
# 方案 2:只启用响应模型 + SQLX/Validator(更轻量)
|
||||
# common-telemetry = { version = "0.1", registry = "kellnr", default-features = false, features = ["response", "with-sqlx", "with-validator"] }
|
||||
|
||||
# 注意:你需要确保服务本身引用的 sqlx 版本与 common-telemetry 兼容
|
||||
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
|
||||
@@ -139,6 +143,10 @@ async fn main() {
|
||||
}
|
||||
```
|
||||
|
||||
**重要:环境变量优先级**
|
||||
|
||||
`telemetry::init` 会优先读取 `RUST_LOG` 环境变量;若未设置则使用 `TelemetryConfig.log_level`。
|
||||
|
||||
#### B. 错误处理与第三方库集成 (Handler)
|
||||
|
||||
由于实现了 `From<T>`,你可以直接使用 `?` 操作符,库会自动处理类型转换和 HTTP 映射。
|
||||
@@ -175,6 +183,32 @@ async fn create_user(
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 统一成功响应 (可选)
|
||||
|
||||
如果你希望“成功”和“错误”都返回统一的 JSON 结构(成功包含 `code/message/data/trace_id`;错误包含 `code/message/details/trace_id`),建议让 handler 返回 `AppResponse<T>`:
|
||||
|
||||
```rust
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
|
||||
async fn get_profile() -> Result<AppResponse<String>, AppError> {
|
||||
Ok(AppResponse::ok("profile data".to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
#### D. 响应体格式(前后端对齐)
|
||||
|
||||
成功响应(`AppResponse<T>`):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "Success", "data": { "any": "payload" }, "trace_id": null }
|
||||
```
|
||||
|
||||
错误响应(`AppError`):
|
||||
|
||||
```json
|
||||
{ "code": 30001, "message": "Validation error: ...", "details": "field required", "trace_id": null }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 功能模块说明 (Feature Flags)
|
||||
@@ -184,17 +218,19 @@ async fn create_user(
|
||||
| Feature | 说明 | 包含依赖 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`default`** | 默认开启全功能 | `full` |
|
||||
| **`full`** | 包含基础功能及所有第三方集成 | `error`, `telemetry`, `with-sqlx`, `with-redis`, `with-anyhow`, `with-validator` |
|
||||
| **`error`** | 仅使用基础错误处理 | `thiserror`, `axum`, `serde` |
|
||||
| **`full`** | 包含基础功能及所有第三方集成 | `response`, `telemetry`, `with-sqlx`, `with-redis`, `with-anyhow`, `with-validator` |
|
||||
| **`response`** | 统一成功/错误响应 + 错误处理 | `thiserror`, `axum`, `serde` |
|
||||
| **`telemetry`** | 仅使用日志与链路追踪 | `tracing` 全家桶 |
|
||||
| **`with-sqlx`** | 集成 `sqlx` 错误转换 | `sqlx` (自动处理 RowNotFound) |
|
||||
| **`with-redis`** | 集成 `redis` 错误转换 | `redis` |
|
||||
| **`with-validator`** | 集成 `validator` 错误转换 | `validator` |
|
||||
| **`with-anyhow`** | 集成 `anyhow` 兜底错误 | `anyhow` |
|
||||
|
||||
**示例 (只用错误 + SQLX支持):**
|
||||
兼容性说明:历史上的 `error` feature 已被重命名为 `response`,但仍保留 `error` 作为别名(`error = ["response"]`),以降低已有服务的迁移成本。
|
||||
|
||||
**示例 (只用响应 + SQLX支持):**
|
||||
```toml
|
||||
common-telemetry = { version = "0.1", default-features = false, features = ["error", "with-sqlx"] }
|
||||
common-telemetry = { version = "0.1", default-features = false, features = ["response", "with-sqlx"] }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -210,16 +246,26 @@ common-telemetry = { version = "0.1", default-features = false, features = ["err
|
||||
| `10000` | `ServerError` | 500 | 服务器内部错误 | 提示“系统繁忙” |
|
||||
| `10001` | `DbError` | 500 | 数据库错误 | 提示“系统繁忙” |
|
||||
| `10002` | `CacheError` | 500 | 缓存服务错误 | 提示“系统繁忙” |
|
||||
| `10003` | `SerializationError` | 500 | 序列化/反序列化失败 | 提示“系统繁忙” |
|
||||
| `10004` | `ExternalServiceError` | 500 | 下游/第三方调用失败 | 提示“系统繁忙” |
|
||||
| `10005` | `ConfigError` | 500 | 配置加载失败 | 提示“系统繁忙” |
|
||||
| **20xxx: 认证授权** | | | | |
|
||||
| `20000` | `Unauthorized` | 401 | 未授权/签名无效 | 跳转登录 |
|
||||
| **`20001`** | **`AccessTokenExpired`** | **401** | **Access Token 过期** | **使用 Refresh Token 静默刷新** |
|
||||
| **`20002`** | **`RefreshTokenExpired`** | **401** | **Refresh Token 过期** | **强制登出,跳转登录页** |
|
||||
| `20003` | `PermissionDenied` | 403 | 权限不足 | 提示无权访问 |
|
||||
| `20004` | `AccountDisabled` | 401 | 账号禁用/锁定 | 跳转登录或提示 |
|
||||
| `20005` | `InvalidCredentials` | 401 | 账号或密码错误 | 提示重试 |
|
||||
| `20006` | `MissingHeader` | 401 | 缺少必要 Header | 提示重试 |
|
||||
| **30xxx: 客户端错误** | | | | |
|
||||
| `30000` | `BadRequest` | 400 | 请求参数通用错误 | 提示错误信息 |
|
||||
| `30001` | `ValidationError` | 400 | 表单校验失败 | 提示具体字段错误 |
|
||||
| `30002` | `ResourceNotFound` | 404 | 资源不存在 | 提示“未找到数据” |
|
||||
| `30003` | `ResourceAlreadyExists`| 409 | 资源已存在 | 提示“重复创建” |
|
||||
| `30004` | `MethodNotAllowed` | 405 | HTTP 方法不支持 | 提示“请求方式错误” |
|
||||
| **40xxx: 业务流控/状态** | | | | |
|
||||
| `40000` | `RateLimitExceeded` | 429 | 请求过于频繁 | 退避重试 |
|
||||
| `40001` | `PreconditionFailed` | 400 | 业务前置条件不满足 | 提示原因 |
|
||||
|
||||
---
|
||||
|
||||
@@ -231,3 +277,24 @@ common-telemetry = { version = "0.1", default-features = false, features = ["err
|
||||
# 使用 --nocapture 查看详细步骤打印
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 前端 TypeScript 类型 (ApiResponse)
|
||||
|
||||
本仓库提供前后端对齐的 TypeScript 类型定义文件(非 NPM 包形式发布):
|
||||
|
||||
- 入口: [types/index.ts](file:///home/shay/project/backend/common-telemetry/types/index.ts)
|
||||
- 定义: [types/api-response.ts](file:///home/shay/project/backend/common-telemetry/types/api-response.ts)
|
||||
- 便于引用的单文件 re-export: [types.ts](file:///home/shay/project/backend/common-telemetry/types.ts)
|
||||
|
||||
前端可直接在代码库中引用(例如作为 git 子模块/直接拷贝),并使用:
|
||||
|
||||
```ts
|
||||
import { ApiResponse, isSuccessResponse } from "./path/to/common-telemetry/types";
|
||||
|
||||
function handle<T>(res: ApiResponse<T>) {
|
||||
if (isSuccessResponse(res)) return res.data;
|
||||
throw new Error(res.message);
|
||||
}
|
||||
```
|
||||
|
||||
29
src/error.rs
29
src/error.rs
@@ -46,6 +46,9 @@ pub enum BizCode {
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
// --- 基础设施错误 ---
|
||||
#[error("Config error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[cfg(feature = "with-sqlx")]
|
||||
#[error("Database error: {0}")]
|
||||
DbError(sqlx::Error),
|
||||
@@ -80,6 +83,9 @@ pub enum AppError {
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthError(String), // 通用认证失败 (签名错误、格式错误)
|
||||
|
||||
#[error("Invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("Missing authorization header")]
|
||||
MissingAuthHeader,
|
||||
|
||||
@@ -111,6 +117,9 @@ pub enum AppError {
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
// ======================================================
|
||||
// 4. 业务逻辑层 (Business Logic)
|
||||
// ======================================================
|
||||
@@ -127,6 +136,8 @@ pub struct ErrorResponse {
|
||||
pub code: u32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>, // 可选:返回 trace_id 方便排查
|
||||
}
|
||||
|
||||
@@ -157,6 +168,7 @@ impl AppError {
|
||||
// 映射 HTTP 状态码 (给网关/浏览器看)
|
||||
fn http_status(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
#[cfg(feature = "with-sqlx")]
|
||||
AppError::DbError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
#[cfg(feature = "with-redis")]
|
||||
@@ -169,6 +181,7 @@ impl AppError {
|
||||
| AppError::SerdeError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
// 401 Unauthorized
|
||||
AppError::AuthError(_)
|
||||
| AppError::InvalidCredentials
|
||||
| AppError::MissingAuthHeader
|
||||
| AppError::AccessTokenExpired
|
||||
| AppError::RefreshTokenExpired
|
||||
@@ -186,6 +199,8 @@ impl AppError {
|
||||
// 429 Too Many Requests
|
||||
AppError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
|
||||
|
||||
AppError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
|
||||
|
||||
// 400 Bad Request (默认)
|
||||
#[cfg(feature = "with-validator")]
|
||||
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
@@ -197,6 +212,7 @@ impl AppError {
|
||||
fn biz_code(&self) -> BizCode {
|
||||
match self {
|
||||
// Infra
|
||||
AppError::ConfigError(_) => BizCode::ConfigError,
|
||||
#[cfg(feature = "with-sqlx")]
|
||||
AppError::DbError(_) => BizCode::DbError,
|
||||
#[cfg(feature = "with-redis")]
|
||||
@@ -209,6 +225,7 @@ impl AppError {
|
||||
|
||||
// Auth
|
||||
AppError::AuthError(_) => BizCode::Unauthorized,
|
||||
AppError::InvalidCredentials => BizCode::InvalidCredentials,
|
||||
AppError::MissingAuthHeader => BizCode::MissingHeader,
|
||||
AppError::AccessTokenExpired => BizCode::AccessTokenExpired,
|
||||
AppError::RefreshTokenExpired => BizCode::RefreshTokenExpired,
|
||||
@@ -221,12 +238,22 @@ impl AppError {
|
||||
AppError::BadRequest(_) => BizCode::BadRequest,
|
||||
#[cfg(feature = "with-validator")]
|
||||
AppError::ValidationError(_) => BizCode::ValidationError,
|
||||
AppError::MethodNotAllowed => BizCode::MethodNotAllowed,
|
||||
|
||||
// Biz
|
||||
AppError::RateLimitExceeded => BizCode::RateLimitExceeded,
|
||||
AppError::BusinessLogicError(_) => BizCode::PreconditionFailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn details(&self) -> Option<serde_json::Value> {
|
||||
match self {
|
||||
AppError::BadRequest(details) => Some(serde_json::Value::String(details.clone())),
|
||||
#[cfg(feature = "with-validator")]
|
||||
AppError::ValidationError(details) => Some(serde_json::Value::String(details.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 核心:实现 Axum 的 IntoResponse
|
||||
@@ -234,6 +261,7 @@ impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.http_status();
|
||||
let biz_code = self.biz_code();
|
||||
let details = self.details();
|
||||
// 生产环境通常不把详细的 DB 报错返回给前端,防止泄露表结构
|
||||
// 但这里为了演示,我们先直接使用 self.to_string()
|
||||
// 建议:在生产环境针对 DbError/AnyhowError 返回统一的 "Internal Server Error"
|
||||
@@ -293,6 +321,7 @@ impl IntoResponse for AppError {
|
||||
let body = Json(ErrorResponse {
|
||||
code: biz_code as u32,
|
||||
message,
|
||||
details,
|
||||
trace_id: None, // 实际项目中从 tracing context 获取
|
||||
});
|
||||
|
||||
|
||||
12
src/lib.rs
12
src/lib.rs
@@ -1,11 +1,17 @@
|
||||
// 只有开启了 'error' feature 才会编译 error 模块
|
||||
#[cfg(feature = "error")]
|
||||
// 只有开启了 'response' feature 才会编译响应模块
|
||||
#[cfg(feature = "response")]
|
||||
pub mod error;
|
||||
|
||||
#[cfg(feature = "response")]
|
||||
pub mod response;
|
||||
|
||||
// 只有开启了 'telemetry' feature 才会编译 telemetry 模块
|
||||
#[cfg(feature = "telemetry")]
|
||||
pub mod telemetry;
|
||||
|
||||
// 方便外部直接 use common_lib::AppError;
|
||||
#[cfg(feature = "error")]
|
||||
#[cfg(feature = "response")]
|
||||
pub use error::{AppError, BizCode};
|
||||
|
||||
#[cfg(feature = "response")]
|
||||
pub use response::{AppResponse, SuccessResponse};
|
||||
|
||||
112
src/response.rs
Normal file
112
src/response.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::BizCode;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SuccessResponse<T> {
|
||||
pub code: u32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
}
|
||||
|
||||
pub enum AppResponse<T> {
|
||||
Ok(SuccessResponse<T>),
|
||||
Created(SuccessResponse<T>),
|
||||
Accepted(SuccessResponse<T>),
|
||||
}
|
||||
|
||||
impl<T> AppResponse<T> {
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self::Ok(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Success".into(),
|
||||
data: Some(data),
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn created(data: T) -> Self {
|
||||
Self::Created(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Created".into(),
|
||||
data: Some(data),
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn accepted(data: T) -> Self {
|
||||
Self::Accepted(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Accepted".into(),
|
||||
data: Some(data),
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn message(mut self, message: impl Into<String>) -> Self {
|
||||
let message = message.into();
|
||||
match &mut self {
|
||||
AppResponse::Ok(body) | AppResponse::Created(body) | AppResponse::Accepted(body) => {
|
||||
body.message = message;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
|
||||
let trace_id = trace_id.into();
|
||||
match &mut self {
|
||||
AppResponse::Ok(body) | AppResponse::Created(body) | AppResponse::Accepted(body) => {
|
||||
body.trace_id = Some(trace_id);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AppResponse<()> {
|
||||
pub fn ok_empty() -> Self {
|
||||
Self::Ok(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Success".into(),
|
||||
data: None,
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn created_empty() -> Self {
|
||||
Self::Created(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Created".into(),
|
||||
data: None,
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn accepted_empty() -> Self {
|
||||
Self::Accepted(SuccessResponse {
|
||||
code: BizCode::Success as u32,
|
||||
message: "Accepted".into(),
|
||||
data: None,
|
||||
trace_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> IntoResponse for AppResponse<T> {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
AppResponse::Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||
AppResponse::Created(body) => (StatusCode::CREATED, Json(body)).into_response(),
|
||||
AppResponse::Accepted(body) => (StatusCode::ACCEPTED, Json(body)).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::{Router, body::Body, routing::get};
|
||||
use common_telemetry::{
|
||||
error::AppError,
|
||||
response::AppResponse,
|
||||
telemetry::{self, TelemetryConfig},
|
||||
};
|
||||
use http::{Request, StatusCode};
|
||||
@@ -28,6 +29,19 @@ async fn handler_success() -> Result<String, AppError> {
|
||||
Ok("Success Data".to_string())
|
||||
}
|
||||
|
||||
async fn handler_success_wrapped() -> Result<AppResponse<String>, AppError> {
|
||||
Ok(AppResponse::ok("Success Data".to_string()))
|
||||
}
|
||||
|
||||
async fn handler_bad_request() -> Result<String, AppError> {
|
||||
Err(AppError::BadRequest("bad params".into()))
|
||||
}
|
||||
|
||||
#[cfg(feature = "with-validator")]
|
||||
async fn handler_validation_error() -> Result<String, AppError> {
|
||||
Err(AppError::ValidationError("field required".into()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_flow_error_and_logging() {
|
||||
println!(">>> [Step 0] Test Initializing...");
|
||||
@@ -55,7 +69,12 @@ async fn test_full_flow_error_and_logging() {
|
||||
let app = Router::new()
|
||||
.route("/access-expired", get(handler_access_expired))
|
||||
.route("/refresh-expired", get(handler_refresh_expired))
|
||||
.route("/success", get(handler_success));
|
||||
.route("/success", get(handler_success))
|
||||
.route("/success-wrapped", get(handler_success_wrapped))
|
||||
.route("/bad-request", get(handler_bad_request));
|
||||
|
||||
#[cfg(feature = "with-validator")]
|
||||
let app = app.route("/validation-error", get(handler_validation_error));
|
||||
|
||||
println!(">>> [Step 1] Running Case A: Access Token Expired...");
|
||||
// --- 测试用例 A: Access Token 过期 ---
|
||||
@@ -120,6 +139,71 @@ async fn test_full_flow_error_and_logging() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// --- 测试用例 D: 统一成功响应格式 ---
|
||||
println!(">>> [Step 4] Running Case D: Success Response Wrapper...");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/success-wrapped")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(body["code"], 0);
|
||||
assert_eq!(body["message"], "Success");
|
||||
assert_eq!(body["data"], "Success Data");
|
||||
|
||||
// --- 测试用例 E: 错误响应 details 字段 ---
|
||||
println!(">>> [Step 5] Running Case E: Error Response Details...");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/bad-request")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(body["code"], 30000);
|
||||
assert_eq!(body["details"], "bad params");
|
||||
|
||||
#[cfg(feature = "with-validator")]
|
||||
{
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/validation-error")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
assert_eq!(body["code"], 30001);
|
||||
assert_eq!(body["details"], "field required");
|
||||
}
|
||||
|
||||
// 稍微等待异步日志写入磁盘
|
||||
println!(" Waiting for logs to flush...");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
50
types/api-response.ts
Normal file
50
types/api-response.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const BizCode = {
|
||||
Success: 0,
|
||||
ServerError: 10000,
|
||||
DbError: 10001,
|
||||
CacheError: 10002,
|
||||
SerializationError: 10003,
|
||||
ExternalServiceError: 10004,
|
||||
ConfigError: 10005,
|
||||
Unauthorized: 20000,
|
||||
AccessTokenExpired: 20001,
|
||||
RefreshTokenExpired: 20002,
|
||||
PermissionDenied: 20003,
|
||||
AccountDisabled: 20004,
|
||||
InvalidCredentials: 20005,
|
||||
MissingHeader: 20006,
|
||||
BadRequest: 30000,
|
||||
ValidationError: 30001,
|
||||
ResourceNotFound: 30002,
|
||||
ResourceAlreadyExists: 30003,
|
||||
MethodNotAllowed: 30004,
|
||||
RateLimitExceeded: 40000,
|
||||
PreconditionFailed: 40001,
|
||||
} as const;
|
||||
|
||||
export type BizCode = (typeof BizCode)[keyof typeof BizCode];
|
||||
|
||||
export type SuccessResponse<T> = {
|
||||
code: typeof BizCode.Success;
|
||||
message: string;
|
||||
data?: T;
|
||||
trace_id?: string;
|
||||
};
|
||||
|
||||
export type ErrorResponse = {
|
||||
code: Exclude<BizCode, typeof BizCode.Success> | number;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
trace_id?: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
export function isSuccessResponse<T>(res: ApiResponse<T>): res is SuccessResponse<T> {
|
||||
return res.code === BizCode.Success;
|
||||
}
|
||||
|
||||
export function isErrorResponse<T>(res: ApiResponse<T>): res is ErrorResponse {
|
||||
return res.code !== BizCode.Success;
|
||||
}
|
||||
|
||||
2
types/index.ts
Normal file
2
types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./api-response";
|
||||
|
||||
Reference in New Issue
Block a user