feat: initialize Nuanji (Warm Notes) project
- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
This commit is contained in:
37
crates/erp-server/src/middleware/frozen_module.rs
Normal file
37
crates/erp-server/src/middleware/frozen_module.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// 冻结模块路径前缀列表。
|
||||
///
|
||||
/// 这些模块前端已通过 FROZEN_ROUTES 守卫拦截,后端也需同步拦截,
|
||||
/// 防止直接调 API 绕过限制。
|
||||
const FROZEN_PREFIXES: &[&str] = &[
|
||||
"/api/v1/health/care-plans",
|
||||
"/api/v1/health/shifts",
|
||||
"/api/v1/health/family-proxy",
|
||||
"/api/v1/health/medications",
|
||||
"/api/v1/health/dialysis",
|
||||
"/api/v1/health/schedules",
|
||||
];
|
||||
|
||||
pub async fn frozen_module_middleware(req: Request<Body>, next: Next) -> Response {
|
||||
let path = req.uri().path();
|
||||
|
||||
for prefix in FROZEN_PREFIXES {
|
||||
if path.starts_with(prefix) {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "该功能正在优化中,暂不可用"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
126
crates/erp-server/src/middleware/metrics.rs
Normal file
126
crates/erp-server/src/middleware/metrics.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::Method;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use metrics::{counter, histogram};
|
||||
use std::time::Instant;
|
||||
|
||||
/// HTTP 请求指标中间件。
|
||||
///
|
||||
/// 记录两个 Prometheus 指标:
|
||||
/// - `http_requests_total` — 计数器,标签: method, path, status
|
||||
/// - `http_request_duration_seconds` — 直方图,标签: method, path, status
|
||||
pub async fn metrics_middleware(req: Request, next: Next) -> Response {
|
||||
let method = method_label(req.method());
|
||||
let path = path_label(req.uri().path());
|
||||
|
||||
let start = Instant::now();
|
||||
let resp = next.run(req).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let status = resp.status().as_u16().to_string();
|
||||
|
||||
let labels = [
|
||||
("method", method.clone()),
|
||||
("path", path.clone()),
|
||||
("status", status.clone()),
|
||||
];
|
||||
|
||||
counter!("http_requests_total", &labels).increment(1);
|
||||
histogram!("http_request_duration_seconds", &labels).record(elapsed.as_secs_f64());
|
||||
|
||||
resp
|
||||
}
|
||||
|
||||
fn method_label(method: &Method) -> String {
|
||||
method.as_str().to_owned()
|
||||
}
|
||||
|
||||
/// 归一化路径:将 UUID 段替换为 `:id`,避免高基数。
|
||||
fn path_label(path: &str) -> String {
|
||||
let parts: Vec<&str> = path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| if looks_like_uuid(s) { ":id" } else { s })
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", parts.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_uuid(s: &str) -> bool {
|
||||
s.len() == 36
|
||||
&& s.chars().filter(|c| *c == '-').count() == 4
|
||||
&& s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
|
||||
}
|
||||
|
||||
/// 在独立端口启动 Prometheus exporter。
|
||||
pub fn start_metrics_server(port: u16) {
|
||||
let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
|
||||
let recorder = builder.build_recorder();
|
||||
let handle = recorder.handle();
|
||||
|
||||
if let Err(e) = metrics::set_global_recorder(recorder) {
|
||||
tracing::error!(error = %e, "Failed to install Prometheus recorder");
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/metrics",
|
||||
axum::routing::get(move || {
|
||||
let handle = handle.clone();
|
||||
async move {
|
||||
let body = handle.render();
|
||||
axum::response::IntoResponse::into_response((
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; version=0.0.4",
|
||||
)],
|
||||
body,
|
||||
))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.fallback(|| async { axum::http::StatusCode::NOT_FOUND.into_response() as Response });
|
||||
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(listener) => {
|
||||
tracing::info!(addr = %addr, "Prometheus metrics server listening");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "Metrics server error");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, addr = %addr, "Failed to bind metrics server");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn path_label_normalizes_uuids() {
|
||||
assert_eq!(path_label("/api/v1/users"), "/api/v1/users");
|
||||
assert_eq!(
|
||||
path_label("/api/v1/users/01234567-89ab-cdef-0123-456789abcdef/posts"),
|
||||
"/api/v1/users/:id/posts"
|
||||
);
|
||||
assert_eq!(path_label("/"), "/");
|
||||
assert_eq!(path_label(""), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_uuid_checks_format() {
|
||||
assert!(looks_like_uuid("01234567-89ab-cdef-0123-456789abcdef"));
|
||||
assert!(!looks_like_uuid("not-a-uuid"));
|
||||
assert!(!looks_like_uuid("short"));
|
||||
}
|
||||
}
|
||||
4
crates/erp-server/src/middleware/mod.rs
Normal file
4
crates/erp-server/src/middleware/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod frozen_module;
|
||||
pub mod metrics;
|
||||
pub mod rate_limit;
|
||||
pub mod tenant_rls;
|
||||
326
crates/erp-server/src/middleware/rate_limit.rs
Normal file
326
crates/erp-server/src/middleware/rate_limit.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use redis::AsyncCommands;
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Redis 连接失败时间戳缓存(毫秒),5 秒内复用失败状态避免重复连接尝试
|
||||
static REDIS_LAST_FAIL_MS: AtomicU64 = AtomicU64::new(0);
|
||||
const REDIS_FAIL_CACHE_SECS: u64 = 5;
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn is_redis_cached_failed() -> bool {
|
||||
let last = REDIS_LAST_FAIL_MS.load(Ordering::Relaxed);
|
||||
last > 0 && now_ms().saturating_sub(last) < REDIS_FAIL_CACHE_SECS * 1000
|
||||
}
|
||||
|
||||
fn mark_redis_failed() {
|
||||
REDIS_LAST_FAIL_MS.store(now_ms(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 限流错误响应。
|
||||
#[derive(Serialize)]
|
||||
struct RateLimitResponse {
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// 账户锁定配置。
|
||||
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
||||
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件(登录等敏感操作,5 次/分钟)。
|
||||
pub async fn rate_limit_by_ip(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 5,
|
||||
window_secs: 60,
|
||||
prefix: "login",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的 IP 限流中间件(Token 刷新,30 次/分钟)。
|
||||
pub async fn rate_limit_refresh_by_ip(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = extract_client_ip(req.headers());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 30,
|
||||
window_secs: 60,
|
||||
prefix: "refresh",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 基于 Redis 的用户限流中间件。
|
||||
///
|
||||
/// 从 TenantContext 中读取 user_id 作为标识符。
|
||||
pub async fn rate_limit_by_user(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let identifier = req
|
||||
.extensions()
|
||||
.get::<erp_core::types::TenantContext>()
|
||||
.map(|ctx| ctx.user_id.to_string())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
apply_rate_limit(
|
||||
RateLimitParams {
|
||||
redis_client: &state.redis,
|
||||
fail_close,
|
||||
max_requests: 300,
|
||||
window_secs: 60,
|
||||
prefix: "api",
|
||||
},
|
||||
&identifier,
|
||||
req,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Redis 不可达时的安全响应(fail-close 模式)。
|
||||
fn service_unavailable(prefix: &str) -> Response {
|
||||
let body = RateLimitResponse {
|
||||
error: "Service Unavailable".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
tracing::error!("Redis 不可达,fail-close 模式拒绝请求 [{}]", prefix);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response()
|
||||
}
|
||||
|
||||
/// 限流参数,打包以避免函数签名过长。
|
||||
struct RateLimitParams<'a> {
|
||||
redis_client: &'a redis::Client,
|
||||
fail_close: bool,
|
||||
max_requests: u64,
|
||||
window_secs: u64,
|
||||
prefix: &'a str,
|
||||
}
|
||||
|
||||
/// 执行限流检查。
|
||||
async fn apply_rate_limit(
|
||||
params: RateLimitParams<'_>,
|
||||
identifier: &str,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// 快速路径:Redis 在缓存期内已知不可用,跳过连接尝试
|
||||
if is_redis_cached_failed() {
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
let key = format!("rate_limit:{}:{}", params.prefix, identifier);
|
||||
|
||||
let mut conn = match params.redis_client.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [{}]({}秒内不再重试)", params.prefix, REDIS_FAIL_CACHE_SECS);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次请求设置 TTL
|
||||
if count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await;
|
||||
}
|
||||
|
||||
if count > params.max_requests as i64 {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "请求过于频繁,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// 账户级登录锁定中间件。
|
||||
///
|
||||
/// 针对登录接口(POST /api/v1/auth/login),在 IP 限流之前执行:
|
||||
/// 1. 解析请求体提取 username
|
||||
/// 2. 检查 Redis 中该 username 的失败次数
|
||||
/// 3. 超过阈值(5次)则拒绝请求
|
||||
/// 4. 观察响应状态码:401 递增失败计数,200 清除计数
|
||||
pub async fn account_lockout_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let fail_close = state.config.rate_limit.fail_close;
|
||||
|
||||
// 获取 Redis 连接
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [login_lockout]");
|
||||
if fail_close {
|
||||
return service_unavailable("login_lockout");
|
||||
}
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 读取请求体以提取 username
|
||||
let (parts, body) = req.into_parts();
|
||||
let bytes = match axum::body::to_bytes(body, 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "读取登录请求体失败,放行");
|
||||
let req = Request::from_parts(parts, Body::from(Vec::new()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析 username
|
||||
let username = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
.ok()
|
||||
.and_then(|v| v.get("username")?.as_str().map(|s| s.to_string()));
|
||||
|
||||
let username = match username {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
return next.run(req).await;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查账户锁定状态
|
||||
let lockout_key = format!("login_fail:{}", username);
|
||||
let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0);
|
||||
|
||||
if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
fail_count = fail_count,
|
||||
"账户已被临时锁定"
|
||||
);
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "账户已被临时锁定,请15分钟后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 用原始 body 重建请求,转发到 handler
|
||||
let req = Request::from_parts(parts, Body::from(bytes.to_vec()));
|
||||
let response = next.run(req).await;
|
||||
|
||||
// 观察响应状态码
|
||||
let status = response.status();
|
||||
let (parts, body) = response.into_parts();
|
||||
|
||||
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
// 登录失败:递增失败计数
|
||||
let new_count: i64 = match redis::cmd("INCR")
|
||||
.arg(&lockout_key)
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败计数失败");
|
||||
let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec()));
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
// 首次失败时设置 TTL
|
||||
if new_count == 1 {
|
||||
let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
username = %username,
|
||||
fail_count = new_count,
|
||||
remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count,
|
||||
"登录失败,递增失败计数"
|
||||
);
|
||||
} else if status.is_success() {
|
||||
// 登录成功:清除失败计数
|
||||
let _: Result<(), _> = conn.del(&lockout_key).await;
|
||||
tracing::info!(username = %username, "登录成功,清除失败计数");
|
||||
}
|
||||
|
||||
// 重建并返回原始响应
|
||||
|
||||
Response::from_parts(parts, Body::from(body_bytes.to_vec()))
|
||||
}
|
||||
|
||||
/// 从请求头中提取客户端 IP。
|
||||
fn extract_client_ip(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| {
|
||||
// x-forwarded-for 可能包含多个 IP,取第一个
|
||||
s.split(',').next().unwrap_or(s).trim().to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
// NOTE: rate_limit_by_gateway was removed during base extraction.
|
||||
// It depended on erp_health::gateway_auth::GatewayAuthContext.
|
||||
// Projects needing gateway rate limiting should add it in their own middleware.
|
||||
50
crates/erp-server/src/middleware/tenant_rls.rs
Normal file
50
crates/erp-server/src/middleware/tenant_rls.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use erp_core::types::TenantContext;
|
||||
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
|
||||
|
||||
/// Tenant RLS 中间件。
|
||||
///
|
||||
/// 从 request extensions 中提取 `TenantContext`,在数据库连接上设置
|
||||
/// `app.current_tenant_id`,使 PostgreSQL RLS 策略自动按租户过滤。
|
||||
///
|
||||
/// 请求处理完成后自动 RESET 设置,防止连接池复用时泄漏。
|
||||
///
|
||||
/// SET 失败时仅 warn 不阻断请求(RLS 是安全网,主隔离仍在应用层)。
|
||||
pub async fn tenant_rls_middleware(
|
||||
db: sea_orm::DatabaseConnection,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let tenant_id = req
|
||||
.extensions()
|
||||
.get::<TenantContext>()
|
||||
.map(|ctx| ctx.tenant_id);
|
||||
|
||||
if let Some(tid) = tenant_id {
|
||||
// SET app.current_tenant_id — RLS 策略读取此值(参数化查询防止注入)
|
||||
if let Err(e) = db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SET app.current_tenant_id = $1",
|
||||
[tid.into()],
|
||||
))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(tenant_id = %tid, error = %e, "SET app.current_tenant_id 失败(RLS 未激活)");
|
||||
}
|
||||
}
|
||||
|
||||
let response = next.run(req).await;
|
||||
|
||||
// RESET — 防止连接池复用时泄漏租户上下文
|
||||
if tenant_id.is_some()
|
||||
&& let Err(e) = db.execute_unprepared("RESET app.current_tenant_id").await
|
||||
{
|
||||
tracing::debug!(error = %e, "RESET app.current_tenant_id 失败(非致命)");
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
Reference in New Issue
Block a user