Files
hms/crates/erp-core/src/audit_service.rs
iven 6d5a711d2c
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
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 统一格式化
2026-05-07 23:43:14 +08:00

134 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::{Digest, Sha256};
use tracing;
/// 持久化审计日志到 audit_logs 表。
///
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
///
/// 自动从 task_local 读取当前请求的 IP 和 User-Agent
/// 如果 AuditLog 中已有 ip_address/user_agent 则不覆盖。
///
/// 哈希链:查询同租户最新一条记录的 record_hash 作为 prev_hash
/// 计算 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 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;
}
}
// 查询同租户最新一条记录的 record_hash 作为 prev_hash
let prev_hash = audit_log::Entity::find()
.filter(audit_log::Column::TenantId.eq(log.tenant_id))
.filter(audit_log::Column::RecordHash.is_not_null())
.order_by_desc(audit_log::Column::CreatedAt)
.one(db)
.await
.ok()
.flatten()
.and_then(|m| m.record_hash);
// 计算当前记录的 record_hash
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
let model = audit_log::ActiveModel {
id: Set(log.id),
tenant_id: Set(log.tenant_id),
user_id: Set(log.user_id),
action: Set(log.action),
resource_type: Set(log.resource_type),
resource_id: Set(log.resource_id),
old_value: Set(log.old_value),
new_value: Set(log.new_value),
ip_address: Set(log.ip_address),
user_agent: Set(log.user_agent),
created_at: Set(log.created_at),
prev_hash: Set(prev_hash),
record_hash: Set(Some(record_hash)),
};
if let Err(e) = model.insert(db).await {
tracing::warn!(error = %e, "审计日志写入失败");
}
}
/// 计算 record_hash: SHA256(id + action + resource_type + resource_id + created_at + prev_hash)
fn compute_record_hash(log: &AuditLog, prev_hash: Option<&str>) -> String {
let mut hasher = Sha256::new();
hasher.update(log.id.to_string().as_bytes());
hasher.update(log.action.as_bytes());
hasher.update(log.resource_type.as_bytes());
hasher.update(
log.resource_id
.map(|id| id.to_string())
.unwrap_or_default()
.as_bytes(),
);
hasher.update(log.created_at.to_rfc3339().as_bytes());
hasher.update(prev_hash.unwrap_or("").as_bytes());
format!("{:x}", hasher.finalize())
}
/// 验证审计日志哈希链完整性。
///
/// 检查指定租户的所有含 record_hash 的日志记录,
/// 验证每条记录的 prev_hash 是否等于前一条的 record_hash
/// 以及 record_hash 是否可以重新计算验证。
///
/// 返回 (总记录数, 断链数)。
pub async fn verify_hash_chain(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
) -> Result<(usize, usize), sea_orm::DbErr> {
use sea_orm::QueryOrder;
let records = audit_log::Entity::find()
.filter(audit_log::Column::TenantId.eq(tenant_id))
.filter(audit_log::Column::RecordHash.is_not_null())
.order_by_asc(audit_log::Column::CreatedAt)
.all(db)
.await?;
let total = records.len();
let mut broken = 0;
let mut prev: Option<String> = None;
for record in &records {
// 验证 prev_hash 指向正确
if prev.as_deref() != record.prev_hash.as_deref() {
broken += 1;
}
// 验证 record_hash 可重算
let log = AuditLog {
id: record.id,
tenant_id: record.tenant_id,
user_id: record.user_id,
action: record.action.clone(),
resource_type: record.resource_type.clone(),
resource_id: record.resource_id,
old_value: record.old_value.clone(),
new_value: record.new_value.clone(),
ip_address: record.ip_address.clone(),
user_agent: record.user_agent.clone(),
created_at: record.created_at,
};
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
if Some(expected.as_str()) != record.record_hash.as_deref() {
broken += 1;
}
prev = record.record_hash.clone();
}
Ok((total, broken))
}