Files
hms/crates/erp-health/src/service/points_service.rs
iven 97bb592688 feat(core): build_event_payload 统一信封 — 28 处事件发布全部迁移
- erp-core 添加 build_event_payload(),自动注入 schema_version + occurred_at
- erp-health 12 个 service(25 处)、erp-auth(1 处)、erp-workflow(2 处)
  全部迁移到统一信封格式
2026-04-27 18:01:05 +08:00

1806 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动
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;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::DomainEvent;
use erp_core::types::PaginatedResponse;
use crate::dto::points_dto::*;
use crate::entity::{
offline_event, offline_event_registration, points_account, points_checkin,
points_order, points_product, points_rule, points_transaction,
};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 积分账户
// ---------------------------------------------------------------------------
/// 获取或创建患者的积分账户(支持事务和非事务连接)
async fn get_or_create_account<C: sea_orm::ConnectionTrait>(
db: &C,
tenant_id: Uuid,
patient_id: Uuid,
) -> HealthResult<points_account::Model> {
if let Some(acc) = points_account::Entity::find()
.filter(points_account::Column::TenantId.eq(tenant_id))
.filter(points_account::Column::PatientId.eq(patient_id))
.filter(points_account::Column::DeletedAt.is_null())
.one(db)
.await?
{
return Ok(acc);
}
let now = Utc::now();
let active = points_account::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
balance: Set(0),
total_earned: Set(0),
total_spent: Set(0),
total_expired: Set(0),
version: Set(1),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
};
Ok(active.insert(db).await?)
}
pub async fn get_account(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
) -> HealthResult<PointsAccountResp> {
let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?;
Ok(PointsAccountResp {
id: acc.id,
patient_id: acc.patient_id,
balance: acc.balance,
total_earned: acc.total_earned,
total_spent: acc.total_spent,
total_expired: acc.total_expired,
created_at: acc.created_at,
updated_at: acc.updated_at,
version: acc.version,
})
}
// ---------------------------------------------------------------------------
// 积分获取(事件触发)
// ---------------------------------------------------------------------------
/// 核心方法:根据事件类型给患者加积分
pub async fn earn_points(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
event_type: &str,
operator_id: Option<Uuid>,
) -> HealthResult<PointsTransactionResp> {
// 1. 查找匹配规则
let rule = points_rule::Entity::find()
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::EventType.eq(event_type))
.filter(points_rule::Column::IsActive.eq(true))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?;
// 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(acc.id))
.filter(points_transaction::Column::TransactionType.eq("earn"))
.filter(points_transaction::Column::RuleId.eq(rule.id))
.filter(points_transaction::Column::CreatedAt.gte(today_start))
.all(&state.db)
.await?
.iter()
.map(|t| t.amount)
.sum();
if earned_today + rule.points_value > rule.daily_cap {
return Err(HealthError::Validation("今日该渠道积分已达上限".into()));
}
}
// 4. 在事务中执行积分获取
let txn = state.db.begin().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();
let expires_at = now + Duration::days(365); // 12 个月过期
// 写入流水
let txn_record = points_transaction::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
account_id: Set(acc.id),
transaction_type: Set("earn".to_string()),
amount: Set(rule.points_value),
remaining_amount: Set(rule.points_value),
status: Set("active".to_string()),
expires_at: Set(Some(expires_at)),
balance_after: Set(acc.balance + rule.points_value),
rule_id: Set(Some(rule.id)),
order_id: Set(None),
description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = txn_record.insert(&txn).await?;
// CAS 更新账户余额:基于 version 字段防止并发覆盖
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
Expr::col(points_account::Column::Balance).add(rule.points_value),
)
.col_expr(
points_account::Column::TotalEarned,
Expr::col(points_account::Column::TotalEarned).add(rule.points_value),
)
.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 cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction")
.with_resource_id(inserted.id),
&state.db,
).await;
state.event_bus.publish(
DomainEvent::new(crate::event::POINTS_EARNED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({
"transaction_id": inserted.id, "account_id": inserted.account_id,
"amount": inserted.amount, "balance_after": inserted.balance_after,
}))),
&state.db,
).await;
Ok(PointsTransactionResp {
id: inserted.id,
account_id: inserted.account_id,
transaction_type: inserted.transaction_type,
amount: inserted.amount,
remaining_amount: inserted.remaining_amount,
status: inserted.status,
expires_at: inserted.expires_at,
balance_after: inserted.balance_after,
description: inserted.description,
created_at: inserted.created_at,
})
}
// ---------------------------------------------------------------------------
// 每日打卡
// ---------------------------------------------------------------------------
pub async fn daily_checkin(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<CheckinStatusResp> {
let today = Utc::now().date_naive();
// 检查今日是否已打卡
let existing = points_checkin::Entity::find()
.filter(points_checkin::Column::TenantId.eq(tenant_id))
.filter(points_checkin::Column::PatientId.eq(patient_id))
.filter(points_checkin::Column::CheckinDate.eq(today))
.filter(points_checkin::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
if existing.is_some() {
let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await?;
return Ok(CheckinStatusResp {
checked_in_today: true,
consecutive_days: consecutive,
next_streak_milestone: next_milestone(consecutive),
});
}
// 计算连续天数
let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await? + 1;
// 事务:写入打卡记录 + 积分获取 + 阶梯奖励
let txn = state.db.begin().await?;
let now = Utc::now();
let active = points_checkin::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
checkin_date: Set(today),
consecutive_days: Set(consecutive),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
active.insert(&txn).await?;
// 在同一事务中执行积分获取
earn_points_in_txn(&txn, tenant_id, patient_id, "daily_checkin", operator_id).await?;
// 检查阶梯奖励(同一事务内)
let _streak_bonus = check_streak_bonus_in_txn(&txn, tenant_id, patient_id, consecutive, operator_id).await?;
txn.commit().await?;
let final_consecutive = consecutive;
Ok(CheckinStatusResp {
checked_in_today: true,
consecutive_days: final_consecutive,
next_streak_milestone: next_milestone(final_consecutive),
})
}
async fn compute_consecutive_days<C: sea_orm::ConnectionTrait>(
db: &C,
tenant_id: Uuid,
patient_id: Uuid,
today: chrono::NaiveDate,
) -> HealthResult<i32> {
let yesterday = today - Duration::days(1);
let yesterday_checkin = points_checkin::Entity::find()
.filter(points_checkin::Column::TenantId.eq(tenant_id))
.filter(points_checkin::Column::PatientId.eq(patient_id))
.filter(points_checkin::Column::CheckinDate.eq(yesterday))
.one(db)
.await?;
Ok(yesterday_checkin.map(|c| c.consecutive_days).unwrap_or(0))
}
/// 事务内版本的积分获取(由 daily_checkin 调用)
async fn earn_points_in_txn<C: sea_orm::ConnectionTrait>(
db: &C,
tenant_id: Uuid,
patient_id: Uuid,
event_type: &str,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
// 1. 查找匹配规则
let rule = points_rule::Entity::find()
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::EventType.eq(event_type))
.filter(points_rule::Column::IsActive.eq(true))
.filter(points_rule::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?;
// 2. 获取账户
let acc = get_or_create_account(db, tenant_id, patient_id).await?;
// 3. 检查每日上限
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(acc.id))
.filter(points_transaction::Column::TransactionType.eq("earn"))
.filter(points_transaction::Column::RuleId.eq(rule.id))
.filter(points_transaction::Column::CreatedAt.gte(today_start))
.all(db)
.await?
.iter()
.map(|t| t.amount)
.sum();
if earned_today + rule.points_value > rule.daily_cap {
return Err(HealthError::Validation("今日该渠道积分已达上限".into()));
}
}
// 4. 写入流水
let now = Utc::now();
let txn_record = points_transaction::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
account_id: Set(acc.id),
transaction_type: Set("earn".to_string()),
amount: Set(rule.points_value),
remaining_amount: Set(rule.points_value),
status: Set("active".to_string()),
expires_at: Set(Some(now + Duration::days(365))),
balance_after: Set(acc.balance + rule.points_value),
rule_id: Set(Some(rule.id)),
order_id: Set(None),
description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
txn_record.insert(db).await?;
// 5. CAS 更新账户余额
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
Expr::col(points_account::Column::Balance).add(rule.points_value),
)
.col_expr(
points_account::Column::TotalEarned,
Expr::col(points_account::Column::TotalEarned).add(rule.points_value),
)
.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(db)
.await?;
if cas_result.rows_affected == 0 {
return Err(HealthError::VersionMismatch);
}
Ok(())
}
/// 事务内版本的阶梯奖励检查(由 daily_checkin 调用)
async fn check_streak_bonus_in_txn<C: sea_orm::ConnectionTrait>(
db: &C,
tenant_id: Uuid,
patient_id: Uuid,
consecutive: i32,
operator_id: Option<Uuid>,
) -> HealthResult<i32> {
let mut bonus = 0i32;
if consecutive == 7 {
bonus = get_streak_bonus_value(db, tenant_id, "streak_7d_bonus").await?;
} else if consecutive == 14 {
bonus = get_streak_bonus_value(db, tenant_id, "streak_14d_bonus").await?;
} else if consecutive == 30 {
bonus = get_streak_bonus_value(db, tenant_id, "streak_30d_bonus").await?;
}
if bonus > 0 {
let acc = get_or_create_account(db, tenant_id, patient_id).await?;
let now = Utc::now();
let txn_record = points_transaction::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
account_id: Set(acc.id),
transaction_type: Set("earn".to_string()),
amount: Set(bonus),
remaining_amount: Set(bonus),
status: Set("active".to_string()),
expires_at: Set(Some(now + Duration::days(365))),
balance_after: Set(acc.balance + bonus),
rule_id: Set(None),
order_id: Set(None),
description: Set(Some(format!("连续打卡{}天奖励: +{}", consecutive, bonus))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
txn_record.insert(db).await?;
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
Expr::col(points_account::Column::Balance).add(bonus),
)
.col_expr(
points_account::Column::TotalEarned,
Expr::col(points_account::Column::TotalEarned).add(bonus),
)
.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(db)
.await?;
if cas_result.rows_affected == 0 {
return Err(HealthError::VersionMismatch);
}
}
Ok(bonus)
}
async fn get_streak_bonus_value<C: sea_orm::ConnectionTrait>(
db: &C,
tenant_id: Uuid,
field: &str,
) -> HealthResult<i32> {
let rule = points_rule::Entity::find()
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::EventType.eq("daily_checkin"))
.filter(points_rule::Column::IsActive.eq(true))
.filter(points_rule::Column::DeletedAt.is_null())
.one(db)
.await?;
Ok(rule.map(|r| match field {
"streak_7d_bonus" => r.streak_7d_bonus,
"streak_14d_bonus" => r.streak_14d_bonus,
"streak_30d_bonus" => r.streak_30d_bonus,
_ => 0,
}).unwrap_or(0))
}
fn next_milestone(consecutive: i32) -> Option<i32> {
[7, 14, 30].iter().find(|&&m| m > consecutive).copied()
}
pub async fn get_checkin_status(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
) -> HealthResult<CheckinStatusResp> {
let today = Utc::now().date_naive();
let existing = points_checkin::Entity::find()
.filter(points_checkin::Column::TenantId.eq(tenant_id))
.filter(points_checkin::Column::PatientId.eq(patient_id))
.filter(points_checkin::Column::CheckinDate.eq(today))
.one(&state.db)
.await?;
let consecutive = if let Some(ref ck) = existing {
ck.consecutive_days
} else {
compute_consecutive_days(&state.db, tenant_id, patient_id, today).await?
};
Ok(CheckinStatusResp {
checked_in_today: existing.is_some(),
consecutive_days: consecutive,
next_streak_milestone: next_milestone(consecutive),
})
}
// ---------------------------------------------------------------------------
// 积分流水查询
// ---------------------------------------------------------------------------
pub async fn list_transactions(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<PointsTransactionResp>> {
let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?;
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = points_transaction::Entity::find()
.filter(points_transaction::Column::TenantId.eq(tenant_id))
.filter(points_transaction::Column::AccountId.eq(acc.id));
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(points_transaction::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| PointsTransactionResp {
id: m.id, account_id: m.account_id, transaction_type: m.transaction_type,
amount: m.amount, remaining_amount: m.remaining_amount,
status: m.status, expires_at: m.expires_at,
balance_after: m.balance_after, description: m.description,
created_at: m.created_at,
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
// ---------------------------------------------------------------------------
// 商品管理
// ---------------------------------------------------------------------------
pub async fn list_products(
state: &HealthState,
tenant_id: Uuid,
product_type: Option<String>,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<PointsProductResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = points_product::Entity::find()
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::IsActive.eq(true))
.filter(points_product::Column::DeletedAt.is_null());
if let Some(ref pt) = product_type {
query = query.filter(points_product::Column::ProductType.eq(pt.as_str()));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_asc(points_product::Column::SortOrder)
.order_by_desc(points_product::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| PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
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 get_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
) -> HealthResult<PointsProductResp> {
let m = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
Ok(PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn create_product(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreatePointsProductReq,
) -> HealthResult<PointsProductResp> {
let now = Utc::now();
let active = points_product::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(req.name),
product_type: Set(req.product_type.unwrap_or_else(|| "physical".into())),
points_cost: Set(req.points_cost),
stock: Set(req.stock.unwrap_or(-1)),
image_url: Set(req.image_url),
description: Set(req.description),
service_config: Set(req.service_config),
is_active: Set(true),
sort_order: Set(req.sort_order.unwrap_or(0)),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.created", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsProductReq,
expected_version: i32,
) -> HealthResult<PointsProductResp> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(product_type) = req.product_type { active.product_type = Set(product_type); }
if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); }
if let Some(stock) = req.stock { active.stock = Set(stock); }
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.updated", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsProductResp {
id: m.id, name: m.name, product_type: m.product_type,
points_cost: m.points_cost, stock: m.stock,
image_url: m.image_url, description: m.description,
is_active: m.is_active, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_product(
state: &HealthState,
tenant_id: Uuid,
product_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_product::Entity::find()
.filter(points_product::Column::Id.eq(product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_product::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 兑换FIFO 消费积分)
// ---------------------------------------------------------------------------
pub async fn exchange_product(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: ExchangeReq,
operator_id: Option<Uuid>,
) -> HealthResult<PointsOrderResp> {
// 1. 查商品
let product = points_product::Entity::find()
.filter(points_product::Column::Id.eq(req.product_id))
.filter(points_product::Column::TenantId.eq(tenant_id))
.filter(points_product::Column::IsActive.eq(true))
.filter(points_product::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsProductNotFound)?;
// 2. 检查库存
if product.stock != -1 && product.stock <= 0 {
return Err(HealthError::Validation("商品库存不足".into()));
}
// 3. 检查积分余额
let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?;
if acc.balance < product.points_cost {
return Err(HealthError::Validation(format!(
"积分不足: 需要 {},当前 {}", product.points_cost, acc.balance
)));
}
// 4. 事务执行FIFO 扣减积分 + 创建订单
let txn = state.db.begin().await?;
let cost = product.points_cost;
let mut remaining_cost = cost;
// FIFO从最老的未过期 earn 记录开始扣减
let earn_records = points_transaction::Entity::find()
.filter(points_transaction::Column::TenantId.eq(tenant_id))
.filter(points_transaction::Column::AccountId.eq(acc.id))
.filter(points_transaction::Column::TransactionType.eq("earn"))
.filter(points_transaction::Column::Status.eq("active"))
.filter(points_transaction::Column::RemainingAmount.gt(0))
.filter(points_transaction::Column::ExpiresAt.gt(Utc::now()))
.order_by_asc(points_transaction::Column::CreatedAt)
.all(&txn)
.await?;
let mut consumed_txn_ids: Vec<Uuid> = Vec::new();
for earn in earn_records {
if remaining_cost <= 0 { break; }
let consume = remaining_cost.min(earn.remaining_amount);
let new_remaining = earn.remaining_amount - consume;
let new_status = if new_remaining == 0 { "consumed" } else { "active" };
// 数据库级 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(earn.id);
remaining_cost -= consume;
}
if remaining_cost > 0 {
txn.rollback().await?;
return Err(HealthError::Validation("可用积分不足以兑换(部分积分可能已过期)".into()));
}
// 写入消费流水
let now = Utc::now();
let spend_txn = points_transaction::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
account_id: Set(acc.id),
transaction_type: Set("spend".to_string()),
amount: Set(-cost),
remaining_amount: Set(0),
status: Set("active".to_string()),
expires_at: Set(None),
balance_after: Set(acc.balance - cost),
rule_id: Set(None),
order_id: Set(None),
description: Set(Some(format!("兑换: {}", product.name))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let spend = spend_txn.insert(&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 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()));
}
}
// 创建订单
let order = points_order::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
product_id: Set(product.id),
points_cost: Set(cost),
status: Set("pending".to_string()),
qr_code: Set(Some(Uuid::now_v7())),
verified_by: Set(None),
verified_at: Set(None),
expires_at: Set(Some(now + Duration::days(30))),
notes: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted_order = order.insert(&txn).await?;
// 关联消费流水的 order_id
let mut spend_active: points_transaction::ActiveModel = spend.into();
spend_active.order_id = Set(Some(inserted_order.id));
spend_active.update(&txn).await?;
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_order.created", "points_order")
.with_resource_id(inserted_order.id),
&state.db,
).await;
state.event_bus.publish(
DomainEvent::new(crate::event::POINTS_EXCHANGED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({
"order_id": inserted_order.id, "patient_id": inserted_order.patient_id,
"product_id": inserted_order.product_id, "points_cost": inserted_order.points_cost,
}))),
&state.db,
).await;
Ok(PointsOrderResp {
id: inserted_order.id,
patient_id: inserted_order.patient_id,
product_id: inserted_order.product_id,
product_name: Some(product.name),
points_cost: inserted_order.points_cost,
status: inserted_order.status,
qr_code: inserted_order.qr_code,
verified_by: inserted_order.verified_by,
verified_at: inserted_order.verified_at,
expires_at: inserted_order.expires_at,
notes: inserted_order.notes,
created_at: inserted_order.created_at,
updated_at: inserted_order.updated_at,
version: inserted_order.version,
})
}
// ---------------------------------------------------------------------------
// 订单管理
// ---------------------------------------------------------------------------
pub async fn list_orders(
state: &HealthState,
tenant_id: Uuid,
patient_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::PatientId.eq(patient_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 })
}
/// 管理端查看所有订单(不按 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,
qr_code: Uuid,
verifier_id: Uuid,
) -> HealthResult<PointsOrderResp> {
let order = points_order::Entity::find()
.filter(points_order::Column::TenantId.eq(tenant_id))
.filter(points_order::Column::QrCode.eq(qr_code))
.filter(points_order::Column::Status.eq("pending"))
.filter(points_order::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsOrderNotFound)?;
let now = Utc::now();
let expected_version = order.version;
// 数据库级 CAS防止并发核销同一订单
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")
.with_resource_id(m.id),
&state.db,
).await;
Ok(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,
})
}
// ---------------------------------------------------------------------------
// 积分规则管理
// ---------------------------------------------------------------------------
pub async fn list_rules(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<Vec<PointsRuleResp>> {
let models = points_rule::Entity::find()
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.order_by_asc(points_rule::Column::CreatedAt)
.all(&state.db)
.await?;
Ok(models.into_iter().map(|m| PointsRuleResp {
id: m.id, event_type: m.event_type, name: m.name,
description: m.description, points_value: m.points_value,
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
is_active: m.is_active, created_at: m.created_at,
updated_at: m.updated_at, version: m.version,
}).collect())
}
pub async fn create_rule(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreatePointsRuleReq,
) -> HealthResult<PointsRuleResp> {
let now = Utc::now();
let active = points_rule::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
event_type: Set(req.event_type),
name: Set(req.name),
description: Set(req.description),
points_value: Set(req.points_value),
daily_cap: Set(req.daily_cap),
streak_7d_bonus: Set(req.streak_7d_bonus),
streak_14d_bonus: Set(req.streak_14d_bonus),
streak_30d_bonus: Set(req.streak_30d_bonus),
is_active: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
Ok(PointsRuleResp {
id: m.id, event_type: m.event_type, name: m.name,
description: m.description, points_value: m.points_value,
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
is_active: m.is_active, created_at: m.created_at,
updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
req: UpdatePointsRuleReq,
expected_version: i32,
) -> HealthResult<PointsRuleResp> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(points_value) = req.points_value { active.points_value = Set(points_value); }
if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); }
if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); }
if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); }
if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); }
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.updated", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(PointsRuleResp {
id: m.id, event_type: m.event_type, name: m.name,
description: m.description, points_value: m.points_value,
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
is_active: m.is_active, created_at: m.created_at,
updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = points_rule::Entity::find()
.filter(points_rule::Column::Id.eq(rule_id))
.filter(points_rule::Column::TenantId.eq(tenant_id))
.filter(points_rule::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PointsRuleNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: points_rule::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 线下活动
// ---------------------------------------------------------------------------
pub async fn list_offline_events(
state: &HealthState,
tenant_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<OfflineEventResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = offline_event::Entity::find()
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.filter(offline_event::Column::Status.is_in(["published", "ongoing", "completed"]));
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(offline_event::Column::EventDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(event_to_resp).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn register_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
let event = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.filter(offline_event::Column::Status.is_in(["published", "ongoing"]))
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
if event.max_participants > 0 && event.current_participants >= event.max_participants {
return Err(HealthError::Validation("活动报名已满".into()));
}
let now = Utc::now();
// 在事务中执行报名 + 参与人数 CAS 更新
let txn = state.db.begin().await?;
let reg = offline_event_registration::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
event_id: Set(event_id),
patient_id: Set(patient_id),
status: Set("registered".to_string()),
checked_in_at: Set(None),
checked_in_by: Set(None),
points_granted: Set(false),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
reg.insert(&txn).await?;
// CAS 更新参与人数:防止并发超出 max_participants
let mut cas = offline_event::Entity::update_many()
.col_expr(
offline_event::Column::CurrentParticipants,
Expr::col(offline_event::Column::CurrentParticipants).add(1),
)
.col_expr(offline_event::Column::UpdatedAt, Expr::value(now))
.col_expr(
offline_event::Column::Version,
Expr::col(offline_event::Column::Version).add(1),
)
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::Version.eq(event.version));
if event.max_participants > 0 {
cas = cas.filter(offline_event::Column::CurrentParticipants.lt(event.max_participants));
}
let cas_result = cas.exec(&txn).await?;
if cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::Validation("活动报名已满或版本冲突,请重试".into()));
}
txn.commit().await?;
Ok(())
}
fn event_to_resp(m: offline_event::Model) -> OfflineEventResp {
OfflineEventResp {
id: m.id, title: m.title, description: m.description,
event_date: m.event_date, start_time: m.start_time, end_time: m.end_time,
location: m.location, points_reward: m.points_reward,
max_participants: m.max_participants, current_participants: m.current_participants,
status: m.status, image_url: m.image_url,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}
}
// ---------------------------------------------------------------------------
// 线下活动 — 管理端 CRUD
// ---------------------------------------------------------------------------
/// 管理端:创建线下活动
pub async fn create_offline_event(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateOfflineEventReq,
) -> HealthResult<OfflineEventResp> {
let now = Utc::now();
let active = offline_event::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
title: Set(req.title),
description: Set(req.description),
event_date: Set(req.event_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
location: Set(req.location),
points_reward: Set(req.points_reward.unwrap_or(0)),
max_participants: Set(req.max_participants.unwrap_or(0)),
current_participants: Set(0),
status: Set("draft".to_string()),
image_url: Set(req.image_url),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.created", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(event_to_resp(m))
}
/// 管理端:更新线下活动
pub async fn update_offline_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateOfflineEventReq,
expected_version: i32,
) -> HealthResult<OfflineEventResp> {
let model = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: offline_event::ActiveModel = model.into();
if let Some(title) = req.title { active.title = Set(title); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(event_date) = req.event_date { active.event_date = Set(event_date); }
if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); }
if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); }
if let Some(location) = req.location { active.location = Set(Some(location)); }
if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); }
if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); }
if let Some(status) = req.status { active.status = Set(status); }
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.updated", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(event_to_resp(m))
}
/// 管理端:软删除线下活动
pub async fn delete_offline_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: offline_event::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.deleted", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
/// 管理端:分页列出所有线下活动(可按状态筛选)
pub async fn admin_list_offline_events(
state: &HealthState,
tenant_id: Uuid,
status_filter: Option<String>,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<OfflineEventResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = offline_event::Entity::find()
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null());
if let Some(ref status) = status_filter {
query = query.filter(offline_event::Column::Status.eq(status.as_str()));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(offline_event::Column::EventDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(event_to_resp).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 管理端:扫码签到 + 自动发积分
pub async fn admin_checkin_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
// 1. 查找活动
let event = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
// 2. 查找报名记录
let reg = offline_event_registration::Entity::find()
.filter(offline_event_registration::Column::TenantId.eq(tenant_id))
.filter(offline_event_registration::Column::EventId.eq(event_id))
.filter(offline_event_registration::Column::PatientId.eq(patient_id))
.filter(offline_event_registration::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::Validation("该患者未报名此活动".into()))?;
if reg.status == "checked_in" {
return Err(HealthError::Validation("该患者已签到".into()));
}
// 3. 事务:签到 + 发积分
let txn = state.db.begin().await?;
let now = Utc::now();
// 更新报名记录状态
let mut reg_active: offline_event_registration::ActiveModel = reg.into();
reg_active.status = Set("checked_in".to_string());
reg_active.checked_in_at = Set(Some(now));
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);
let updated_reg = reg_active.update(&txn).await?;
// 4. 如果活动有积分奖励且尚未发放,则发放积分
if event.points_reward > 0 && !updated_reg.points_granted {
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
// 写入积分流水
let txn_record = points_transaction::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
account_id: Set(acc.id),
transaction_type: Set("earn".to_string()),
amount: Set(event.points_reward),
remaining_amount: Set(event.points_reward),
status: Set("active".to_string()),
expires_at: Set(Some(now + Duration::days(365))),
balance_after: Set(acc.balance + event.points_reward),
rule_id: Set(None),
order_id: Set(None),
description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
txn_record.insert(&txn).await?;
// CAS 更新账户余额:基于 version 字段防止并发覆盖
let cas_result = points_account::Entity::update_many()
.col_expr(
points_account::Column::Balance,
Expr::col(points_account::Column::Balance).add(event.points_reward),
)
.col_expr(
points_account::Column::TotalEarned,
Expr::col(points_account::Column::TotalEarned).add(event.points_reward),
)
.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 cas_result.rows_affected == 0 {
txn.rollback().await?;
return Err(HealthError::VersionMismatch);
}
// 标记积分已发放
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.update(&txn).await?;
}
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration")
.with_resource_id(event_id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 积分统计 — 管理端
// ---------------------------------------------------------------------------
/// 管理端:积分统计汇总
pub async fn get_points_statistics(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<PointsStatisticsResp> {
use sea_orm::FromQueryResult;
#[derive(Debug, FromQueryResult)]
struct AggRow {
total_issued: Option<i64>,
total_spent: Option<i64>,
total_expired: Option<i64>,
active_accounts: Option<i64>,
}
#[derive(Debug, FromQueryResult)]
struct TopEarnerRow {
id: Uuid,
patient_id: Uuid,
total_earned: Option<i32>,
}
// 聚合查询:总发放/总消费/总过期/活跃账户数
let agg_sql = r#"
SELECT
COALESCE(SUM(total_earned), 0) AS total_issued,
COALESCE(SUM(total_spent), 0) AS total_spent,
COALESCE(SUM(total_expired), 0) AS total_expired,
COUNT(*) AS active_accounts
FROM points_account
WHERE tenant_id = $1 AND deleted_at IS NULL
"#;
let agg = AggRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
agg_sql,
[tenant_id.into()],
),
)
.one(&state.db)
.await?
.unwrap_or(AggRow {
total_issued: Some(0),
total_spent: Some(0),
total_expired: Some(0),
active_accounts: Some(0),
});
// Top 10 积分获取者
let top_sql = r#"
SELECT id, patient_id, total_earned
FROM points_account
WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY total_earned DESC
LIMIT 10
"#;
let top_rows = TopEarnerRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
top_sql,
[tenant_id.into()],
),
)
.all(&state.db)
.await?;
let top_earners = top_rows.into_iter().map(|r| TopEarner {
account_id: r.id,
patient_id: r.patient_id,
total_earned: r.total_earned.unwrap_or(0),
}).collect();
Ok(PointsStatisticsResp {
total_issued: agg.total_issued.unwrap_or(0),
total_spent: agg.total_spent.unwrap_or(0),
total_expired: agg.total_expired.unwrap_or(0),
active_accounts: agg.active_accounts.unwrap_or(0),
top_earners,
})
}
// ---------------------------------------------------------------------------
// 积分过期清理
// ---------------------------------------------------------------------------
/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired
/// 返回处理的过期交易数量
pub async fn expire_points(db: &sea_orm::DatabaseConnection, event_bus: &erp_core::events::EventBus) -> HealthResult<u64> {
let now = Utc::now();
// 查找所有已过期但未标记 expired 的 earn 交易
let expired_txns: Vec<points_transaction::Model> = points_transaction::Entity::find()
.filter(points_transaction::Column::TransactionType.eq("earn"))
.filter(points_transaction::Column::Status.eq("active"))
.filter(points_transaction::Column::ExpiresAt.is_not_null())
.filter(points_transaction::Column::ExpiresAt.lt(now))
.filter(points_transaction::Column::DeletedAt.is_null())
.filter(points_transaction::Column::RemainingAmount.gt(0))
.all(db)
.await?;
if expired_txns.is_empty() {
return Ok(0);
}
let tenant_id = expired_txns.first().map(|t| t.tenant_id).unwrap_or_default();
let mut processed: u64 = 0;
for txn in expired_txns {
let txn_id = txn.id;
let account_id = txn.account_id;
let remaining = txn.remaining_amount;
let txn_result = db
.transaction::<_, (), HealthError>(|txn_db| {
Box::pin(async move {
// 标记交易为 expired
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.updated_at = Set(Utc::now());
active_txn.update(txn_db).await?;
// 扣减账户余额,更新 total_expired
let account = points_account::Entity::find_by_id(account_id)
.one(txn_db)
.await?
.ok_or_else(|| HealthError::Validation("积分账户不存在".to_string()))?;
let new_balance = (account.balance - remaining).max(0);
let new_expired = account.total_expired + remaining;
let mut active_account: points_account::ActiveModel = account.into();
active_account.balance = Set(new_balance);
active_account.total_expired = Set(new_expired);
active_account.version = Set(active_account.version.unwrap() + 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)?;
active_account.update(txn_db).await?;
Ok(())
})
})
.await;
match txn_result {
Ok(()) => {
processed += 1;
tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成");
}
Err(e) => {
tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过");
}
}
}
if processed > 0 {
tracing::info!(count = processed, "积分过期清理完成");
let event = erp_core::events::DomainEvent::new(
crate::event::POINTS_EXPIRED,
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({ "expired_count": processed })),
);
event_bus.publish(event, db).await;
}
Ok(processed)
}