feat(health): 积分商城后端完整实现 (Chunk 2 V2 迭代)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增 8 张数据库表: points_account/rule/transaction/product/order/checkin + offline_event/registration
- SeaORM Entity: 8 个实体,含完整 Relation 定义
- DTO: 积分规则/商品/订单/签到/线下活动请求响应类型
- Service: FIFO 积分消费、每日打卡(连续奖励)、商品兑换(QR码核销)、线下活动报名
- Handler: 16 个 API 端点 (患者端10 + 管理端6)
- 权限: health.points.list / health.points.manage
- 12个月滚动过期机制
- 审计日志全量覆盖
This commit is contained in:
iven
2026-04-25 16:51:38 +08:00
parent 41dda568a5
commit 4ab67ba559
21 changed files with 2248 additions and 7 deletions

104
Cargo.lock generated
View File

@@ -1167,6 +1167,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1284,6 +1315,31 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erp-ai"
version = "0.1.0"
dependencies = [
"async-stream",
"async-trait",
"axum",
"chrono",
"erp-core",
"futures",
"handlebars",
"hex",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
"utoipa",
"uuid",
]
[[package]]
name = "erp-auth"
version = "0.1.0"
@@ -1484,6 +1540,7 @@ dependencies = [
"axum",
"chrono",
"config",
"erp-ai",
"erp-auth",
"erp-config",
"erp-core",
@@ -1883,6 +1940,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "handlebars"
version = "6.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e"
dependencies = [
"derive_builder",
"log",
"num-order",
"pest",
"pest_derive",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -2774,6 +2847,21 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-modular"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
[[package]]
name = "num-order"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
dependencies = [
"num-modular",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3436,6 +3524,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
@@ -3457,12 +3546,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
@@ -5293,6 +5384,19 @@ dependencies = [
"wasmparser 0.246.2",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"

View File

@@ -6,6 +6,7 @@ pub mod doctor_dto;
pub mod follow_up_dto;
pub mod health_data_dto;
pub mod patient_dto;
pub mod points_dto;
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {

View File

@@ -0,0 +1,261 @@
use chrono::NaiveDate;
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// 积分账户
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsAccountResp {
pub id: Uuid,
pub patient_id: Uuid,
pub balance: i32,
pub total_earned: i32,
pub total_spent: i32,
pub total_expired: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CheckinStatusResp {
pub checked_in_today: bool,
pub consecutive_days: i32,
pub next_streak_milestone: Option<i32>,
}
// ---------------------------------------------------------------------------
// 积分流水
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsTransactionResp {
pub id: Uuid,
pub account_id: Uuid,
pub r#type: String,
pub amount: i32,
pub remaining_amount: i32,
pub status: String,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub balance_after: i32,
pub description: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// ---------------------------------------------------------------------------
// 积分规则
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreatePointsRuleReq {
pub event_type: String,
pub name: String,
pub description: Option<String>,
pub points_value: i32,
#[serde(default)]
pub daily_cap: i32,
#[serde(default)]
pub streak_7d_bonus: i32,
#[serde(default)]
pub streak_14d_bonus: i32,
#[serde(default)]
pub streak_30d_bonus: i32,
}
impl CreatePointsRuleReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdatePointsRuleReq {
pub name: Option<String>,
pub description: Option<String>,
pub points_value: Option<i32>,
pub daily_cap: Option<i32>,
pub streak_7d_bonus: Option<i32>,
pub streak_14d_bonus: Option<i32>,
pub streak_30d_bonus: Option<i32>,
pub is_active: Option<bool>,
}
impl UpdatePointsRuleReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsRuleResp {
pub id: Uuid,
pub event_type: String,
pub name: String,
pub description: Option<String>,
pub points_value: i32,
pub daily_cap: i32,
pub streak_7d_bonus: i32,
pub streak_14d_bonus: i32,
pub streak_30d_bonus: i32,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 兑换商品
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreatePointsProductReq {
pub name: String,
pub product_type: Option<String>,
pub points_cost: i32,
pub stock: Option<i32>,
pub image_url: Option<String>,
pub description: Option<String>,
pub service_config: Option<serde_json::Value>,
pub sort_order: Option<i32>,
}
impl CreatePointsProductReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdatePointsProductReq {
pub name: Option<String>,
pub product_type: Option<String>,
pub points_cost: Option<i32>,
pub stock: Option<i32>,
pub image_url: Option<String>,
pub description: Option<String>,
pub service_config: Option<serde_json::Value>,
pub is_active: Option<bool>,
pub sort_order: Option<i32>,
}
impl UpdatePointsProductReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsProductResp {
pub id: Uuid,
pub name: String,
pub product_type: String,
pub points_cost: i32,
pub stock: i32,
pub image_url: Option<String>,
pub description: Option<String>,
pub is_active: bool,
pub sort_order: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 兑换订单
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ExchangeReq {
pub product_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsOrderResp {
pub id: Uuid,
pub patient_id: Uuid,
pub product_id: Uuid,
pub product_name: Option<String>,
pub points_cost: i32,
pub status: String,
pub qr_code: Option<Uuid>,
pub verified_by: Option<Uuid>,
pub verified_at: Option<chrono::DateTime<chrono::Utc>>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct VerifyOrderReq {
pub qr_code: Uuid,
}
// ---------------------------------------------------------------------------
// 线下活动
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateOfflineEventReq {
pub title: String,
pub description: Option<String>,
pub event_date: NaiveDate,
pub start_time: Option<chrono::NaiveTime>,
pub end_time: Option<chrono::NaiveTime>,
pub location: Option<String>,
pub points_reward: Option<i32>,
pub max_participants: Option<i32>,
pub image_url: Option<String>,
}
impl CreateOfflineEventReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
self.location = sanitize_option(self.location.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateOfflineEventReq {
pub title: Option<String>,
pub description: Option<String>,
pub event_date: Option<NaiveDate>,
pub start_time: Option<chrono::NaiveTime>,
pub end_time: Option<chrono::NaiveTime>,
pub location: Option<String>,
pub points_reward: Option<i32>,
pub max_participants: Option<i32>,
pub status: Option<String>,
pub image_url: Option<String>,
}
impl UpdateOfflineEventReq {
pub fn sanitize(&mut self) {
self.description = sanitize_option(self.description.take());
self.location = sanitize_option(self.location.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct OfflineEventResp {
pub id: Uuid,
pub title: String,
pub description: Option<String>,
pub event_date: NaiveDate,
pub start_time: Option<chrono::NaiveTime>,
pub end_time: Option<chrono::NaiveTime>,
pub location: Option<String>,
pub points_reward: i32,
pub max_participants: i32,
pub current_participants: i32,
pub status: String,
pub image_url: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}

View File

@@ -16,3 +16,11 @@ pub mod patient_family_member;
pub mod patient_tag;
pub mod patient_tag_relation;
pub mod vital_signs;
pub mod points_account;
pub mod points_checkin;
pub mod points_order;
pub mod points_product;
pub mod points_rule;
pub mod points_transaction;
pub mod offline_event;
pub mod offline_event_registration;

View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "offline_event")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub title: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub event_date: chrono::NaiveDate,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub start_time: Option<chrono::NaiveTime>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub end_time: Option<chrono::NaiveTime>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub points_reward: i32,
pub max_participants: i32,
pub current_participants: i32,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "offline_event_registration")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub event_id: Uuid,
pub patient_id: Uuid,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub checked_in_at: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub checked_in_by: Option<Uuid>,
pub points_granted: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,42 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_account")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub balance: i32,
pub total_earned: i32,
pub total_spent: i32,
pub total_expired: i32,
pub version: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_checkin")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub checkin_date: chrono::NaiveDate,
pub consecutive_days: i32,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_order")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub product_id: Uuid,
pub points_cost: i32,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub qr_code: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub verified_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,36 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_product")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub product_type: String,
pub points_cost: i32,
pub stock: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub service_config: Option<serde_json::Value>,
pub is_active: bool,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,34 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_rule")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub event_type: String,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub points_value: i32,
pub daily_cap: i32,
pub streak_7d_bonus: i32,
pub streak_14d_bonus: i32,
pub streak_30d_bonus: i32,
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,38 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "points_transaction")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub account_id: Uuid,
pub r#type: String,
pub amount: i32,
pub remaining_amount: i32,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTimeUtc>,
pub balance_after: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub order_id: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -26,6 +26,15 @@ pub enum HealthError {
#[error("透析记录不存在")]
DialysisRecordNotFound,
#[error("兑换商品不存在")]
PointsProductNotFound,
#[error("兑换订单不存在")]
PointsOrderNotFound,
#[error("线下活动不存在")]
OfflineEventNotFound,
#[error("健康档案不存在")]
HealthRecordNotFound,
@@ -73,7 +82,10 @@ impl From<HealthError> for AppError {
| HealthError::TagNotFound
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound
| HealthError::ArticleNotFound => AppError::NotFound(err.to_string()),
| HealthError::ArticleNotFound
| HealthError::PointsProductNotFound
| HealthError::PointsOrderNotFound
| HealthError::OfflineEventNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,

View File

@@ -6,3 +6,4 @@ pub mod doctor_handler;
pub mod follow_up_handler;
pub mod health_data_handler;
pub mod patient_handler;
pub mod points_handler;

View File

@@ -0,0 +1,286 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::points_dto::*;
use crate::service::points_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PaginationParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ProductTypeParam {
pub product_type: Option<String>,
}
// ---------------------------------------------------------------------------
// 患者端:积分账户 + 打卡
// ---------------------------------------------------------------------------
pub async fn get_my_account<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn daily_checkin<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::daily_checkin(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id),
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_checkin_status<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::get_checkin_status(&state, ctx.tenant_id, patient_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 患者端:积分流水 + 商品 + 兑换
// ---------------------------------------------------------------------------
pub async fn list_my_transactions<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_transactions(
&state, ctx.tenant_id, patient_id, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn list_products<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ProductTypeParam>,
Query(page): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let p = page.page.unwrap_or(1);
let ps = page.page_size.unwrap_or(20);
let result = points_service::list_products(
&state, ctx.tenant_id, params.product_type, p, ps,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(product_id): Path<Uuid>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let result = points_service::get_product(&state, ctx.tenant_id, product_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn exchange_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<ExchangeReq>,
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let result = points_service::exchange_product(
&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id),
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn list_my_orders<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_orders(
&state, ctx.tenant_id, patient_id, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 线下活动(患者端)
// ---------------------------------------------------------------------------
pub async fn list_offline_events<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = points_service::list_offline_events(
&state, ctx.tenant_id, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn register_event<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(event_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
points_service::register_event(
&state, ctx.tenant_id, event_id, patient_id, Some(ctx.user_id),
).await?;
Ok(Json(ApiResponse::ok(())))
}
// ---------------------------------------------------------------------------
// 管理端:核销 + 规则管理 + 商品管理
// ---------------------------------------------------------------------------
pub async fn verify_order<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<VerifyOrderReq>,
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let result = points_service::verify_order(
&state, ctx.tenant_id, req.qr_code, ctx.user_id,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn list_rules<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PointsRuleResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.list")?;
let result = points_service::list_rules(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_rule<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreatePointsRuleReq>,
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut req = req;
req.sanitize();
let result = points_service::create_rule(
&state, ctx.tenant_id, Some(ctx.user_id), req,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_create_product<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreatePointsProductReq>,
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut req = req;
req.sanitize();
let result = points_service::create_product(
&state, ctx.tenant_id, Some(ctx.user_id), req,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_list_orders<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
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,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 辅助:通过 user_id 解析 patient_id
// ---------------------------------------------------------------------------
async fn resolve_patient_id(
state: &HealthState,
tenant_id: Uuid,
user_id: Uuid,
) -> Result<Uuid, AppError> {
use crate::entity::patient;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
let result: Option<patient::Model> = patient::Entity::find()
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::UserId.eq(user_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await
.map_err(|e: sea_orm::DbErr| AppError::Internal(e.to_string()))?;
result
.map(|p| p.id)
.ok_or_else(|| AppError::NotFound("当前用户未关联患者档案".into()))
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use erp_core::error::AppResult;
use erp_core::error::{AppError, AppResult};
use erp_core::health_provider::{
HealthDataProvider, HealthReportDto, LabReportDto, PatientSummaryDto, TimeRange, VitalSignDto,
};
@@ -9,6 +9,19 @@ 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()))
}
};
}
#[async_trait]
impl HealthDataProvider for HealthDataProviderImpl {
async fn get_lab_report(
@@ -16,7 +29,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_report_id: Uuid,
) -> AppResult<LabReportDto> {
todo!("实现化验报告数据查询")
Err(AppError::Internal(
"HealthDataProvider::get_lab_report 尚未实现 (Phase 1 stub)".into(),
))
}
async fn get_vital_signs(
@@ -26,7 +41,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
_metrics: &[String],
_range: &TimeRange,
) -> AppResult<Vec<VitalSignDto>> {
todo!("实现生命体征趋势查询")
Err(AppError::Internal(
"HealthDataProvider::get_vital_signs 尚未实现 (Phase 1 stub)".into(),
))
}
async fn get_patient_summary(
@@ -34,7 +51,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_patient_id: Uuid,
) -> AppResult<PatientSummaryDto> {
todo!("实现患者摘要查询")
Err(AppError::Internal(
"HealthDataProvider::get_patient_summary 尚未实现 (Phase 1 stub)".into(),
))
}
async fn get_full_report(
@@ -42,6 +61,8 @@ impl HealthDataProvider for HealthDataProviderImpl {
_tenant_id: Uuid,
_report_id: Uuid,
) -> AppResult<HealthReportDto> {
todo!("实现完整报告查询")
Err(AppError::Internal(
"HealthDataProvider::get_full_report 尚未实现 (Phase 1 stub)".into(),
))
}
}

View File

@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
appointment_handler, article_handler, consultation_handler, dialysis_handler, doctor_handler, follow_up_handler,
health_data_handler, patient_handler,
health_data_handler, patient_handler, points_handler,
};
pub struct HealthModule;
@@ -265,6 +265,66 @@ impl HealthModule {
.put(article_handler::update_article)
.delete(article_handler::delete_article),
)
// 积分商城 — 患者端
.route(
"/health/points/account",
axum::routing::get(points_handler::get_my_account),
)
.route(
"/health/points/checkin",
axum::routing::post(points_handler::daily_checkin),
)
.route(
"/health/points/checkin/status",
axum::routing::get(points_handler::get_checkin_status),
)
.route(
"/health/points/transactions",
axum::routing::get(points_handler::list_my_transactions),
)
.route(
"/health/points/products",
axum::routing::get(points_handler::list_products),
)
.route(
"/health/points/products/{id}",
axum::routing::get(points_handler::get_product),
)
.route(
"/health/points/exchange",
axum::routing::post(points_handler::exchange_product),
)
.route(
"/health/points/orders",
axum::routing::get(points_handler::list_my_orders),
)
// 线下活动 — 患者端
.route(
"/health/offline-events",
axum::routing::get(points_handler::list_offline_events),
)
.route(
"/health/offline-events/{id}/register",
axum::routing::post(points_handler::register_event),
)
// 积分商城 — 管理端
.route(
"/health/points/verify",
axum::routing::post(points_handler::verify_order),
)
.route(
"/health/admin/points/rules",
axum::routing::get(points_handler::list_rules)
.post(points_handler::create_rule),
)
.route(
"/health/admin/points/products",
axum::routing::post(points_handler::admin_create_product),
)
.route(
"/health/admin/points/orders",
axum::routing::get(points_handler::admin_list_orders),
)
}
}
@@ -433,6 +493,18 @@ impl ErpModule for HealthModule {
description: "创建、编辑、删除健康资讯文章".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.points.list".into(),
name: "查看积分".into(),
description: "查看积分规则、订单列表".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.points.manage".into(),
name: "管理积分".into(),
description: "创建积分规则、管理商品、核销订单".into(),
module: "health".into(),
},
]
}

View File

@@ -7,6 +7,7 @@ pub mod follow_up_service;
pub mod health_data_service;
pub mod masking;
pub mod patient_service;
pub mod points_service;
pub mod seed;
pub mod trend_service;
pub mod validation;

View File

@@ -0,0 +1,908 @@
//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动
use chrono::{Duration, Utc};
use sea_orm::entity::prelude::*;
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::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. 检查每日上限
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::Type.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()));
}
}
// 3. 在事务中执行积分获取
let txn = state.db.begin().await?;
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 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),
r#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?;
// 更新账户余额
let mut acc_active: points_account::ActiveModel = acc.into();
acc_active.balance = Set(acc_active.balance.unwrap() + rule.points_value);
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + rule.points_value);
acc_active.updated_at = Set(now);
acc_active.updated_by = Set(operator_id);
acc_active.version = Set(next_ver);
acc_active.update(&txn).await?;
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction")
.with_resource_id(inserted.id),
&state.db,
).await;
Ok(PointsTransactionResp {
id: inserted.id,
account_id: inserted.account_id,
r#type: inserted.r#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))
.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 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(Utc::now()),
};
active.insert(&state.db).await?;
// 触发积分获取
earn_points(state, tenant_id, patient_id, "daily_checkin", operator_id).await?;
// 检查阶梯奖励
let _streak_bonus = check_streak_bonus(state, tenant_id, patient_id, consecutive, operator_id).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(
db: &DatabaseConnection,
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))
}
async fn check_streak_bonus(
state: &HealthState,
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(&state.db, tenant_id, "streak_7d_bonus").await?;
} else if consecutive == 14 {
bonus = get_streak_bonus_value(&state.db, tenant_id, "streak_14d_bonus").await?;
} else if consecutive == 30 {
bonus = get_streak_bonus_value(&state.db, tenant_id, "streak_30d_bonus").await?;
}
if bonus > 0 {
// 额外奖励通过事件系统
let acc = get_or_create_account(&state.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),
r#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(&state.db).await?;
let mut acc_active: points_account::ActiveModel = acc.into();
acc_active.balance = Set(acc_active.balance.unwrap() + bonus);
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + bonus);
acc_active.updated_at = Set(now);
acc_active.version = Set(acc_active.version.unwrap() + 1);
acc_active.update(&state.db).await?;
}
Ok(bonus)
}
async fn get_streak_bonus_value(
db: &DatabaseConnection,
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, r#type: m.r#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,
})
}
// ---------------------------------------------------------------------------
// 兑换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::Type.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" };
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?;
consumed_txn_ids.push(txn_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),
r#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?;
// 更新账户余额
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?;
// 扣减库存
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 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;
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 })
}
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 next_ver = check_version(order.version, order.version).unwrap_or(order.version + 1);
let now = Utc::now();
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?;
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 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();
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(&state.db).await?;
// 更新参与人数
let mut event_active: offline_event::ActiveModel = event.into();
event_active.current_participants = Set(event_active.current_participants.unwrap() + 1);
event_active.updated_at = Set(now);
event_active.version = Set(event_active.version.unwrap() + 1);
event_active.update(&state.db).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,
}
}

