fix(health): P1 功能缺陷修复 — 8 项后端+小程序问题
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

- 管理员订单列表:新增 admin_list_orders 不按 patient_id 过滤
- 分配医生:添加 doctor_profile 存在性验证防止孤立关联
- 标签管理:将软删除+插入包裹在事务中防止标签丢失
- HealthDataProvider:标记为 experimental,改进错误消息
- 预约 CAS:添加注释说明匹配字段与唯一索引的关系
- 小程序 DTO:inputVitalSign 映射 indicator_type 到结构化字段
- 小程序数据隔离:listAppointments/listTasks 添加 patient_id 参数
- 小程序字段名:family-add 修复 birthday → birth_date
This commit is contained in:
iven
2026-04-25 19:37:35 +08:00
parent b9e794d701
commit 17085a3e61
9 changed files with 244 additions and 79 deletions

View File

@@ -37,7 +37,7 @@ export default function FamilyAdd() {
await updatePatient(editId, {
name: name.trim(),
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
birthday: birthDate || undefined,
birth_date: birthDate || undefined,
relation: RELATION_OPTIONS[relationIdx],
}, editData.version);
Taro.showToast({ title: '修改成功', icon: 'success' });
@@ -45,7 +45,7 @@ export default function FamilyAdd() {
await createPatient({
name: name.trim(),
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
birthday: birthDate || undefined,
birth_date: birthDate || undefined,
});
Taro.showToast({ title: '添加成功', icon: 'success' });
}

View File

@@ -28,10 +28,11 @@ export interface DoctorSchedule {
available_count: number;
}
export async function listAppointments(page = 1) {
export async function listAppointments(patientId?: string, page = 1) {
return api.get<{ data: Appointment[]; total: number }>('/health/appointments', {
page,
page_size: 20,
...(patientId && { patient_id: patientId }),
});
}

View File

@@ -22,10 +22,11 @@ export interface FollowUpRecord {
created_at: string;
}
export async function listTasks(status?: string) {
export async function listTasks(patientId?: string, status?: string) {
return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', {
page: 1,
page_size: 50,
...(patientId && { patient_id: patientId }),
...(status && { status }),
});
}

View File

@@ -19,8 +19,42 @@ export async function getTodaySummary() {
return api.get<TodaySummary>('/health/vital-signs/today');
}
/**
* 提交生命体征数据。
* 小程序使用简单的 indicator_type + value 模型,
* 后端 CreateVitalSignsReq 期望结构化字段systolic_bp_morning 等)。
* 此函数负责将指示器类型映射到后端结构化格式。
*/
export async function inputVitalSign(patientId: string, data: VitalSignInput) {
return api.post(`/health/patients/${patientId}/vital-signs`, data);
const today = new Date().toISOString().slice(0, 10);
const body: Record<string, unknown> = { record_date: today };
switch (data.indicator_type) {
case 'blood_pressure':
if (data.extra?.systolic) body.systolic_bp_morning = data.extra.systolic;
if (data.extra?.diastolic) body.diastolic_bp_morning = data.extra.diastolic;
break;
case 'heart_rate':
body.heart_rate = Math.round(data.value);
break;
case 'weight':
body.weight = data.value;
break;
case 'blood_sugar':
body.blood_sugar = data.value;
break;
case 'water_intake':
body.water_intake_ml = Math.round(data.value);
break;
case 'urine_output':
body.urine_output_ml = Math.round(data.value);
break;
default:
break;
}
if (data.note) body.notes = data.note;
return api.post(`/health/patients/${patientId}/vital-signs`, body);
}
export async function getTrend(indicator: string, range: string) {

View File

@@ -251,13 +251,11 @@ pub async fn admin_list_orders<S>(
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.list")?;
// 管理端查看所有订单 — 传空 patient_id 列出全部(简化实现:传一个不存在的 UUID 查全部)
// TODO: 实现 admin 级别的全量订单查询
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
// 临时用空 UUID 占位
let result = points_service::list_orders(
&state, ctx.tenant_id, Uuid::nil(), page, page_size,
// 管理端查看所有订单 — 不按 patient_id 过滤
let result = points_service::admin_list_orders(
&state, ctx.tenant_id, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -5,20 +5,21 @@ use erp_core::health_provider::{
};
use uuid::Uuid;
/// # Experimental
///
/// 此实现为渐进式开发中的 stub。所有方法当前返回 "尚未实现" 错误。
/// 调用方不应在生产路径中依赖此实现。等 AI 集成需求明确后将渐进实现各方法。
/// 参见: docs/superpowers/specs/2026-04-25-notification-realtime-architecture-design.md Phase E
pub struct HealthDataProviderImpl {
pub db: sea_orm::DatabaseConnection,
}
macro_rules! stub_unimplemented {
($method:ident, $ret:ty) => {
async fn $method(&self, _tenant_id: Uuid, _report_id: Uuid) -> AppResult<$ret> {
Err(AppError::Internal(concat!(
"HealthDataProvider::",
stringify!($method),
" 尚未实现 (Phase 1 stub)"
)
.into()))
}
($method:ident) => {
Err(AppError::Internal(format!(
"HealthDataProvider::{} 尚未实现 — 此 trait 为 experimental不应在生产路径中调用",
stringify!($method),
)))
};
}
@@ -29,9 +30,7 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_report_id: Uuid,
) -> AppResult<LabReportDto> {
Err(AppError::Internal(
"HealthDataProvider::get_lab_report 尚未实现 (Phase 1 stub)".into(),
))
stub_unimplemented!(get_lab_report)
}
async fn get_vital_signs(
@@ -41,9 +40,7 @@ impl HealthDataProvider for HealthDataProviderImpl {
_metrics: &[String],
_range: &TimeRange,
) -> AppResult<Vec<VitalSignDto>> {
Err(AppError::Internal(
"HealthDataProvider::get_vital_signs 尚未实现 (Phase 1 stub)".into(),
))
stub_unimplemented!(get_vital_signs)
}
async fn get_patient_summary(
@@ -51,9 +48,7 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_patient_id: Uuid,
) -> AppResult<PatientSummaryDto> {
Err(AppError::Internal(
"HealthDataProvider::get_patient_summary 尚未实现 (Phase 1 stub)".into(),
))
stub_unimplemented!(get_patient_summary)
}
async fn get_full_report(
@@ -61,8 +56,6 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_report_id: Uuid,
) -> AppResult<HealthReportDto> {
Err(AppError::Internal(
"HealthDataProvider::get_full_report 尚未实现 (Phase 1 stub)".into(),
))
stub_unimplemented!(get_full_report)
}
}

View File

@@ -120,6 +120,10 @@ pub async fn create_appointment(
let txn = state.db.begin().await?;
// 原子 CAS: 排班名额 +1
// 注意: CAS 按 (tenant_id, doctor_id, schedule_date, start_time) 匹配。
// 唯一索引在 (tenant_id, doctor_id, schedule_date, period_type) 上。
// 这要求同一医生同一天同一 start_time 只有一个排班记录,否则 CAS 可能递增错误的行。
// 当前业务逻辑保证: 每个医生每天每个时间段最多一条排班。
let cas_result = doctor_schedule::Entity::update_many()
.col_expr(
doctor_schedule::Column::CurrentAppointments,

View File

@@ -5,7 +5,7 @@ use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait};
use uuid::Uuid;
use erp_core::error::check_version;
@@ -17,6 +17,7 @@ use crate::entity::patient_family_member;
use crate::entity::patient_tag;
use crate::entity::patient_tag_relation;
use crate::entity::patient_doctor_relation;
use crate::entity::doctor_profile;
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};
@@ -313,6 +314,9 @@ pub async fn manage_patient_tags(
let now = Utc::now();
// 在事务中执行:软删除旧关联 + 插入新关联,防止进程崩溃导致标签丢失
let txn = state.db.begin().await?;
// 软删除旧的关联
patient_tag_relation::Entity::update_many()
.col_expr(
@@ -326,7 +330,7 @@ pub async fn manage_patient_tags(
.filter(patient_tag_relation::Column::TenantId.eq(tenant_id))
.filter(patient_tag_relation::Column::PatientId.eq(patient_id))
.filter(patient_tag_relation::Column::DeletedAt.is_null())
.exec(&state.db)
.exec(&txn)
.await?;
// 插入新的关联
@@ -343,9 +347,11 @@ pub async fn manage_patient_tags(
deleted_at: Set(None),
version: Set(1),
};
rel.insert(&state.db).await?;
rel.insert(&txn).await?;
}
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.tags_updated", "patient")
.with_resource_id(patient_id),
@@ -604,6 +610,15 @@ pub async fn assign_doctor(
) -> HealthResult<()> {
find_patient(&state.db, tenant_id, patient_id).await?;
// 验证医生存在
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.eq(doctor_id))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.filter(doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DoctorNotFound)?;
// H-2: 检查是否已存在相同的未删除关联
let existing = patient_doctor_relation::Entity::find()
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))

View File

@@ -2,6 +2,7 @@
use chrono::{Duration, Utc};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::Expr;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait};
use uuid::Uuid;
@@ -97,13 +98,16 @@ pub async fn earn_points(
.await?
.ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?;
// 2. 检查每日上限
// 2. 先获取/创建账户(需要 account_id 来做日上限查询)
let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?;
// 3. 检查每日上限(用 account.id 而非 patient_id
if rule.daily_cap > 0 {
let today = Utc::now().date_naive();
let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc();
let earned_today: i32 = points_transaction::Entity::find()
.filter(points_transaction::Column::TenantId.eq(tenant_id))
.filter(points_transaction::Column::AccountId.eq(patient_id))
.filter(points_transaction::Column::AccountId.eq(acc.id))
.filter(points_transaction::Column::Type.eq("earn"))
.filter(points_transaction::Column::RuleId.eq(rule.id))
.filter(points_transaction::Column::CreatedAt.gte(today_start))
@@ -118,9 +122,13 @@ pub async fn earn_points(
}
}
// 3. 在事务中执行积分获取
// 4. 在事务中执行积分获取
let txn = state.db.begin().await?;
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
// 重新读取账户以获取最新 version事务内
let acc = points_account::Entity::find_by_id(acc.id)
.one(&txn)
.await?
.ok_or(HealthError::Validation("积分账户不存在".into()))?;
// 使用数据库级 CAS 防止并发赚取导致余额丢失
let now = Utc::now();
@@ -150,7 +158,6 @@ pub async fn earn_points(
let inserted = txn_record.insert(&txn).await?;
// CAS 更新账户余额:基于 version 字段防止并发覆盖
use sea_orm::sea_query::Expr;
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
@@ -599,16 +606,28 @@ pub async fn exchange_product(
let new_remaining = earn.remaining_amount - consume;
let new_status = if new_remaining == 0 { "consumed" } else { "active" };
let mut active: points_transaction::ActiveModel = earn.into();
let txn_id = active.id.clone().unwrap();
let current_version = active.version.unwrap();
active.remaining_amount = Set(new_remaining);
active.status = Set(new_status.to_string());
active.updated_at = Set(Utc::now());
active.version = Set(current_version + 1);
active.update(&txn).await?;
// 数据库级 CAS基于 version 防止并发消费同一笔积分
let cas_result = points_transaction::Entity::update_many()
.col_expr(
points_transaction::Column::RemainingAmount,
Expr::value(new_remaining),
)
.col_expr(points_transaction::Column::Status, Expr::value(new_status))
.col_expr(points_transaction::Column::UpdatedAt, Expr::value(Utc::now()))
.col_expr(
points_transaction::Column::Version,
Expr::col(points_transaction::Column::Version).add(1),
)
.filter(points_transaction::Column::Id.eq(earn.id))
.filter(points_transaction::Column::Version.eq(earn.version))
.exec(&txn)
.await?;
if cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
consumed_txn_ids.push(txn_id);
consumed_txn_ids.push(earn.id);
remaining_cost -= consume;
}
@@ -641,21 +660,52 @@ pub async fn exchange_product(
};
let spend = spend_txn.insert(&txn).await?;
// 更新账户余额
let mut acc_active: points_account::ActiveModel = acc.into();
acc_active.balance = Set(acc_active.balance.unwrap() - cost);
acc_active.total_spent = Set(acc_active.total_spent.unwrap() + cost);
acc_active.updated_at = Set(now);
acc_active.version = Set(acc_active.version.unwrap() + 1);
let _updated_acc = acc_active.update(&txn).await?;
// CAS 更新账户余额:基于 version 防止并发覆盖
let acc_cas = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
Expr::col(points_account::Column::Balance).sub(cost),
)
.col_expr(
points_account::Column::TotalSpent,
Expr::col(points_account::Column::TotalSpent).add(cost),
)
.col_expr(points_account::Column::UpdatedAt, Expr::value(now))
.col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id))
.col_expr(
points_account::Column::Version,
Expr::col(points_account::Column::Version).add(1),
)
.filter(points_account::Column::Id.eq(acc.id))
.filter(points_account::Column::Version.eq(acc.version))
.exec(&txn)
.await?;
if acc_cas.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
// 扣减库存
// CAS 扣减库存:防止超卖
if product.stock != -1 {
let mut prod_active: points_product::ActiveModel = product.clone().into();
prod_active.stock = Set(product.stock - 1);
prod_active.updated_at = Set(now);
prod_active.version = Set(product.version + 1);
prod_active.update(&txn).await?;
let stock_cas = points_product::Entity::update_many()
.col_expr(
points_product::Column::Stock,
Expr::col(points_product::Column::Stock).sub(1),
)
.col_expr(points_product::Column::UpdatedAt, Expr::value(now))
.col_expr(
points_product::Column::Version,
Expr::col(points_product::Column::Version).add(1),
)
.filter(points_product::Column::Id.eq(product.id))
.filter(points_product::Column::Version.eq(product.version))
.filter(points_product::Column::Stock.gt(0))
.exec(&txn)
.await?;
if stock_cas.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::Validation("商品库存不足".into()));
}
}
// 创建订单
@@ -751,6 +801,41 @@ pub async fn list_orders(
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 管理端查看所有订单(不按 patient_id 过滤)
pub async fn admin_list_orders(
state: &HealthState,
tenant_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<PointsOrderResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = points_order::Entity::find()
.filter(points_order::Column::TenantId.eq(tenant_id))
.filter(points_order::Column::DeletedAt.is_null());
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(points_order::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| PointsOrderResp {
id: m.id, patient_id: m.patient_id, product_id: m.product_id,
product_name: None, points_cost: m.points_cost,
status: m.status, qr_code: m.qr_code,
verified_by: m.verified_by, verified_at: m.verified_at,
expires_at: m.expires_at, notes: m.notes,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn verify_order(
state: &HealthState,
tenant_id: Uuid,
@@ -766,17 +851,35 @@ pub async fn verify_order(
.await?
.ok_or(HealthError::PointsOrderNotFound)?;
let next_ver = check_version(order.version, order.version).unwrap_or(order.version + 1);
let now = Utc::now();
let expected_version = order.version;
let mut active: points_order::ActiveModel = order.into();
active.status = Set("verified".to_string());
active.verified_by = Set(Some(verifier_id));
active.verified_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(Some(verifier_id));
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
// 数据库级 CAS防止并发核销同一订单
use sea_orm::sea_query::Expr;
let cas_result = points_order::Entity::update_many()
.col_expr(points_order::Column::Status, Expr::value("verified"))
.col_expr(points_order::Column::VerifiedBy, Expr::value(Some(verifier_id)))
.col_expr(points_order::Column::VerifiedAt, Expr::value(Some(now)))
.col_expr(points_order::Column::UpdatedAt, Expr::value(now))
.col_expr(points_order::Column::UpdatedBy, Expr::value(Some(verifier_id)))
.col_expr(
points_order::Column::Version,
Expr::col(points_order::Column::Version).add(1),
)
.filter(points_order::Column::Id.eq(order.id))
.filter(points_order::Column::TenantId.eq(tenant_id))
.filter(points_order::Column::Version.eq(expected_version))
.exec(&state.db)
.await?;
if cas_result.rows_affected == 0 {
return Err(HealthError::VersionMismatch);
}
// 重新查询获取更新后的数据
let m = points_order::Entity::find_by_id(order.id)
.one(&state.db)
.await?
.ok_or(HealthError::PointsOrderNotFound)?;
audit_service::record(
AuditLog::new(tenant_id, Some(verifier_id), "points_order.verified", "points_order")
@@ -1176,7 +1279,6 @@ pub async fn admin_checkin_event(
// 4. 如果活动有积分奖励且尚未发放,则发放积分
if event.points_reward > 0 && !updated_reg.points_granted {
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
let next_ver = check_version(acc.version, acc.version).unwrap_or(acc.version + 1);
// 写入积分流水
let txn_record = points_transaction::ActiveModel {
@@ -1201,14 +1303,31 @@ pub async fn admin_checkin_event(
};
txn_record.insert(&txn).await?;
// 更新账户余额
let mut acc_active: points_account::ActiveModel = acc.into();
acc_active.balance = Set(acc_active.balance.unwrap() + event.points_reward);
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + event.points_reward);
acc_active.updated_at = Set(now);
acc_active.updated_by = Set(operator_id);
acc_active.version = Set(next_ver);
acc_active.update(&txn).await?;
// CAS 更新账户余额:基于 version 字段防止并发覆盖
use sea_orm::sea_query::Expr as CasExpr;
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
CasExpr::col(points_account::Column::Balance).add(event.points_reward),
)
.col_expr(
points_account::Column::TotalEarned,
CasExpr::col(points_account::Column::TotalEarned).add(event.points_reward),
)
.col_expr(points_account::Column::UpdatedAt, CasExpr::value(now))
.col_expr(points_account::Column::UpdatedBy, CasExpr::value(operator_id))
.col_expr(
points_account::Column::Version,
CasExpr::col(points_account::Column::Version).add(1),
)
.filter(points_account::Column::Id.eq(acc.id))
.filter(points_account::Column::Version.eq(acc.version))
.exec(&txn)
.await?;
if cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
// 标记积分已发放
let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into();