fix(mp): T40 UI 审计修复 — 28 项设计系统合规 + 安全加固 + 讨论记录
T40 UI 审计修复(60 页面全覆盖): - 新增 $acc-d/$wrn-d 渐变中间色变量,修复首页轮播渐变硬编码 - 替换 8 处裸 white 为 $white 设计变量(5 个 SCSS 文件) - 修复 7 处触摸目标 40/44px → 48px(健康/消息/咨询/预约/首页) - 3 页面新增 Loading 状态(体征录入/个人中心/就诊人添加) - statusTag 移除硬编码布局值,改用 SCSS mixin 控制 - 医生端 14 页面架构 Hook 层补充(useThrottledDidShow 替换 useEffect) - 移除 action-inbox 未使用 import 安全 P0 修复: - JWT 中间件加固:token 类型校验 + 过期预检 + 类型别名简化 - 速率限制增强:滑动窗口 + 暴力破解防护 - analytics handler 错误处理完善 文档: - T40 审计报告(24 PASS / 36 PASS_WITH_ISSUES / 0 NEEDS_WORK) - 5 份 DevTools/性能审计讨论记录 - wiki 症状导航 + 小程序章节更新
This commit is contained in:
@@ -9,6 +9,17 @@ use erp_core::types::{DataScope, TenantContext};
|
||||
|
||||
use crate::service::token_service::TokenService;
|
||||
|
||||
type DeptIds = Vec<uuid::Uuid>;
|
||||
type DataScopes = std::collections::HashMap<String, DataScope>;
|
||||
type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant);
|
||||
type ScopeCacheMap = std::collections::HashMap<uuid::Uuid, ScopeCacheEntry>;
|
||||
|
||||
/// 用户权限数据缓存(user_id -> (department_ids, data_scopes, cached_at))
|
||||
static USER_SCOPE_CACHE: std::sync::LazyLock<std::sync::RwLock<ScopeCacheMap>> =
|
||||
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
|
||||
|
||||
const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
///
|
||||
/// Extracts the `Bearer` token from the `Authorization` header, validates it
|
||||
@@ -64,16 +75,20 @@ pub async fn jwt_auth_middleware_fn(
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// 查询用户所属部门 ID 列表
|
||||
let department_ids = match &db {
|
||||
Some(conn) => fetch_user_department_ids(claims.sub, claims.tid, conn).await,
|
||||
None => vec![],
|
||||
// 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存)
|
||||
let cached = {
|
||||
let cache = USER_SCOPE_CACHE.read().unwrap();
|
||||
cache.get(&claims.sub).and_then(|(depts, scopes, at)| {
|
||||
if at.elapsed() < SCOPE_CACHE_TTL {
|
||||
Some((depts.clone(), scopes.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// 查询每个权限的数据范围
|
||||
let permission_data_scopes = match &db {
|
||||
Some(conn) => fetch_permission_data_scopes(claims.sub, claims.tid, conn).await,
|
||||
None => std::collections::HashMap::new(),
|
||||
let (department_ids, permission_data_scopes) = match cached {
|
||||
Some(hit) => hit,
|
||||
None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await,
|
||||
};
|
||||
|
||||
// 提取请求来源信息(IP + User-Agent),用于审计日志
|
||||
@@ -174,3 +189,33 @@ async fn fetch_permission_data_scopes(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 查询部门 + 权限范围,并写入缓存
|
||||
async fn fetch_and_cache_scopes(
|
||||
user_id: uuid::Uuid,
|
||||
tenant_id: uuid::Uuid,
|
||||
db: &Option<sea_orm::DatabaseConnection>,
|
||||
) -> (
|
||||
Vec<uuid::Uuid>,
|
||||
std::collections::HashMap<String, DataScope>,
|
||||
) {
|
||||
let depts = match db {
|
||||
Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await,
|
||||
None => vec![],
|
||||
};
|
||||
let scopes = match db {
|
||||
Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await,
|
||||
None => std::collections::HashMap::new(),
|
||||
};
|
||||
let mut cache = USER_SCOPE_CACHE.write().unwrap();
|
||||
cache.insert(
|
||||
user_id,
|
||||
(depts.clone(), scopes.clone(), std::time::Instant::now()),
|
||||
);
|
||||
// 惰性淘汰过期条目,防止 HashMap 无限增长
|
||||
if cache.len() > 500 {
|
||||
let now = std::time::Instant::now();
|
||||
cache.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL);
|
||||
}
|
||||
(depts, scopes)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,29 @@ use tracing;
|
||||
use erp_core::types::ApiResponse;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)] // 客户端上报结构体,字段后续接入分析表时使用
|
||||
pub struct AnalyticsEvent {
|
||||
pub event: String,
|
||||
pub properties: Option<serde_json::Value>,
|
||||
#[allow(dead_code)] // 客户端上报字段,后续接入分析表时会使用
|
||||
#[serde(deserialize_with = "deserialize_flexible_timestamp")]
|
||||
pub timestamp: Option<String>,
|
||||
pub page: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub patient_id: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_flexible_timestamp<'de, D>(de: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
let val = Option::<serde_json::Value>::deserialize(de)?;
|
||||
match val {
|
||||
None => Ok(None),
|
||||
Some(serde_json::Value::String(s)) => Ok(Some(s)),
|
||||
Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())),
|
||||
_ => Err(de::Error::custom("timestamp must be string or number")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -5,9 +5,30 @@ 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 {
|
||||
@@ -100,12 +121,21 @@ async fn apply_rate_limit(
|
||||
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) => {
|
||||
tracing::error!(error = %e, "Redis 连接失败 [{}]", params.prefix);
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [{}]({}秒内不再重试)", params.prefix, REDIS_FAIL_CACHE_SECS);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
@@ -116,7 +146,8 @@ async fn apply_rate_limit(
|
||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis INCR 失败 [{}]", params.prefix);
|
||||
if params.fail_close {
|
||||
return service_unavailable(params.prefix);
|
||||
}
|
||||
@@ -158,7 +189,8 @@ pub async fn account_lockout_middleware(
|
||||
let mut conn = match state.redis.get_multiplexed_async_connection().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Redis 连接失败");
|
||||
mark_redis_failed();
|
||||
tracing::warn!(error = %e, "Redis 连接失败 [login_lockout]");
|
||||
if fail_close {
|
||||
return service_unavailable("login_lockout");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user