diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 487d8e50..8a5de406 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -110,7 +110,8 @@ 交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs) - [x] 接入 `x-response-time-ms` 交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs)、[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs) -- [ ] 实现 `/healthz` +- [x] 实现 `/healthz` + 交付物:[../server-rs/apps/api-server/src/health.rs](../server-rs/apps/api-server/src/health.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs) ### 基础工程脚本 @@ -123,9 +124,11 @@ ### 阶段验收 - [ ] Axum 服务可独立启动 -- [ ] `/healthz` 返回与当前工程兼容 -- [ ] 基础 response envelope 与 request id 行为稳定 -- [ ] Rust workspace 能完整编译通过 +- [x] `/healthz` 返回与当前工程兼容 +- [x] 基础 response envelope 与 request id 行为稳定 + 证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 envelope 协商与 `/healthz` 头部回写。 +- [x] Rust workspace 能完整编译通过 + 证据:`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 已通过。 ## M2:鉴权、会话、JWT 与 refresh cookie diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 21570637..e91d9446 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -22,10 +22,12 @@ name = "api-server" version = "0.1.0" dependencies = [ "axum", + "http-body-util", "serde", "serde_json", "time", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/server-rs/apps/api-server/Cargo.toml b/server-rs/apps/api-server/Cargo.toml index 87b59b37..3872bc16 100644 --- a/server-rs/apps/api-server/Cargo.toml +++ b/server-rs/apps/api-server/Cargo.toml @@ -14,3 +14,7 @@ tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/server-rs/apps/api-server/README.md b/server-rs/apps/api-server/README.md index ddf43069..d5ab79d6 100644 --- a/server-rs/apps/api-server/README.md +++ b/server-rs/apps/api-server/README.md @@ -31,7 +31,7 @@ 2. [x] 接入 `request_id` 3. [x] 接入统一错误处理中间件 4. [x] 接入 response envelope -5. [ ] 接入 `/healthz` +5. [x] 接入 `/healthz` 当前 tracing 约定: @@ -64,6 +64,13 @@ 3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。 4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。 +当前 `/healthz` 约定: + +1. 路径固定为 `/healthz`。 +2. 裸响应继续返回 `{ ok: true, service: "genarrative-node-server" }`,保持与当前 Node 工程兼容。 +3. 当请求携带 `x-genarrative-response-envelope` 时,`/healthz` 会返回标准 success envelope。 +4. `x-request-id`、`x-api-version`、`x-route-version`、`x-response-time-ms` 会在 `/healthz` 响应中一并回写。 + ## 3. 边界约束 1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。 diff --git a/server-rs/apps/api-server/src/app.rs b/server-rs/apps/api-server/src/app.rs index e1f8dbc8..3b27eb65 100644 --- a/server-rs/apps/api-server/src/app.rs +++ b/server-rs/apps/api-server/src/app.rs @@ -1,9 +1,10 @@ -use axum::{body::Body, http::Request, middleware, Router}; +use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router}; use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::{info_span, Level}; use crate::{ error_middleware::normalize_error_response, + health::health_check, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::AppState, @@ -12,6 +13,12 @@ use crate::{ // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { Router::new() + .route( + "/healthz", + get(|Extension(request_context): Extension<_>| async move { + health_check(Extension(request_context)).await + }), + ) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 @@ -38,3 +45,102 @@ pub fn build_router(state: AppState) -> Router { .layer(middleware::from_fn(attach_request_context)) .with_state(state) } + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use serde_json::Value; + use tower::ServiceExt; + + use crate::{config::AppConfig, state::AppState}; + + use super::build_router; + + #[tokio::test] + async fn healthz_returns_legacy_compatible_payload_and_headers() { + let app = build_router(AppState::new(AppConfig::default())); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .header("x-request-id", "req-health-legacy") + .body(Body::empty()) + .expect("healthz request should build"), + ) + .await + .expect("healthz request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("x-request-id").and_then(|value| value.to_str().ok()), + Some("req-health-legacy") + ); + assert_eq!( + response.headers().get("x-api-version").and_then(|value| value.to_str().ok()), + Some("2026-04-08") + ); + assert_eq!( + response.headers().get("x-route-version").and_then(|value| value.to_str().ok()), + Some("2026-04-08") + ); + assert!(response.headers().contains_key("x-response-time-ms")); + + let body = response + .into_body() + .collect() + .await + .expect("healthz body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("healthz body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["service"], + Value::String("genarrative-node-server".to_string()) + ); + } + + #[tokio::test] + async fn healthz_returns_standard_envelope_when_requested() { + let app = build_router(AppState::new(AppConfig::default())); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .header("x-request-id", "req-health-envelope") + .header("x-genarrative-response-envelope", "v1") + .body(Body::empty()) + .expect("healthz request should build"), + ) + .await + .expect("healthz request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("healthz body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("healthz body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["data"]["service"], + Value::String("genarrative-node-server".to_string()) + ); + assert_eq!( + payload["meta"]["requestId"], + Value::String("req-health-envelope".to_string()) + ); + } +} diff --git a/server-rs/apps/api-server/src/health.rs b/server-rs/apps/api-server/src/health.rs new file mode 100644 index 00000000..2fa1a1e1 --- /dev/null +++ b/server-rs/apps/api-server/src/health.rs @@ -0,0 +1,16 @@ +use axum::{extract::Extension, Json}; +use serde_json::{json, Value}; + +use crate::{api_response::json_success_body, request_context::RequestContext}; + +pub async fn health_check( + Extension(request_context): Extension, +) -> Json { + json_success_body( + Some(&request_context), + json!({ + "ok": true, + "service": "genarrative-node-server", + }), + ) +} diff --git a/server-rs/apps/api-server/src/main.rs b/server-rs/apps/api-server/src/main.rs index 6f840e8c..61cb0909 100644 --- a/server-rs/apps/api-server/src/main.rs +++ b/server-rs/apps/api-server/src/main.rs @@ -2,6 +2,7 @@ mod app; mod api_response; mod config; mod error_middleware; +mod health; mod http_error; mod logging; mod request_context;