fix: 全面 QA 审计修复 — 安全加固/代码质量/跨平台一致性/测试覆盖
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

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:
iven
2026-04-25 10:00:49 +08:00
parent 07f4ba41ba
commit 945ccd64ba
56 changed files with 634 additions and 273 deletions

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

View File

@@ -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;

View File

@@ -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
)))
}
}