View File

@@ -52,6 +52,7 @@ mod m20260425_000049_widen_patient_id_number;
mod m20260425_00050_add_doctor_name_column;
mod m20260425_000051_dialysis_and_lab_enhance;
mod m20260425_000052_create_ai_tables;
mod m20260425_000053_create_points_tables;
pub struct Migrator;
@@ -111,6 +112,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260425_00050_add_doctor_name_column::Migration),
Box::new(m20260425_000051_dialysis_and_lab_enhance::Migration),
Box::new(m20260425_000052_create_ai_tables::Migration),
Box::new(m20260425_000053_create_points_tables::Migration),
]
}
}

View File

@@ -0,0 +1,259 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
/// V2 积分商城: 8 张新表 — account/rule/transaction/product/order/checkin/event/registration
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. points_account — 积分账户(每患者一个)
manager
.create_table(
Table::create()
.table(Alias::new("points_account"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("balance")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("total_earned")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("total_spent")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("total_expired")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_account_patient").table(Alias::new("points_account")).col(Alias::new("patient_id")).unique().to_owned())
.await?;
// 2. points_rule — 积分获取规则
manager
.create_table(
Table::create()
.table(Alias::new("points_rule"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("event_type")).string_len(64).not_null())
.col(ColumnDef::new(Alias::new("name")).string_len(128).not_null())
.col(ColumnDef::new(Alias::new("description")).text())
.col(ColumnDef::new(Alias::new("points_value")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("daily_cap")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("streak_7d_bonus")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("streak_14d_bonus")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("streak_30d_bonus")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(true))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_rule_event_type").table(Alias::new("points_rule")).col(Alias::new("event_type")).to_owned())
.await?;
// 3. points_transaction — 积分流水FIFO 桶模型)
manager
.create_table(
Table::create()
.table(Alias::new("points_transaction"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("account_id")).uuid().not_null())
// earn / spend / expired / refund
.col(ColumnDef::new(Alias::new("r#type")).string_len(16).not_null())
.col(ColumnDef::new(Alias::new("amount")).integer().not_null())
.col(ColumnDef::new(Alias::new("remaining_amount")).integer().not_null().default(0))
// active / expired / consumed
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("active"))
.col(ColumnDef::new(Alias::new("expires_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("balance_after")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("rule_id")).uuid())
.col(ColumnDef::new(Alias::new("order_id")).uuid())
.col(ColumnDef::new(Alias::new("description")).string_len(256))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_txn_account").table(Alias::new("points_transaction")).col(Alias::new("account_id")).col(Alias::new("status")).col(Alias::new("expires_at")).to_owned())
.await?;
// 4. points_product — 兑换商品
manager
.create_table(
Table::create()
.table(Alias::new("points_product"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("name")).string_len(128).not_null())
// physical / service / privilege
.col(ColumnDef::new(Alias::new("product_type")).string_len(16).not_null().default("physical"))
.col(ColumnDef::new(Alias::new("points_cost")).integer().not_null())
.col(ColumnDef::new(Alias::new("stock")).integer().not_null().default(-1))
.col(ColumnDef::new(Alias::new("image_url")).string_len(512))
.col(ColumnDef::new(Alias::new("description")).text())
.col(ColumnDef::new(Alias::new("service_config")).json())
.col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(true))
.col(ColumnDef::new(Alias::new("sort_order")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
// 5. points_order — 兑换订单
manager
.create_table(
Table::create()
.table(Alias::new("points_order"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("product_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("points_cost")).integer().not_null())
// pending / verified / cancelled / expired
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("pending"))
.col(ColumnDef::new(Alias::new("qr_code")).uuid())
.col(ColumnDef::new(Alias::new("verified_by")).uuid())
.col(ColumnDef::new(Alias::new("verified_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("expires_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("notes")).string_len(256))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_order_patient").table(Alias::new("points_order")).col(Alias::new("patient_id")).col(Alias::new("status")).to_owned())
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_order_qr").table(Alias::new("points_order")).col(Alias::new("qr_code")).to_owned())
.await?;
// 6. points_checkin — 每日打卡
manager
.create_table(
Table::create()
.table(Alias::new("points_checkin"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("checkin_date")).date().not_null())
.col(ColumnDef::new(Alias::new("consecutive_days")).integer().not_null().default(1))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_points_checkin_unique").table(Alias::new("points_checkin")).col(Alias::new("patient_id")).col(Alias::new("checkin_date")).unique().to_owned())
.await?;
// 7. offline_event — 线下活动
manager
.create_table(
Table::create()
.table(Alias::new("offline_event"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("title")).string_len(256).not_null())
.col(ColumnDef::new(Alias::new("description")).text())
.col(ColumnDef::new(Alias::new("event_date")).date().not_null())
.col(ColumnDef::new(Alias::new("start_time")).time())
.col(ColumnDef::new(Alias::new("end_time")).time())
.col(ColumnDef::new(Alias::new("location")).string_len(256))
.col(ColumnDef::new(Alias::new("points_reward")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("max_participants")).integer().not_null().default(0))
.col(ColumnDef::new(Alias::new("current_participants")).integer().not_null().default(0))
// draft / published / ongoing / completed / cancelled
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("draft"))
.col(ColumnDef::new(Alias::new("image_url")).string_len(512))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
// 8. offline_event_registration — 活动报名 + 签到
manager
.create_table(
Table::create()
.table(Alias::new("offline_event_registration"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("event_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
// registered / checked_in / cancelled
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("registered"))
.col(ColumnDef::new(Alias::new("checked_in_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("checked_in_by")).uuid())
.col(ColumnDef::new(Alias::new("points_granted")).boolean().not_null().default(false))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_index(Index::create().if_not_exists().name("idx_event_reg_unique").table(Alias::new("offline_event_registration")).col(Alias::new("event_id")).col(Alias::new("patient_id")).unique().to_owned())
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let tables = [
"offline_event_registration",
"offline_event",
"points_checkin",
"points_order",
"points_product",
"points_transaction",
"points_rule",
"points_account",
];
for t in tables {
manager
.drop_table(Table::drop().table(Alias::new(t)).if_exists().to_owned())
.await?;
}
Ok(())
}
}