feat(mod): add response

This commit is contained in:
2026-01-29 18:09:58 +08:00
parent 54058478c4
commit 4db955113c
9 changed files with 379 additions and 25 deletions

View File

@@ -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 }

View File

@@ -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 | 业务前置条件不满足 | 提示原因 |
---
@@ -230,4 +276,25 @@ common-telemetry = { version = "0.1", default-features = false, features = ["err
```bash
# 使用 --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);
}
```

View File

@@ -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 获取
});

View File

@@ -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
View 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(),
}
}
}

View File

@@ -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;

2
types.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./types";

50
types/api-response.ts Normal file
View 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
View File

@@ -0,0 +1,2 @@
export * from "./api-response";