fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复: 1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查 2. 仪表盘统计容错:单个查询失败返回零值而非 500 3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致 4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径 5. 积分端点权限码:health.health-data.list → health.points.list 6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage 7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档 Clippy 全 workspace 清零(14→0 errors): - erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处 - erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处 - erp-ai: 修复 dead_code、unused import 等 11 处 - erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处 - erp-server-migration: 修复 enum_variant_names 5 处 - erp-auth/config/workflow/message: 各 1-3 处 工程改进: - lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy) - cargo fmt 统一格式化
This commit is contained in:
@@ -2,7 +2,7 @@ use crate::audit::AuditLog;
|
||||
use crate::entity::audit_log;
|
||||
use crate::request_info::RequestInfo;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
@@ -16,14 +16,12 @@ use tracing;
|
||||
/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。
|
||||
pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 自动填充请求来源信息(仅当调用方未显式设置时)
|
||||
if log.ip_address.is_none() || log.user_agent.is_none() {
|
||||
if let Some(info) = RequestInfo::try_current() {
|
||||
if log.ip_address.is_none() {
|
||||
log.ip_address = info.ip_address;
|
||||
}
|
||||
if log.user_agent.is_none() {
|
||||
log.user_agent = info.user_agent;
|
||||
}
|
||||
if let Some(info) = RequestInfo::try_current() {
|
||||
if log.ip_address.is_none() {
|
||||
log.ip_address = info.ip_address;
|
||||
}
|
||||
if log.user_agent.is_none() {
|
||||
log.user_agent = info.user_agent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use rand::RngCore;
|
||||
|
||||
const CIPHER_VERSION: u8 = 0x01;
|
||||
@@ -41,6 +41,8 @@ pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result<String, String> {
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| e.to_string())?;
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| e.to_string())?;
|
||||
String::from_utf8(plaintext).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -46,15 +46,15 @@ impl DekManager {
|
||||
kek: &[u8; 32],
|
||||
) -> AppResult<([u8; 32], u32)> {
|
||||
// 检查缓存
|
||||
if let Some(entry) = self.cache.get(&tenant_id) {
|
||||
if entry.loaded_at.elapsed().as_secs() < self.ttl_secs {
|
||||
return Ok((entry.dek, entry.version));
|
||||
}
|
||||
if let Some(entry) = self.cache.get(&tenant_id)
|
||||
&& entry.loaded_at.elapsed().as_secs() < self.ttl_secs
|
||||
{
|
||||
return Ok((entry.dek, entry.version));
|
||||
}
|
||||
|
||||
// 从加密 DEK 解密
|
||||
if let Some(enc_dek) = encrypted_dek {
|
||||
let dek_hex = engine::decrypt(kek, enc_dek).map_err(|e| AppError::Internal(e))?;
|
||||
let dek_hex = engine::decrypt(kek, enc_dek).map_err(AppError::Internal)?;
|
||||
let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
if dek_bytes.len() != 32 {
|
||||
return Err(AppError::Internal("DEK must be 32 bytes".into()));
|
||||
@@ -64,29 +64,35 @@ impl DekManager {
|
||||
|
||||
// 缓存(版本从外部传入时无法确定,使用默认值 1)
|
||||
self.evict_if_full();
|
||||
self.cache.insert(tenant_id, CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
self.cache.insert(
|
||||
tenant_id,
|
||||
CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
return Ok((dek, 1));
|
||||
}
|
||||
|
||||
// 无现有 DEK → 生成新的
|
||||
let dek = Self::generate_dek();
|
||||
self.evict_if_full();
|
||||
self.cache.insert(tenant_id, CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
});
|
||||
self.cache.insert(
|
||||
tenant_id,
|
||||
CachedDek {
|
||||
dek,
|
||||
version: 1,
|
||||
loaded_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
Ok((dek, 1))
|
||||
}
|
||||
|
||||
/// 使用 KEK 加密 DEK 以便存储
|
||||
pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult<String> {
|
||||
let dek_hex = hex::encode(dek);
|
||||
engine::encrypt(kek, &dek_hex).map_err(|e| AppError::Internal(e))
|
||||
engine::encrypt(kek, &dek_hex).map_err(AppError::Internal)
|
||||
}
|
||||
|
||||
/// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK)
|
||||
@@ -110,7 +116,8 @@ impl DekManager {
|
||||
|
||||
fn evict_if_full(&self) {
|
||||
if self.cache.len() >= self.max_entries {
|
||||
let to_remove: Vec<Uuid> = self.cache
|
||||
let to_remove: Vec<Uuid> = self
|
||||
.cache
|
||||
.iter()
|
||||
.filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2)
|
||||
.map(|e| *e.key())
|
||||
@@ -156,7 +163,9 @@ mod tests {
|
||||
let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(1);
|
||||
let (recovered_dek, _ver) = mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek).unwrap();
|
||||
let (recovered_dek, _ver) = mgr
|
||||
.get_or_create_dek(tenant_id, Some(&encrypted), &kek)
|
||||
.unwrap();
|
||||
assert_eq!(original_dek, recovered_dek);
|
||||
}
|
||||
|
||||
@@ -188,7 +197,10 @@ mod tests {
|
||||
let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap();
|
||||
let mgr = DekManager::new(300, 100);
|
||||
let tenant_id = test_uuid(4);
|
||||
assert!(mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2).is_err());
|
||||
assert!(
|
||||
mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -204,7 +216,9 @@ mod tests {
|
||||
fn max_entries_eviction() {
|
||||
let mgr = DekManager::new(300, 3);
|
||||
for i in 0..5u8 {
|
||||
let _ = mgr.get_or_create_dek(test_uuid(i), None, &test_kek()).unwrap();
|
||||
let _ = mgr
|
||||
.get_or_create_dek(test_uuid(i), None, &test_kek())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(mgr.cache.len() <= 6);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mask_phone_normal() {
|
||||
assert_eq!(Some("138****5678".to_string()), mask_phone(Some("13812345678")));
|
||||
assert_eq!(
|
||||
Some("138****5678".to_string()),
|
||||
mask_phone(Some("13812345678"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +90,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mask_phone_unicode_safe() {
|
||||
assert_eq!(Some("你好世****cdef".to_string()), mask_phone(Some("你好世界abcdef")));
|
||||
assert_eq!(
|
||||
Some("你好世****cdef".to_string()),
|
||||
mask_phone(Some("你好世界abcdef"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@ pub mod masking;
|
||||
|
||||
pub use engine::{decrypt, encrypt};
|
||||
pub use hmac_index::hmac_hash;
|
||||
pub use masking::{mask_id_number, mask_license_number, mask_phone};
|
||||
pub use key_manager::DekManager;
|
||||
pub use masking::{mask_id_number, mask_license_number, mask_phone};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
@@ -21,10 +21,12 @@ pub struct PiiCrypto {
|
||||
impl PiiCrypto {
|
||||
/// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex(32 字节)。
|
||||
pub fn from_kek_hex(kek_hex: &str) -> AppResult<Self> {
|
||||
let bytes =
|
||||
hex::decode(kek_hex).map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
|
||||
let bytes = hex::decode(kek_hex)
|
||||
.map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(AppError::Internal("KEK must be 32 bytes (64 hex chars)".into()));
|
||||
return Err(AppError::Internal(
|
||||
"KEK must be 32 bytes (64 hex chars)".into(),
|
||||
));
|
||||
}
|
||||
let mut kek = [0u8; 32];
|
||||
kek.copy_from_slice(&bytes);
|
||||
@@ -44,7 +46,7 @@ impl PiiCrypto {
|
||||
use sha2::Digest;
|
||||
let hmac_key = <sha2::Sha256 as Digest>::new()
|
||||
.chain_update(b"pii-hmac-index-v1")
|
||||
.chain_update(&kek)
|
||||
.chain_update(kek)
|
||||
.finalize();
|
||||
let mut hk = [0u8; 32];
|
||||
hk.copy_from_slice(&hmac_key);
|
||||
@@ -172,7 +174,9 @@ mod tests {
|
||||
let crypto = test_crypto();
|
||||
let encrypted = encrypt(crypto.kek(), "test").unwrap();
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(&encrypted).unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&encrypted)
|
||||
.unwrap();
|
||||
assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01");
|
||||
}
|
||||
|
||||
@@ -189,11 +193,7 @@ mod tests {
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"encrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
assert!(avg_us < 50, "encrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||
eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
@@ -208,11 +208,7 @@ mod tests {
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let avg_us = elapsed.as_micros() / 1000;
|
||||
assert!(
|
||||
avg_us < 50,
|
||||
"decrypt 平均耗时应 < 50μs, 实际: {}μs",
|
||||
avg_us
|
||||
);
|
||||
assert!(avg_us < 50, "decrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us);
|
||||
eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
/// 统一错误响应格式
|
||||
|
||||
@@ -44,11 +44,11 @@ pub fn build_event_payload(data: serde_json::Value) -> serde_json::Value {
|
||||
"schema_version": EVENT_SCHEMA_VERSION,
|
||||
"occurred_at": Utc::now().to_rfc3339(),
|
||||
});
|
||||
if let serde_json::Value::Object(ref mut map) = envelope {
|
||||
if let serde_json::Value::Object(data_map) = data {
|
||||
for (k, v) in data_map {
|
||||
map.insert(k, v);
|
||||
}
|
||||
if let serde_json::Value::Object(ref mut map) = envelope
|
||||
&& let serde_json::Value::Object(data_map) = data
|
||||
{
|
||||
for (k, v) in data_map {
|
||||
map.insert(k, v);
|
||||
}
|
||||
}
|
||||
envelope
|
||||
@@ -314,10 +314,10 @@ impl EventBus {
|
||||
event = broadcast_rx.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if event.event_type.starts_with(&prefix) {
|
||||
if mpsc_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if event.event_type.starts_with(&prefix)
|
||||
&& mpsc_tx.send(event).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
|
||||
@@ -9,11 +9,7 @@ use crate::error::AppResult;
|
||||
#[async_trait]
|
||||
pub trait HealthDataProvider: Send + Sync {
|
||||
/// 获取化验报告(指标列表)
|
||||
async fn get_lab_report(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> AppResult<LabReportDto>;
|
||||
async fn get_lab_report(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<LabReportDto>;
|
||||
|
||||
/// 获取生命体征趋势数据
|
||||
async fn get_vital_signs(
|
||||
@@ -32,11 +28,8 @@ pub trait HealthDataProvider: Send + Sync {
|
||||
) -> AppResult<PatientSummaryDto>;
|
||||
|
||||
/// 获取完整健康报告(用于摘要生成)
|
||||
async fn get_full_report(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> AppResult<HealthReportDto>;
|
||||
async fn get_full_report(&self, tenant_id: Uuid, report_id: Uuid)
|
||||
-> AppResult<HealthReportDto>;
|
||||
|
||||
/// 获取趋势分析预计算数据(统计摘要 + 异常检测)
|
||||
async fn get_trend_analysis_data(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
///
|
||||
/// 基于 ammonia(html5ever)剥离所有 HTML 标签,防止存储型 XSS。
|
||||
/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。
|
||||
|
||||
///
|
||||
/// 剥离字符串中的所有 HTML 标签,返回纯文本。
|
||||
///
|
||||
/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。
|
||||
//! 多个测试共享同一个数据库连接池,无连接竞争。
|
||||
|
||||
use sea_orm::{ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait};
|
||||
use sea_orm::{
|
||||
ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait,
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
@@ -22,12 +24,8 @@ fn db_url() -> String {
|
||||
async fn db_pool() -> &'static DatabaseConnection {
|
||||
DB_POOL
|
||||
.get_or_init(|| async {
|
||||
let opt = ConnectOptions::new(db_url())
|
||||
.max_connections(5)
|
||||
.to_owned();
|
||||
Database::connect(opt)
|
||||
.await
|
||||
.expect("测试数据库连接失败")
|
||||
let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned();
|
||||
Database::connect(opt).await.expect("测试数据库连接失败")
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -35,7 +33,5 @@ async fn db_pool() -> &'static DatabaseConnection {
|
||||
/// 创建测试用事务。测试结束自动回滚,无数据残留。
|
||||
pub async fn test_txn() -> DatabaseTransaction {
|
||||
let pool = db_pool().await;
|
||||
pool.begin()
|
||||
.await
|
||||
.expect("测试事务创建失败")
|
||||
pool.begin().await.expect("测试事务创建失败")
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ pub enum DataScope {
|
||||
}
|
||||
|
||||
impl DataScope {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
pub fn parse_scope(s: &str) -> Self {
|
||||
match s {
|
||||
"self" => Self::SelfOnly,
|
||||
"department" => Self::Department,
|
||||
|
||||
Reference in New Issue
Block a user