fix: P0 止血 — 消除崩溃风险 + 伪CAS修复 + 硬编码清除 + 晚间血压
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

- 新增 sea_orm_ext 模块: safe_version() / bump_version() 替代 14 处 unwrap()
- 修复 points_service 伪 CAS 逻辑 bug: 在 Set() 前提取原始版本并重新验证
- AdminDashboard: API 失败时显示 unknown 状态而非虚假绿色 healthy
- AdminDashboard: 今日操作改用真实数据,移除 "0 错误" 硬编码
- OperatorWorkbench: 移除硬编码 "美玲",改用真实用户名
- Home.tsx: operator "内容发布" 从硬编码 0 改为真实积分统计
- 小程序体征录入: 新增晚间血压 indicator_type,映射到 evening 字段
This commit is contained in:
iven
2026-05-02 23:42:01 +08:00
parent dd44c1526f
commit 603af83aa9
13 changed files with 82 additions and 57 deletions

23
Cargo.lock generated
View File

@@ -1662,30 +1662,12 @@ dependencies = [
"wit-bindgen 0.55.0",
]
[[package]]
name = "erp-points"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"erp-core",
"sea-orm",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
"uuid",
"validator",
]
[[package]]
name = "erp-server"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"chrono",
"config",
@@ -1699,6 +1681,8 @@ dependencies = [
"erp-plugin",
"erp-server-migration",
"erp-workflow",
"futures",
"hex",
"metrics",
"metrics-exporter-prometheus",
"moka",
@@ -1706,6 +1690,7 @@ dependencies = [
"sea-orm",
"serde",
"serde_json",
"sha2",
"sqlx",
"tokio",
"tower",

View File

@@ -11,7 +11,8 @@ import { trackEvent } from '@/services/analytics';
import './index.scss';
const INDICATORS = [
{ value: 'blood_pressure', label: '血压 (mmHg)' },
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
{ value: 'blood_pressure_evening', label: '晚间血压 (mmHg)' },
{ value: 'heart_rate', label: '心率 (bpm)' },
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
@@ -19,8 +20,10 @@ const INDICATORS = [
{ value: 'temperature', label: '体温 (℃)' },
];
const BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening'];
const vitalSignSchema = z.object({
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
indicator_type: z.enum(['blood_pressure', 'blood_pressure_evening', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
value: z.number().positive({ message: '请输入有效数值' }),
extra: z.object({
systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(),
@@ -34,11 +37,13 @@ function getWarnForIndicator(
thresholds: HealthThreshold[],
indicator: string,
): { max?: number; min?: number; warning: string } | null {
const high = findThreshold(thresholds, indicator === 'blood_pressure' ? 'systolic_bp' : indicator, 'high');
const low = findThreshold(thresholds, indicator === 'blood_pressure' ? 'systolic_bp' : indicator, 'low');
const isBp = BP_INDICATORS.includes(indicator);
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high');
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low');
if (!high && !low) return null;
const warningMap: Record<string, string> = {
blood_pressure: '收缩压偏高,建议及时就医',
blood_pressure_evening: '收缩压偏高,建议及时就医',
heart_rate: '心率异常,请注意休息',
blood_sugar_fasting: '血糖偏高,建议就医检查',
blood_sugar_postprandial: '血糖偏高,建议就医检查',
@@ -97,7 +102,7 @@ export default function HealthInput() {
const currentIndicator = INDICATORS[indicatorIdx].value;
if (currentIndicator === 'blood_pressure') {
if (BP_INDICATORS.includes(currentIndicator)) {
if (!systolic || !diastolic) {
Taro.showToast({ title: '请填写收缩压和舒张压', icon: 'none' });
return;
@@ -109,8 +114,8 @@ export default function HealthInput() {
}
}
const input = currentIndicator === 'blood_pressure'
? { indicator_type: 'blood_pressure' as const, value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
const input = BP_INDICATORS.includes(currentIndicator)
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
: { indicator_type: currentIndicator as any, value: parseFloat(value) };
const result = vitalSignSchema.safeParse(input);
@@ -188,7 +193,7 @@ export default function HealthInput() {
</View>
{/* 数值输入 */}
{INDICATORS[indicatorIdx].value === 'blood_pressure' ? (
{BP_INDICATORS.includes(INDICATORS[indicatorIdx].value) ? (
<View className='input-card'>
<Text className='input-section-title'></Text>
<View className='input-bp-group'>

View File

@@ -139,7 +139,7 @@ const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
{ key: 'issued', title: '积分发放', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: <TrophyOutlined />, path: '/health/points' },
{ key: 'spent', title: '积分消费', getValue: (_p, s) => s.pointsStats?.total_spent ?? 0, icon: <ShoppingOutlined />, path: '/health/mall' },
{ key: 'active', title: '活跃账户', getValue: (_p, s) => s.pointsStats?.active_accounts ?? 0, icon: <TeamOutlined />, path: '/health/points' },
{ key: 'articles', title: '内容发布', getValue: () => 0, icon: <FileTextOutlined />, path: '/health/content' },
{ key: 'articles', title: '内容发布', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: <FileTextOutlined />, path: '/health/content' },
],
};

View File

@@ -90,15 +90,15 @@ export default function AdminDashboard() {
const statCards = [
{ label: '注册用户', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, color: '#2563EB', gradient: 'linear-gradient(90deg,#2563EB,#60A5FA)', sub: `今日活跃 ${userActivity?.daily_active ?? 0}` },
{ label: '业务模块', value: `${activeModules} / ${totalModules}`, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: `${totalModules - activeModules} 个插件待启用` },
{ label: '今日操作', value: auditLogs.length, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: 'API 请求 · 0 错误' },
{ label: '业务模块', value: `${activeModules} / ${totalModules}`, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: totalModules > 0 ? `${totalModules - activeModules} 个插件待启用` : '加载中...' },
{ label: '今日操作', value: userActivity?.daily_active ?? 0, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: `${auditLogs.length} 条记录` },
{ label: '本周活跃', value: userActivity?.weekly_active ?? 0, color: '#EA580C', gradient: 'linear-gradient(90deg,#EA580C,#FB923C)', sub: `月活 ${userActivity?.monthly_active ?? 0}` },
];
const healthServices = systemHealth?.services ?? [
{ name: 'API 服务', status: 'healthy', message: '检测中...', response_ms: null },
{ name: '数据库', status: 'healthy', message: '检测中...', response_ms: null },
{ name: '定时任务', status: 'healthy', message: '检测中...', response_ms: null },
{ name: 'API 服务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
{ name: '数据库', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
{ name: '定时任务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
];
const userActivityItems = [

View File

@@ -65,7 +65,7 @@ export default function OperatorWorkbench() {
className="erp-fade-in">
<div style={{ position: 'absolute', top: -30, right: -30, width: 150, height: 150, background: 'rgba(255,255,255,0.06)', borderRadius: '50%' }} />
<div style={{ position: 'absolute', bottom: -50, left: '25%', width: 200, height: 200, background: 'rgba(255,255,255,0.04)', borderRadius: '50%' }} />
<div style={{ fontSize: 14, opacity: 0.85, marginBottom: 4, position: 'relative', zIndex: 1 }}>{greeting}{firstName.charAt(0)}AI </div>
<div style={{ fontSize: 14, opacity: 0.85, marginBottom: 4, position: 'relative', zIndex: 1 }}>{greeting}{firstName}AI </div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, position: 'relative', zIndex: 1 }}>{stats?.total_pending ?? 0} </div>
<div style={{ fontSize: 13, lineHeight: 2, opacity: 0.92, position: 'relative', zIndex: 1 }}>
<b style={{ color: '#FED7AA', fontWeight: 600 }}>1. </b> {statsData.pointsStats?.total_issued ?? 0} {statsData.pointsStats?.total_spent ?? 0}<br />

View File

@@ -9,6 +9,7 @@ pub mod module;
pub mod rbac;
pub mod request_info;
pub mod sanitize;
pub mod sea_orm_ext;
pub mod types;
#[cfg(test)]

View File

@@ -0,0 +1,17 @@
use sea_orm::ActiveValue;
/// 从 SeaORM ActiveValue<i32> 中安全提取 version 值。
/// Set(v) / Unchanged(v) → 返回 v
/// NotSet → 返回 1首次版本号
/// 绝不 panic。
pub fn safe_version(val: &ActiveValue<i32>) -> i32 {
match val {
ActiveValue::Set(v) | ActiveValue::Unchanged(v) => *v,
ActiveValue::NotSet => 1,
}
}
/// 安全递增 version基于当前值 +1绝不 panic。
pub fn bump_version(current: &ActiveValue<i32>) -> i32 {
safe_version(current) + 1
}

View File

@@ -8,6 +8,8 @@ use uuid::Uuid;
use erp_core::events::DomainEvent;
use erp_core::types::PaginatedResponse;
use erp_core::sea_orm_ext::bump_version;
use crate::entity::{device_readings, patient, patient_devices, vital_signs, vital_signs_hourly};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_device_type;
@@ -187,12 +189,12 @@ async fn ensure_device_binding(
version: Set(1),
};
binding.insert(db).await?;
} else {
} else if let Some(existing) = existing {
// 更新最后同步时间
let mut active: patient_devices::ActiveModel = existing.unwrap().into();
let mut active: patient_devices::ActiveModel = existing.into();
active.last_sync_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
active.update(db).await?;
}
Ok(())
@@ -306,7 +308,7 @@ async fn upsert_hourly_aggregates(
active.avg_val = Set(combined_avg);
active.sample_count = Set(total_count);
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
active.update(db).await?;
} else {
to_insert.push(vital_signs_hourly::ActiveModel {
@@ -491,7 +493,7 @@ async fn sync_bp_glucose_to_vital_signs(
let mut model = if let Some(rec) = existing {
let mut m: vital_signs::ActiveModel = rec.into();
m.updated_at = Set(Utc::now());
m.version = Set(m.version.unwrap() + 1);
m.version = Set(bump_version(&m.version));
m
} else {
vital_signs::ActiveModel {

View File

@@ -4,6 +4,7 @@ use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use erp_core::sea_orm_ext::bump_version;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, DatabaseBackend, QueryOrder, QuerySelect, Statement, TransactionTrait};
use uuid::Uuid;
@@ -433,7 +434,7 @@ pub async fn batch_assign_tasks(
active.assigned_to = Set(Some(req.assigned_to));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
active.update(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
succeeded += 1;
}

View File

@@ -10,6 +10,7 @@ use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::DomainEvent;
use erp_core::sea_orm_ext::bump_version;
use erp_core::types::PaginatedResponse;
use crate::dto::points_dto::*;
@@ -770,7 +771,7 @@ pub async fn delete_product(
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
let m = active.update(&state.db).await?;
audit_service::record(
@@ -1272,7 +1273,7 @@ pub async fn delete_rule(
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
let m = active.update(&state.db).await?;
audit_service::record(
@@ -1511,7 +1512,7 @@ pub async fn delete_offline_event(
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
let m = active.update(&state.db).await?;
audit_service::record(
@@ -1597,7 +1598,7 @@ pub async fn admin_checkin_event(
reg_active.checked_in_by = Set(operator_id);
reg_active.updated_at = Set(now);
reg_active.updated_by = Set(operator_id);
reg_active.version = Set(reg_active.version.unwrap() + 1);
reg_active.version = Set(bump_version(&reg_active.version));
let updated_reg = reg_active.update(&txn).await?;
// 4. 如果活动有积分奖励且尚未发放,则发放积分
@@ -1656,7 +1657,7 @@ pub async fn admin_checkin_event(
let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into();
reg_active2.points_granted = Set(true);
reg_active2.updated_at = Set(now);
reg_active2.version = Set(reg_active2.version.unwrap() + 1);
reg_active2.version = Set(bump_version(&reg_active2.version));
reg_active2.update(&txn).await?;
}
@@ -1796,7 +1797,7 @@ pub async fn expire_points(db: &sea_orm::DatabaseConnection, event_bus: &erp_cor
let mut active_txn: points_transaction::ActiveModel = txn.into();
active_txn.status = Set("expired".to_string());
active_txn.remaining_amount = Set(0);
active_txn.version = Set(active_txn.version.unwrap() + 1);
active_txn.version = Set(bump_version(&active_txn.version));
active_txn.updated_at = Set(Utc::now());
active_txn.update(txn_db).await?;
@@ -1810,15 +1811,26 @@ pub async fn expire_points(db: &sea_orm::DatabaseConnection, event_bus: &erp_cor
let new_expired = account.total_expired + remaining;
let mut active_account: points_account::ActiveModel = account.into();
let original_ver: i32 = match active_account.version {
sea_orm::ActiveValue::Unchanged(v) => v,
_ => {
return Err(HealthError::Validation(
"积分账户版本号状态异常".to_string(),
))
}
};
active_account.balance = Set(new_balance);
active_account.total_expired = Set(new_expired);
active_account.version = Set(active_account.version.unwrap() + 1);
active_account.version = Set(original_ver + 1);
active_account.updated_at = Set(Utc::now());
let expected_ver: i32 = match &active_account.version {
sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v,
_ => 0,
};
let _next_ver = check_version(expected_ver, expected_ver)?;
// 重新从 DB 读取当前版本,防止并发修改导致伪 CAS 通过
let current = points_account::Entity::find_by_id(account_id)
.one(txn_db)
.await?
.ok_or_else(|| {
HealthError::Validation("积分账户不存在".to_string())
})?;
let _next_ver = check_version(original_ver, current.version)?;
active_account.update(txn_db).await?;
Ok(())

View File

@@ -4,6 +4,8 @@ use std::collections::HashMap;
use uuid::Uuid;
use sha2::{Sha256, Digest};
use erp_core::sea_orm_ext::bump_version;
use erp_core::error::AppResult;
use crate::dto::{
@@ -673,7 +675,7 @@ impl PluginService {
active.plugin_version = Set(new_version.clone());
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
active.version = Set(active.version.unwrap() + 1);
active.version = Set(bump_version(&active.version));
let updated = active
.update(db)

View File

@@ -127,7 +127,7 @@ async fn process_pending_events(
let mut active: domain_event::ActiveModel = event_model.into();
active.status = Set("published".to_string());
active.published_at = Set(Some(Utc::now()));
active.attempts = Set(active.attempts.unwrap() + 1);
active.attempts = Set(erp_core::sea_orm_ext::bump_version(&active.attempts));
active.update(db).await?;
}

View File

@@ -172,13 +172,13 @@ impl DefinitionService {
active.description = Set(Some(description.clone()));
}
// 当 nodes 或 edges 任一存在时,取最终值验证流程图完整性
let final_nodes = req.nodes.as_ref().or_else(|| {
let _final_nodes = req.nodes.as_ref().or_else(|| {
serde_json::from_value::<Vec<crate::dto::NodeDef>>(active.nodes.as_ref().clone()).ok().as_ref().map(|_| unreachable!())
});
// 简化:如果提供了 nodes 或 edges将两者合并后验证
if req.nodes.is_some() || req.edges.is_some() {
let nodes_val = req.nodes.as_ref().map(|n| serde_json::to_value(n).unwrap()).unwrap_or(active.nodes.as_ref().clone());
let edges_val = req.edges.as_ref().map(|e| serde_json::to_value(e).unwrap()).unwrap_or(active.edges.as_ref().clone());
let nodes_val = req.nodes.as_ref().map(|n| serde_json::to_value(n).unwrap_or_default()).unwrap_or(active.nodes.as_ref().clone());
let edges_val = req.edges.as_ref().map(|e| serde_json::to_value(e).unwrap_or_default()).unwrap_or(active.edges.as_ref().clone());
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(nodes_val)
.map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?;
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(edges_val)