fix: 全面 QA 审计修复 — 安全加固/代码质量/跨平台一致性/测试覆盖
Phase 0 安全热修复 (CRITICAL): - 外部化微信 appid/secret 到 ERP__WECHAT__APPID/SECRET 环境变量 - 正确连接 HealthCrypto 到 ERP__HEALTH__AES_KEY/HMAC_KEY 环境变量 - 外部化小程序加密密钥到 TARO_APP_ENCRYPTION_KEY 环境变量 - 移除小程序 auth store 中的敏感信息 console.log Phase 1 安全加固: - 微信自动注册 display_name 添加 sanitize 防止 XSS - 测试数据库凭据改为从 TEST_DB_URL 环境变量读取 Phase 2 代码质量: - 提取 useThemeMode hook 消除 22 处重复暗色模式检测 - 提取共享健康常量到 constants/health.ts - 拆分 patient_service.rs 脱敏函数到 masking.rs - 移除未使用的 i18next/react-i18next 依赖 - 移除未使用的 api/errors.ts 和 erp-auth/anyhow 依赖 Phase 3 测试覆盖: - 新增 5 个患者模块集成测试 (CRUD/租户隔离/验证/软删除) Phase 4 跨平台一致性: - 统一小程序 Patient.birthday → birth_date 匹配后端 - 统一小程序 Appointment.time_slot → start_time/end_time 匹配后端 Phase 5 架构: - 微信登录添加多租户 TODO 注释 - 更新 wiki/infrastructure.md 环境变量文档
This commit is contained in:
157
crates/erp-health/src/service/masking.rs
Normal file
157
crates/erp-health/src/service/masking.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! 数据脱敏和状态转换验证
|
||||
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
|
||||
/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_id_number(s: &str) -> String {
|
||||
if s.len() >= 7 {
|
||||
format!("{}****{}", &s[..3], &s[s.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 * 替代
|
||||
pub fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||
s.map(|p| {
|
||||
if p.len() >= 7 {
|
||||
format!("{}****{}", &p[..3], &p[p.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
|
||||
pub fn validate_status_transition(
|
||||
field_name: &str,
|
||||
current: &str,
|
||||
new_status: &str,
|
||||
allowed_transitions: &[(&str, &str)],
|
||||
) -> HealthResult<()> {
|
||||
if current == new_status {
|
||||
return Ok(());
|
||||
}
|
||||
if allowed_transitions
|
||||
.iter()
|
||||
.any(|(from, to)| *from == current && *to == new_status)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HealthError::InvalidStatusTransition(format!(
|
||||
"{}: 不允许从 '{}' 转换到 '{}'",
|
||||
field_name, current, new_status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_id_18_digits() {
|
||||
assert_eq!("110****1234", mask_id_number("110101199001011234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_15_digits() {
|
||||
assert_eq!("123****2345", mask_id_number("123456789012345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_7_chars() {
|
||||
assert_eq!("123****4567", mask_id_number("1234567"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_short() {
|
||||
assert_eq!("****", mask_id_number("123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_id_empty() {
|
||||
assert_eq!("****", mask_id_number(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_normal() {
|
||||
assert_eq!(
|
||||
Some("138****5678".to_string()),
|
||||
mask_phone(Some("13812345678"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_7_chars() {
|
||||
assert_eq!(
|
||||
Some("123****4567".to_string()),
|
||||
mask_phone(Some("1234567"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_short() {
|
||||
assert_eq!(Some("****".to_string()), mask_phone(Some("123456")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_phone_none() {
|
||||
assert_eq!(None, mask_phone(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patient_active_to_inactive() {
|
||||
assert!(validate_status_transition(
|
||||
"patient.status",
|
||||
"active",
|
||||
"inactive",
|
||||
&[("active", "inactive"), ("active", "deceased"), ("inactive", "active")]
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patient_deceased_to_active_fails() {
|
||||
assert!(validate_status_transition(
|
||||
"patient.status",
|
||||
"deceased",
|
||||
"active",
|
||||
&[("active", "inactive"), ("active", "deceased"), ("inactive", "active")]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patient_same_status_ok() {
|
||||
assert!(validate_status_transition(
|
||||
"patient.status",
|
||||
"active",
|
||||
"active",
|
||||
&[("active", "inactive")]
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_pending_to_verified() {
|
||||
assert!(validate_status_transition(
|
||||
"patient.verification_status",
|
||||
"pending",
|
||||
"verified",
|
||||
&[("pending", "verified"), ("pending", "rejected"), ("rejected", "pending")]
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_verified_to_pending_fails() {
|
||||
assert!(validate_status_transition(
|
||||
"patient.verification_status",
|
||||
"verified",
|
||||
"pending",
|
||||
&[("pending", "verified"), ("pending", "rejected"), ("rejected", "pending")]
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod consultation_service;
|
||||
pub mod doctor_service;
|
||||
pub mod follow_up_service;
|
||||
pub mod health_data_service;
|
||||
pub mod masking;
|
||||
pub mod patient_service;
|
||||
pub mod seed;
|
||||
pub mod trend_service;
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::entity::patient_tag_relation;
|
||||
use crate::entity::patient_doctor_relation;
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status};
|
||||
use crate::service::masking::{mask_id_number, mask_phone, validate_status_transition};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -744,40 +745,3 @@ fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Mod
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_id_number(s: &str) -> String {
|
||||
if s.len() >= 7 {
|
||||
format!("{}****{}", &s[..3], &s[s.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_phone(s: Option<&str>) -> Option<String> {
|
||||
s.map(|p| {
|
||||
if p.len() >= 7 {
|
||||
format!("{}****{}", &p[..3], &p[p.len() - 4..])
|
||||
} else {
|
||||
"****".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
|
||||
fn validate_status_transition(
|
||||
field_name: &str,
|
||||
current: &str,
|
||||
new_status: &str,
|
||||
allowed_transitions: &[(&str, &str)],
|
||||
) -> HealthResult<()> {
|
||||
if current == new_status {
|
||||
return Ok(());
|
||||
}
|
||||
if allowed_transitions.iter().any(|(from, to)| *from == current && *to == new_status) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HealthError::InvalidStatusTransition(format!(
|
||||
"{}: 不允许从 '{}' 转换到 '{}'",
|
||||
field_name, current, new_status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user