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