From 603af83aa90f2c8a4d0e7aa0ca7fe8e3a57abbb5 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 2 May 2026 23:42:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20P0=20=E6=AD=A2=E8=A1=80=20=E2=80=94=20?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=B4=A9=E6=BA=83=E9=A3=8E=E9=99=A9=20+=20?= =?UTF-8?q?=E4=BC=AACAS=E4=BF=AE=E5=A4=8D=20+=20=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E6=B8=85=E9=99=A4=20+=20=E6=99=9A=E9=97=B4=E8=A1=80?= =?UTF-8?q?=E5=8E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 字段 --- Cargo.lock | 23 +++--------- .../src/pages/pkg-health/input/index.tsx | 21 ++++++----- apps/web/src/pages/Home.tsx | 2 +- .../components/workbench/AdminDashboard.tsx | 10 +++--- .../workbench/OperatorWorkbench.tsx | 2 +- crates/erp-core/src/lib.rs | 1 + crates/erp-core/src/sea_orm_ext.rs | 17 +++++++++ .../src/service/device_reading_service.rs | 12 ++++--- .../src/service/follow_up_service.rs | 3 +- .../erp-health/src/service/points_service.rs | 36 ++++++++++++------- crates/erp-plugin/src/service.rs | 4 ++- crates/erp-server/src/outbox.rs | 2 +- .../src/service/definition_service.rs | 6 ++-- 13 files changed, 82 insertions(+), 57 deletions(-) create mode 100644 crates/erp-core/src/sea_orm_ext.rs diff --git a/Cargo.lock b/Cargo.lock index f715acf..b0de519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/apps/miniprogram/src/pages/pkg-health/input/index.tsx b/apps/miniprogram/src/pages/pkg-health/input/index.tsx index 82b227d..a9c89be 100644 --- a/apps/miniprogram/src/pages/pkg-health/input/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/input/index.tsx @@ -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 = { 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() { {/* 数值输入 */} - {INDICATORS[indicatorIdx].value === 'blood_pressure' ? ( + {BP_INDICATORS.includes(INDICATORS[indicatorIdx].value) ? ( 血压数值 diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 58ee470..746771d 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -139,7 +139,7 @@ const ROLE_STATS: Record = { { key: 'issued', title: '积分发放', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: , path: '/health/points' }, { key: 'spent', title: '积分消费', getValue: (_p, s) => s.pointsStats?.total_spent ?? 0, icon: , path: '/health/mall' }, { key: 'active', title: '活跃账户', getValue: (_p, s) => s.pointsStats?.active_accounts ?? 0, icon: , path: '/health/points' }, - { key: 'articles', title: '内容发布', getValue: () => 0, icon: , path: '/health/content' }, + { key: 'articles', title: '内容发布', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: , path: '/health/content' }, ], }; diff --git a/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx index 14220b1..13a2a78 100644 --- a/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx +++ b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx @@ -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 = [ diff --git a/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx b/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx index ef3bfc8..29afdb3 100644 --- a/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx +++ b/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx @@ -65,7 +65,7 @@ export default function OperatorWorkbench() { className="erp-fade-in">
-
{greeting},{firstName.charAt(0)}美玲。AI 帮你梳理了今天的运营重点:
+
{greeting},{firstName}。AI 帮你梳理了今天的运营重点:
{stats?.total_pending ?? 0} 个运营洞察需要关注
1. 积分兑换活动数据 — 今日发放 {statsData.pointsStats?.total_issued ?? 0} 积分,消费 {statsData.pointsStats?.total_spent ?? 0}。
diff --git a/crates/erp-core/src/lib.rs b/crates/erp-core/src/lib.rs index b857801..346c109 100644 --- a/crates/erp-core/src/lib.rs +++ b/crates/erp-core/src/lib.rs @@ -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)] diff --git a/crates/erp-core/src/sea_orm_ext.rs b/crates/erp-core/src/sea_orm_ext.rs new file mode 100644 index 0000000..8602769 --- /dev/null +++ b/crates/erp-core/src/sea_orm_ext.rs @@ -0,0 +1,17 @@ +use sea_orm::ActiveValue; + +/// 从 SeaORM ActiveValue 中安全提取 version 值。 +/// Set(v) / Unchanged(v) → 返回 v +/// NotSet → 返回 1(首次版本号) +/// 绝不 panic。 +pub fn safe_version(val: &ActiveValue) -> i32 { + match val { + ActiveValue::Set(v) | ActiveValue::Unchanged(v) => *v, + ActiveValue::NotSet => 1, + } +} + +/// 安全递增 version:基于当前值 +1,绝不 panic。 +pub fn bump_version(current: &ActiveValue) -> i32 { + safe_version(current) + 1 +} diff --git a/crates/erp-health/src/service/device_reading_service.rs b/crates/erp-health/src/service/device_reading_service.rs index fd7cf76..2a9b17e 100644 --- a/crates/erp-health/src/service/device_reading_service.rs +++ b/crates/erp-health/src/service/device_reading_service.rs @@ -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 { diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index fb43be8..841ec3f 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -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; } diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index 24f28b0..df4898e 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -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(®_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(®_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(()) diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs index 45e3d63..d942bb8 100644 --- a/crates/erp-plugin/src/service.rs +++ b/crates/erp-plugin/src/service.rs @@ -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) diff --git a/crates/erp-server/src/outbox.rs b/crates/erp-server/src/outbox.rs index 19dde6a..faa1e75 100644 --- a/crates/erp-server/src/outbox.rs +++ b/crates/erp-server/src/outbox.rs @@ -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?; } diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs index ac2d386..920445d 100644 --- a/crates/erp-workflow/src/service/definition_service.rs +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -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::>(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 = serde_json::from_value(nodes_val) .map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?; let edges: Vec = serde_json::from_value(edges_val)