From 17085a3e61caa7b13cf966035c29b86a5b2a4f02 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 19:37:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20P1=20=E5=8A=9F=E8=83=BD=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E4=BF=AE=E5=A4=8D=20=E2=80=94=208=20=E9=A1=B9?= =?UTF-8?q?=E5=90=8E=E7=AB=AF+=E5=B0=8F=E7=A8=8B=E5=BA=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理员订单列表:新增 admin_list_orders 不按 patient_id 过滤 - 分配医生:添加 doctor_profile 存在性验证防止孤立关联 - 标签管理:将软删除+插入包裹在事务中防止标签丢失 - HealthDataProvider:标记为 experimental,改进错误消息 - 预约 CAS:添加注释说明匹配字段与唯一索引的关系 - 小程序 DTO:inputVitalSign 映射 indicator_type 到结构化字段 - 小程序数据隔离:listAppointments/listTasks 添加 patient_id 参数 - 小程序字段名:family-add 修复 birthday → birth_date --- .../src/pages/profile/family-add/index.tsx | 4 +- apps/miniprogram/src/services/appointment.ts | 3 +- apps/miniprogram/src/services/followup.ts | 3 +- apps/miniprogram/src/services/health.ts | 36 ++- .../erp-health/src/handler/points_handler.rs | 8 +- crates/erp-health/src/health_provider_impl.rs | 35 ++- .../src/service/appointment_service.rs | 4 + .../erp-health/src/service/patient_service.rs | 21 +- .../erp-health/src/service/points_service.rs | 209 ++++++++++++++---- 9 files changed, 244 insertions(+), 79 deletions(-) diff --git a/apps/miniprogram/src/pages/profile/family-add/index.tsx b/apps/miniprogram/src/pages/profile/family-add/index.tsx index dad2408..beb1501 100644 --- a/apps/miniprogram/src/pages/profile/family-add/index.tsx +++ b/apps/miniprogram/src/pages/profile/family-add/index.tsx @@ -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' }); } diff --git a/apps/miniprogram/src/services/appointment.ts b/apps/miniprogram/src/services/appointment.ts index 633c345..2f9b1a9 100644 --- a/apps/miniprogram/src/services/appointment.ts +++ b/apps/miniprogram/src/services/appointment.ts @@ -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 }), }); } diff --git a/apps/miniprogram/src/services/followup.ts b/apps/miniprogram/src/services/followup.ts index 8c63fca..98060a2 100644 --- a/apps/miniprogram/src/services/followup.ts +++ b/apps/miniprogram/src/services/followup.ts @@ -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 }), }); } diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts index 591da8d..e4deb59 100644 --- a/apps/miniprogram/src/services/health.ts +++ b/apps/miniprogram/src/services/health.ts @@ -19,8 +19,42 @@ export async function getTodaySummary() { return api.get('/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 = { 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) { diff --git a/crates/erp-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs index e3460a3..079b075 100644 --- a/crates/erp-health/src/handler/points_handler.rs +++ b/crates/erp-health/src/handler/points_handler.rs @@ -251,13 +251,11 @@ pub async fn admin_list_orders( where HealthState: FromRef, 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))) } diff --git a/crates/erp-health/src/health_provider_impl.rs b/crates/erp-health/src/health_provider_impl.rs index 91bfaa4..6fa16dd 100644 --- a/crates/erp-health/src/health_provider_impl.rs +++ b/crates/erp-health/src/health_provider_impl.rs @@ -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 { - 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> { - 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 { - 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 { - Err(AppError::Internal( - "HealthDataProvider::get_full_report 尚未实现 (Phase 1 stub)".into(), - )) + stub_unimplemented!(get_full_report) } } diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 9d1cf92..ab8605b 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -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, diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 52c0cc5..ef48ff8 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -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)) diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index 1c2dbf9..e531886 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -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> { + 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();