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

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()))
}