fix(health): P1 功能缺陷修复 — 8 项后端+小程序问题
- 管理员订单列表:新增 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:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user