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:
@@ -13,7 +13,6 @@ chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
|
||||
@@ -34,8 +34,18 @@ where
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
tracing::info!(
|
||||
code = %req.code,
|
||||
tenant_id = %state.default_tenant_id,
|
||||
appid_len = state.wechat_appid.len(),
|
||||
secret_len = state.wechat_secret.len(),
|
||||
"微信登录请求"
|
||||
);
|
||||
|
||||
// TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户)
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp = WechatService::login(&state, tenant_id, &req.code).await?;
|
||||
tracing::info!(bound = resp.bound, has_token = resp.token.is_some(), "微信登录结果");
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -63,6 +73,7 @@ where
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
// TODO: 多租户微信登录需要设计租户解析策略
|
||||
let tenant_id = state.default_tenant_id;
|
||||
let resp = WechatService::bind_phone(
|
||||
&state,
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::entity::wechat_user;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use crate::service::auth_service::JwtConfig;
|
||||
use crate::service::token_service::TokenService;
|
||||
use erp_core::sanitize::sanitize_string;
|
||||
|
||||
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
||||
|
||||
@@ -52,6 +53,11 @@ impl WechatService {
|
||||
tenant_id: Uuid,
|
||||
code: &str,
|
||||
) -> AuthResult<WechatLoginResp> {
|
||||
tracing::info!(
|
||||
appid = %state.wechat_appid,
|
||||
code = %code,
|
||||
"fetch_session 开始"
|
||||
);
|
||||
let session = fetch_session(&state.wechat_appid, &state.wechat_secret, code).await?;
|
||||
|
||||
let openid = session
|
||||
@@ -209,7 +215,7 @@ impl WechatService {
|
||||
id: Set(user_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
username: Set(format!("wx_{}", suffix)),
|
||||
display_name: Set(Some(format!("微信用户{}", suffix))),
|
||||
display_name: Set(Some(sanitize_string(&format!("微信用户{}", suffix)))),
|
||||
phone: Set(Some(phone.to_string())),
|
||||
email: Set(None),
|
||||
avatar_url: Set(None),
|
||||
@@ -360,6 +366,7 @@ async fn fetch_session(
|
||||
if let Some(errcode) = session.errcode {
|
||||
if errcode != 0 {
|
||||
let msg = session.errmsg.clone().unwrap_or_default();
|
||||
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
|
||||
return Err(AuthError::Validation(format!(
|
||||
"微信登录失败 ({}): {}",
|
||||
errcode, msg
|
||||
@@ -367,5 +374,11 @@ async fn fetch_session(
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
has_openid = session.openid.is_some(),
|
||||
has_session_key = session.session_key.is_some(),
|
||||
"微信 jscode2session 成功"
|
||||
);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,9 @@ level = "info"
|
||||
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
|
||||
|
||||
[wechat]
|
||||
appid = "wx20f4ef9cc2ec66c5"
|
||||
secret = "096ba4fa828e7b1fa7de2235eb6c7836"
|
||||
appid = "__MUST_SET_VIA_ENV__"
|
||||
secret = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
[health]
|
||||
aes_key = "__MUST_SET_VIA_ENV__"
|
||||
hmac_key = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct AppConfig {
|
||||
pub log: LogConfig,
|
||||
pub cors: CorsConfig,
|
||||
pub wechat: WechatConfig,
|
||||
pub health: HealthConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -60,6 +61,14 @@ pub struct WechatConfig {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HealthConfig {
|
||||
/// AES-256 密钥 (64 字符 hex 编码,32 字节)
|
||||
pub aes_key: String,
|
||||
/// HMAC-SHA256 密钥 (64 字符 hex 编码,32 字节)
|
||||
pub hmac_key: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let config = config::Config::builder()
|
||||
|
||||
@@ -205,6 +205,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if config.wechat.appid == "__MUST_SET_VIA_ENV__" || config.wechat.secret == "__MUST_SET_VIA_ENV__" {
|
||||
tracing::error!(
|
||||
"微信凭据为默认占位值,拒绝启动。请设置环境变量 ERP__WECHAT__APPID 和 ERP__WECHAT__SECRET"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if config.health.aes_key == "__MUST_SET_VIA_ENV__" || config.health.hmac_key == "__MUST_SET_VIA_ENV__" {
|
||||
tracing::error!(
|
||||
"健康数据加密密钥为默认占位值,拒绝启动。请设置环境变量 ERP__HEALTH__AES_KEY 和 ERP__HEALTH__HMAC_KEY(64 字符 hex 编码)"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if let Err(e) = erp_health::HealthCrypto::from_keys(&config.health.aes_key, &config.health.hmac_key) {
|
||||
tracing::error!("健康数据加密密钥无效: {}。密钥必须为 64 字符 hex 编码(32 字节)", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
|
||||
@@ -102,10 +102,16 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
/// Allow erp-health handlers to extract their required state.
|
||||
impl FromRef<AppState> for erp_health::HealthState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
let crypto = erp_health::HealthCrypto::from_keys(
|
||||
&state.config.health.aes_key,
|
||||
&state.config.health.hmac_key,
|
||||
)
|
||||
.expect("Health encryption keys must be valid 32-byte hex strings. Set ERP__HEALTH__AES_KEY and ERP__HEALTH__HMAC_KEY");
|
||||
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
crypto: erp_health::HealthCrypto::dev_default(),
|
||||
crypto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,5 @@ mod auth_tests;
|
||||
mod plugin_tests;
|
||||
#[path = "integration/workflow_tests.rs"]
|
||||
mod workflow_tests;
|
||||
#[path = "integration/health_patient_tests.rs"]
|
||||
mod health_patient_tests;
|
||||
|
||||
208
crates/erp-server/tests/integration/health_patient_tests.rs
Normal file
208
crates/erp-server/tests/integration/health_patient_tests.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! erp-health 患者管理集成测试
|
||||
//!
|
||||
//! 验证患者 CRUD、租户隔离、字段校验、软删除等核心行为。
|
||||
//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。
|
||||
|
||||
use erp_core::events::EventBus;
|
||||
use erp_health::dto::patient_dto::CreatePatientReq;
|
||||
use erp_health::service::patient_service;
|
||||
use erp_health::state::HealthState;
|
||||
use erp_health::HealthCrypto;
|
||||
|
||||
use super::test_db::TestDb;
|
||||
|
||||
/// 构建测试用 HealthState
|
||||
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
|
||||
HealthState {
|
||||
db: db.clone(),
|
||||
event_bus: EventBus::new(100),
|
||||
crypto: HealthCrypto::dev_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_patient() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
let operator_id = uuid::Uuid::new_v4();
|
||||
|
||||
let req = CreatePatientReq {
|
||||
name: "张三".to_string(),
|
||||
gender: Some("male".to_string()),
|
||||
birth_date: Some(chrono::NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()),
|
||||
blood_type: Some("A".to_string()),
|
||||
id_number: Some("110101199001151234".to_string()),
|
||||
allergy_history: Some("青霉素过敏".to_string()),
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: Some("李四".to_string()),
|
||||
emergency_contact_phone: Some("13800138000".to_string()),
|
||||
source: Some("offline".to_string()),
|
||||
notes: None,
|
||||
};
|
||||
|
||||
let patient = patient_service::create_patient(&state, tenant_id, Some(operator_id), req)
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
assert_eq!(patient.name, "张三");
|
||||
assert_eq!(patient.gender, Some("male".to_string()));
|
||||
assert_eq!(patient.status, "active");
|
||||
assert_eq!(patient.verification_status, "pending");
|
||||
assert_eq!(patient.version, 1);
|
||||
assert!(patient.id_number.is_none(), "列表视图不应返回身份证号明文");
|
||||
|
||||
// 通过 get_patient 验证存储正确
|
||||
let found = patient_service::get_patient(&state, tenant_id, patient.id)
|
||||
.await
|
||||
.expect("查询患者应成功");
|
||||
assert_eq!(found.name, "张三");
|
||||
assert_eq!(found.gender, Some("male".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_patients() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 创建 2 个患者
|
||||
for i in 0..2 {
|
||||
let req = CreatePatientReq {
|
||||
name: format!("患者{}", i + 1),
|
||||
gender: if i == 0 { Some("male".to_string()) } else { Some("female".to_string()) },
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
patient_service::create_patient(&state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
}
|
||||
|
||||
let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None)
|
||||
.await
|
||||
.expect("列表查询应成功");
|
||||
|
||||
assert_eq!(result.total, 2, "应有 2 条患者记录");
|
||||
assert_eq!(result.data.len(), 2, "当前页应返回 2 条");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_patient_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 创建患者
|
||||
let req_a = CreatePatientReq {
|
||||
name: "租户A患者".to_string(),
|
||||
gender: Some("male".to_string()),
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
let patient_a = patient_service::create_patient(&state, tenant_a, None, req_a)
|
||||
.await
|
||||
.expect("租户 A 创建患者应成功");
|
||||
|
||||
// 租户 B 列表查询应看不到租户 A 的患者
|
||||
let result_b = patient_service::list_patients(&state, tenant_b, 1, 10, None, None)
|
||||
.await
|
||||
.expect("租户 B 列表查询应成功");
|
||||
assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的患者");
|
||||
assert!(result_b.data.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的患者应返回 PatientNotFound
|
||||
let lookup_result = patient_service::get_patient(&state, tenant_b, patient_a.id).await;
|
||||
assert!(
|
||||
lookup_result.is_err(),
|
||||
"跨租户查询应返回错误"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_patient_validation_gender() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
let req = CreatePatientReq {
|
||||
name: "无效性别患者".to_string(),
|
||||
gender: Some("unknown".to_string()), // 不在白名单 ["male", "female", "other"] 中
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
|
||||
let result = patient_service::create_patient(&state, tenant_id, None, req).await;
|
||||
assert!(result.is_err(), "无效性别应返回校验错误");
|
||||
|
||||
// 验证错误消息包含字段名
|
||||
let err_msg = format!("{:#}", result.unwrap_err());
|
||||
assert!(
|
||||
err_msg.contains("gender"),
|
||||
"错误消息应包含 'gender' 字段名,实际: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_patient_soft_delete() {
|
||||
let test_db = TestDb::new().await;
|
||||
let state = make_state(test_db.db());
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 创建患者
|
||||
let req = CreatePatientReq {
|
||||
name: "待删除患者".to_string(),
|
||||
gender: Some("other".to_string()),
|
||||
birth_date: None,
|
||||
blood_type: None,
|
||||
id_number: None,
|
||||
allergy_history: None,
|
||||
medical_history_summary: None,
|
||||
emergency_contact_name: None,
|
||||
emergency_contact_phone: None,
|
||||
source: None,
|
||||
notes: None,
|
||||
};
|
||||
let patient = patient_service::create_patient(&state, tenant_id, None, req)
|
||||
.await
|
||||
.expect("创建患者应成功");
|
||||
|
||||
// 软删除
|
||||
patient_service::delete_patient(&state, tenant_id, patient.id, None, patient.version)
|
||||
.await
|
||||
.expect("软删除应成功");
|
||||
|
||||
// 列表不应包含已软删除的患者
|
||||
let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None)
|
||||
.await
|
||||
.expect("列表查询应成功");
|
||||
assert_eq!(result.total, 0, "软删除后列表应为空");
|
||||
assert!(result.data.is_empty());
|
||||
|
||||
// get_patient 也应返回 PatientNotFound
|
||||
let lookup = patient_service::get_patient(&state, tenant_id, patient.id).await;
|
||||
assert!(lookup.is_err(), "软删除后查询应返回错误");
|
||||
}
|
||||
@@ -15,9 +15,10 @@ impl TestDb {
|
||||
pub async fn new() -> Self {
|
||||
let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple());
|
||||
|
||||
// 连接本地 PostgreSQL 的默认库(postgres)来创建测试库
|
||||
let admin_url = "postgres://postgres:123123@localhost:5432/postgres";
|
||||
let admin_db = Database::connect(admin_url)
|
||||
let admin_url = std::env::var("TEST_DB_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
|
||||
|
||||
let admin_db = Database::connect(&admin_url)
|
||||
.await
|
||||
.expect("连接本地 PostgreSQL 失败,请确认服务正在运行");
|
||||
|
||||
@@ -31,8 +32,12 @@ impl TestDb {
|
||||
|
||||
drop(admin_db);
|
||||
|
||||
// 连接测试库
|
||||
let test_url = format!("postgres://postgres:123123@localhost:5432/{}", db_name);
|
||||
// 从 admin_url 推导测试库 URL(替换路径部分)
|
||||
let test_url = if let Some(pos) = admin_url.rfind('/') {
|
||||
format!("{}/{}", &admin_url[..pos], db_name)
|
||||
} else {
|
||||
format!("postgres://postgres:123123@localhost:5432/{}", db_name)
|
||||
};
|
||||
let db = Database::connect(&test_url)
|
||||
.await
|
||||
.expect("连接测试数据库失败");
|
||||
@@ -63,8 +68,9 @@ impl Drop for TestDb {
|
||||
.build();
|
||||
if let Ok(rt) = rt {
|
||||
rt.block_on(async {
|
||||
let admin_url = "postgres://postgres:123123@localhost:5432/postgres";
|
||||
if let Ok(admin_db) = Database::connect(admin_url).await {
|
||||
let admin_url = std::env::var("TEST_DB_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string());
|
||||
if let Ok(admin_db) = Database::connect(&admin_url).await {
|
||||
let disconnect_sql = format!(
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'",
|
||||
db_name
|
||||
|
||||
Reference in New Issue
Block a user