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:
iven
2026-05-31 20:52:19 +08:00
commit c539e6fd83
285 changed files with 59156 additions and 0 deletions

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

View 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"));
}
}

View File

@@ -0,0 +1,4 @@
pub mod frozen_module;
pub mod metrics;
pub mod rate_limit;
pub mod tenant_rls;

View 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.

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