Files
hms/crates/erp-server/migration/src/m20260425_000053_create_points_tables.rs
iven 4ab67ba559
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
feat(health): 积分商城后端完整实现 (Chunk 2 V2 迭代)
- 新增 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个月滚动过期机制
- 审计日志全量覆盖
2026-04-25 16:51:38 +08:00

260 lines
16 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.
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(())
}
}