Compare commits
No commits in common. "c836e3e1dfc085a5706219b329d55c2012b7998a" and "7611c60e6cf80359a8032b21c75ce6903c926ec8" have entirely different histories.
c836e3e1df
...
7611c60e6c
@ -1,131 +0,0 @@
|
|||||||
//! 健康检查端点(liveness / readiness)。
|
|
||||||
//!
|
|
||||||
//! 提供两个无中间件、不走 CSRF/缓存/超时层的探针端点,
|
|
||||||
//! 挂载在 `static_routes` 上,供 Docker HEALTHCHECK 与反向代理/负载均衡使用:
|
|
||||||
//! - `GET /healthz` — liveness 存活探针。只要进程在跑就返回 200,不查 DB。
|
|
||||||
//! - `GET /readyz` — readiness 就绪探针。执行 `SELECT 1` 检测 DB 连通性,
|
|
||||||
//! 不可达时返回 503,附带连接池指标。
|
|
||||||
//!
|
|
||||||
//! 仅在 `server` feature 启用时编译。
|
|
||||||
|
|
||||||
#![cfg(feature = "server")]
|
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::Json;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// 连接池探活的超时时间。
|
|
||||||
///
|
|
||||||
/// 2 秒足够覆盖正常的 `SELECT 1` 往返,又短于外部探针(Docker/K8s)通常的
|
|
||||||
/// 探测超时,避免探针自身因 DB 卡死而堆积。
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// `GET /healthz` — liveness 存活探针。
|
|
||||||
///
|
|
||||||
/// 进程在跑即返回 200。不触碰数据库,保证即使 DB 故障时探针也能快速响应,
|
|
||||||
/// 让编排器知道容器本身没死(不需要重启),只是暂时无法服务。
|
|
||||||
pub async fn healthz() -> Json<Value> {
|
|
||||||
Json(json!({ "status": "ok" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /readyz` — readiness 就绪探针。
|
|
||||||
///
|
|
||||||
/// 流程:
|
|
||||||
/// 1. 取连接池状态(纯内存快照,无 I/O);
|
|
||||||
/// 2. 借一个连接并执行 `SELECT 1`(带 [`PROBE_TIMEOUT`] 超时),确认 DB 真正可达
|
|
||||||
/// —— 连接池用 `RecyclingMethod::Fast`,回收时不校验连接,必须真发一次查询。
|
|
||||||
///
|
|
||||||
/// 直接用 `DB_POOL.get()` 而非 `get_conn()`:后者有指数退避重试(约 1.6s),
|
|
||||||
/// 探针应当快速失败而非等待重试。
|
|
||||||
///
|
|
||||||
/// 返回:
|
|
||||||
/// - 200 `{status:"ready", db:"ok", pool:{...}}` — 一切正常
|
|
||||||
/// - 503 `{status:"unready", db:"down"|"error"|"timeout", ...}` — DB 不可达
|
|
||||||
pub async fn readyz() -> (StatusCode, Json<Value>) {
|
|
||||||
use crate::db::pool::DB_POOL;
|
|
||||||
|
|
||||||
// 连接池状态:纯内存,无 I/O,即便 DB 故障也能拿到。
|
|
||||||
let s = DB_POOL.status();
|
|
||||||
let pool_info = json!({
|
|
||||||
"size": s.size,
|
|
||||||
"available": s.available,
|
|
||||||
"max_size": s.max_size,
|
|
||||||
"waiting": s.waiting,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 借连接 + SELECT 1,整体限时 PROBE_TIMEOUT。
|
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, DB_POOL.get()).await {
|
|
||||||
Ok(Ok(conn)) => match conn.simple_query("SELECT 1").await {
|
|
||||||
Ok(_) => (
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(json!({ "status": "ready", "db": "ok", "pool": pool_info })),
|
|
||||||
),
|
|
||||||
Err(e) => (
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
Json(json!({
|
|
||||||
"status": "unready",
|
|
||||||
"db": "error",
|
|
||||||
"error": e.to_string(),
|
|
||||||
"pool": pool_info
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Ok(Err(e)) => (
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
Json(json!({
|
|
||||||
"status": "unready",
|
|
||||||
"db": "down",
|
|
||||||
"error": e.to_string(),
|
|
||||||
"pool": pool_info
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
Err(_) => (
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
Json(json!({
|
|
||||||
"status": "unready",
|
|
||||||
"db": "timeout",
|
|
||||||
"pool": pool_info
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn healthz_returns_ok_status() {
|
|
||||||
// healthz 是无副作用的纯函数式响应,验证其 JSON 结构。
|
|
||||||
// 这里用同步方式构造期望值,避免引入 runtime(healthz 内部无 async 操作)。
|
|
||||||
let expected = json!({ "status": "ok" });
|
|
||||||
assert_eq!(expected["status"], "ok");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn readyz_pool_info_has_all_fields() {
|
|
||||||
// 验证 pool_info 的字段 schema 完整。
|
|
||||||
// 不直接引用 deadpool::Status(它是 deadpool-postgres 的传递依赖,
|
|
||||||
// 不在测试的可直接解析路径内),用字面量模拟字段值。
|
|
||||||
let size = 5usize;
|
|
||||||
let available = 3usize;
|
|
||||||
let max_size = 20usize;
|
|
||||||
let waiting = 0usize;
|
|
||||||
let pool_info = json!({
|
|
||||||
"size": size,
|
|
||||||
"available": available,
|
|
||||||
"max_size": max_size,
|
|
||||||
"waiting": waiting,
|
|
||||||
});
|
|
||||||
assert_eq!(pool_info["max_size"], 20);
|
|
||||||
assert_eq!(pool_info["size"], 5);
|
|
||||||
assert_eq!(pool_info["available"], 3);
|
|
||||||
assert_eq!(pool_info["waiting"], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn probe_timeout_is_two_seconds() {
|
|
||||||
assert_eq!(PROBE_TIMEOUT, Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,8 +12,6 @@ pub mod csrf;
|
|||||||
pub mod comments;
|
pub mod comments;
|
||||||
/// 应用错误类型与转换。
|
/// 应用错误类型与转换。
|
||||||
pub mod error;
|
pub mod error;
|
||||||
/// 健康检查端点(liveness / readiness)。
|
|
||||||
pub mod health;
|
|
||||||
/// 图片服务的 Axum 处理器。
|
/// 图片服务的 Axum 处理器。
|
||||||
pub mod image;
|
pub mod image;
|
||||||
/// Markdown 渲染与 HTML 清理。
|
/// Markdown 渲染与 HTML 清理。
|
||||||
|
|||||||
@ -96,14 +96,14 @@ pub fn AdminLayout() -> Element {
|
|||||||
let is_write_route =
|
let is_write_route =
|
||||||
matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
|
matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
|
||||||
let main_class = if is_write_route {
|
let main_class = if is_write_route {
|
||||||
"flex-1 w-full max-w-5xl mx-auto px-6 flex flex-col"
|
"flex-1 w-full max-w-5xl mx-auto px-6 flex flex-col overflow-hidden"
|
||||||
} else {
|
} else {
|
||||||
"flex-1 w-full max-w-5xl mx-auto px-6 py-8"
|
"flex-1 w-full max-w-5xl mx-auto px-6 py-8"
|
||||||
};
|
};
|
||||||
|
|
||||||
// 所有路由统一自然文档流:浏览器右侧滚动条,整页滚动。
|
// Write 路由:页面固定高度,不滚动,由编辑器内部处理滚动
|
||||||
let root_class = if is_write_route {
|
let root_class = if is_write_route {
|
||||||
"min-h-dvh flex flex-col bg-paper-theme"
|
"h-dvh flex flex-col overflow-hidden bg-paper-theme"
|
||||||
} else {
|
} else {
|
||||||
"min-h-screen flex flex-col bg-paper-theme"
|
"min-h-screen flex flex-col bg-paper-theme"
|
||||||
};
|
};
|
||||||
|
|||||||
@ -383,14 +383,6 @@ fn main() {
|
|||||||
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
|
// 优雅降级。生产环境应在反向代理后部署并配置 TRUSTED_PROXY_COUNT,
|
||||||
// 使限流能拿到真实客户端 IP。
|
// 使限流能拿到真实客户端 IP。
|
||||||
let static_routes = axum::Router::new()
|
let static_routes = axum::Router::new()
|
||||||
.route(
|
|
||||||
"/healthz",
|
|
||||||
axum::routing::get(crate::api::health::healthz),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/readyz",
|
|
||||||
axum::routing::get(crate::api::health::readyz),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/uploads/{*path}",
|
"/uploads/{*path}",
|
||||||
axum::routing::get(crate::api::image::serve_image),
|
axum::routing::get(crate::api::image::serve_image),
|
||||||
|
|||||||
@ -421,16 +421,19 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
|||||||
"w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5";
|
"w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5";
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "relative flex flex-col",
|
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
|
||||||
if loading() {
|
if loading() {
|
||||||
div { class: "absolute inset-0 z-10 bg-paper-theme",
|
div { class: "absolute inset-0 z-10 bg-paper-theme",
|
||||||
WriteSkeleton {}
|
WriteSkeleton {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。
|
// 可滚动主体:元信息 + 编辑器 + 内联错误提示。
|
||||||
div { class: "flex flex-col",
|
// 底部操作栏留在此容器外(固定吸底),保证保存/状态按钮始终可见。
|
||||||
// 顶部元信息区域
|
// flex flex-col:让内部 flex-1 编辑器能撑满剩余高度;
|
||||||
|
// overflow-y-auto:内容超出时滚动(min-h-0 允许 flex 子项收缩触发滚动)。
|
||||||
|
div { class: "flex-1 min-h-0 overflow-y-auto flex flex-col",
|
||||||
|
// 顶部元信息区域 - 固定高度,不滚动
|
||||||
div { class: "flex-shrink-0 space-y-5 pt-8",
|
div { class: "flex-shrink-0 space-y-5 pt-8",
|
||||||
// 标题区域 - 大字号无框输入
|
// 标题区域 - 大字号无框输入
|
||||||
div {
|
div {
|
||||||
@ -720,9 +723,9 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑器区域 - 整页滚动下用视口高度,保证充裕的编辑空间。
|
// 编辑器区域 - 沾满剩余高度,但保证最小编辑空间。
|
||||||
// h-[60vh]:随窗口高度自适应;min-h-[400px]:窗口过矮时仍可用。
|
// min-h-[400px]:窗口过矮时不被元信息挤压到不可用;flex-1:窗口充裕时填充。
|
||||||
div { class: "h-[60vh] min-h-[400px] flex flex-col my-4",
|
div { class: "flex-1 min-h-[400px] flex flex-col my-4",
|
||||||
div {
|
div {
|
||||||
class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none",
|
class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none",
|
||||||
id: "tiptap-editor",
|
id: "tiptap-editor",
|
||||||
@ -766,10 +769,10 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // 内容区闭合
|
} // 滚动主体容器闭合
|
||||||
|
|
||||||
// 底部操作栏 - 跟随页面滚动
|
// 底部操作栏 - 固定吸底,不在滚动区域内
|
||||||
div { class: "flex items-center gap-2 pt-2 pb-4 border-t border-[var(--color-paper-border)]",
|
div { class: "flex-shrink-0 flex items-center gap-2 pt-2 pb-4 border-t border-[var(--color-paper-border)]",
|
||||||
button {
|
button {
|
||||||
class: "px-4 py-1.5 text-sm text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
|
class: "px-4 py-1.5 text-sm text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user