feat(health): 线下活动管理端 CRUD + 积分统计 API + 前端页面 (Chunk 4)
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

后端:
- 线下活动管理: create/update/delete/list/checkin 5 个管理端接口
- 活动签到自动发放积分 (事务内原子操作)
- 积分统计 API: 总发放/总消耗/总过期/活跃账户/Top10排行

前端:
- OfflineEventList: 活动管理页面 (创建/编辑/删除/状态筛选)
- points.ts 扩展: 线下活动 + 统计 API 方法
- 侧边栏新增线下活动入口
This commit is contained in:
iven
2026-04-25 17:34:54 +08:00
parent eb937d3d02
commit 7b18a7398d
8 changed files with 987 additions and 0 deletions

View File

@@ -259,3 +259,38 @@ pub struct OfflineEventResp {
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 管理端:带版本号的更新/删除包装
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateOfflineEventWithVersion {
pub data: UpdateOfflineEventReq,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminCheckinReq {
pub patient_id: Uuid,
}
// ---------------------------------------------------------------------------
// 积分统计
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PointsStatisticsResp {
pub total_issued: i64,
pub total_spent: i64,
pub total_expired: i64,
pub active_accounts: i64,
pub top_earners: Vec<TopEarner>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TopEarner {
pub account_id: Uuid,
pub patient_id: Uuid,
pub total_earned: i32,
}

View File

@@ -262,6 +262,111 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 线下活动 — 管理端 CRUD + 签到
// ---------------------------------------------------------------------------
pub async fn admin_create_event<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateOfflineEventReq>,
) -> Result<Json<ApiResponse<OfflineEventResp>>, 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_offline_event(
&state, ctx.tenant_id, Some(ctx.user_id), req,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_update_event<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(event_id): Path<Uuid>,
Json(wrapper): Json<UpdateOfflineEventWithVersion>,
) -> Result<Json<ApiResponse<OfflineEventResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
let mut data = wrapper.data;
data.sanitize();
let result = points_service::update_offline_event(
&state, ctx.tenant_id, event_id, Some(ctx.user_id), data, wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_delete_event<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(event_id): Path<Uuid>,
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::delete_offline_event(
&state, ctx.tenant_id, event_id, Some(ctx.user_id), wrapper.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct AdminListEventsParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub status: Option<String>,
}
pub async fn admin_list_events<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<AdminListEventsParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = points_service::admin_list_offline_events(
&state, ctx.tenant_id, params.status, page, page_size,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn admin_checkin_event<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(event_id): Path<Uuid>,
Json(req): Json<AdminCheckinReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.manage")?;
points_service::admin_checkin_event(
&state, ctx.tenant_id, event_id, req.patient_id, Some(ctx.user_id),
).await?;
Ok(Json(ApiResponse::ok(())))
}
// ---------------------------------------------------------------------------
// 积分统计 — 管理端
// ---------------------------------------------------------------------------
pub async fn get_points_statistics<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<PointsStatisticsResp>>, AppError>
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.points.list")?;
let result = points_service::get_points_statistics(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 辅助:通过 user_id 解析 patient_id
// ---------------------------------------------------------------------------

View File

@@ -340,6 +340,26 @@ impl HealthModule {
"/health/admin/points/orders",
axum::routing::get(points_handler::admin_list_orders),
)
// 线下活动 — 管理端
.route(
"/health/admin/offline-events",
axum::routing::get(points_handler::admin_list_events)
.post(points_handler::admin_create_event),
)
.route(
"/health/admin/offline-events/{id}",
axum::routing::put(points_handler::admin_update_event)
.delete(points_handler::admin_delete_event),
)
.route(
"/health/admin/offline-events/{id}/checkin",
axum::routing::post(points_handler::admin_checkin_event),
)
// 积分统计 — 管理端
.route(
"/health/admin/points/statistics",
axum::routing::get(points_handler::get_points_statistics),
)
}
}

View File

@@ -906,3 +906,345 @@ fn event_to_resp(m: offline_event::Model) -> OfflineEventResp {
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}
}
// ---------------------------------------------------------------------------
// 线下活动 — 管理端 CRUD
// ---------------------------------------------------------------------------
/// 管理端:创建线下活动
pub async fn create_offline_event(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateOfflineEventReq,
) -> HealthResult<OfflineEventResp> {
let now = Utc::now();
let active = offline_event::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
title: Set(req.title),
description: Set(req.description),
event_date: Set(req.event_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
location: Set(req.location),
points_reward: Set(req.points_reward.unwrap_or(0)),
max_participants: Set(req.max_participants.unwrap_or(0)),
current_participants: Set(0),
status: Set("draft".to_string()),
image_url: Set(req.image_url),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.created", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(event_to_resp(m))
}
/// 管理端:更新线下活动
pub async fn update_offline_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateOfflineEventReq,
expected_version: i32,
) -> HealthResult<OfflineEventResp> {
let model = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
let next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: offline_event::ActiveModel = model.into();
if let Some(title) = req.title { active.title = Set(title); }
if let Some(description) = req.description { active.description = Set(Some(description)); }
if let Some(event_date) = req.event_date { active.event_date = Set(event_date); }
if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); }
if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); }
if let Some(location) = req.location { active.location = Set(Some(location)); }
if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); }
if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); }
if let Some(status) = req.status { active.status = Set(status); }
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.updated", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(event_to_resp(m))
}
/// 管理端:软删除线下活动
pub async fn delete_offline_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
let _next_ver = check_version(expected_version, model.version)?;
let now = Utc::now();
let mut active: offline_event::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.deleted", "offline_event")
.with_resource_id(m.id),
&state.db,
).await;
Ok(())
}
/// 管理端:分页列出所有线下活动(可按状态筛选)
pub async fn admin_list_offline_events(
state: &HealthState,
tenant_id: Uuid,
status_filter: Option<String>,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<OfflineEventResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = offline_event::Entity::find()
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null());
if let Some(ref status) = status_filter {
query = query.filter(offline_event::Column::Status.eq(status.as_str()));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(offline_event::Column::EventDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(event_to_resp).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 管理端:扫码签到 + 自动发积分
pub async fn admin_checkin_event(
state: &HealthState,
tenant_id: Uuid,
event_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
// 1. 查找活动
let event = offline_event::Entity::find()
.filter(offline_event::Column::Id.eq(event_id))
.filter(offline_event::Column::TenantId.eq(tenant_id))
.filter(offline_event::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::OfflineEventNotFound)?;
// 2. 查找报名记录
let reg = offline_event_registration::Entity::find()
.filter(offline_event_registration::Column::TenantId.eq(tenant_id))
.filter(offline_event_registration::Column::EventId.eq(event_id))
.filter(offline_event_registration::Column::PatientId.eq(patient_id))
.filter(offline_event_registration::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::Validation("该患者未报名此活动".into()))?;
if reg.status == "checked_in" {
return Err(HealthError::Validation("该患者已签到".into()));
}
// 3. 事务:签到 + 发积分
let txn = state.db.begin().await?;
let now = Utc::now();
// 更新报名记录状态
let mut reg_active: offline_event_registration::ActiveModel = reg.into();
reg_active.status = Set("checked_in".to_string());
reg_active.checked_in_at = Set(Some(now));
reg_active.checked_in_by = Set(operator_id);
reg_active.updated_at = Set(now);
reg_active.updated_by = Set(operator_id);
reg_active.version = Set(reg_active.version.unwrap() + 1);
let updated_reg = reg_active.update(&txn).await?;
// 4. 如果活动有积分奖励且尚未发放,则发放积分
if event.points_reward > 0 && !updated_reg.points_granted {
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
let next_ver = check_version(acc.version, acc.version).unwrap_or(acc.version + 1);
// 写入积分流水
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(event.points_reward),
remaining_amount: Set(event.points_reward),
status: Set("active".to_string()),
expires_at: Set(Some(now + Duration::days(365))),
balance_after: Set(acc.balance + event.points_reward),
rule_id: Set(None),
order_id: Set(None),
description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
txn_record.insert(&txn).await?;
// 更新账户余额
let mut acc_active: points_account::ActiveModel = acc.into();
acc_active.balance = Set(acc_active.balance.unwrap() + event.points_reward);
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + event.points_reward);
acc_active.updated_at = Set(now);
acc_active.updated_by = Set(operator_id);
acc_active.version = Set(next_ver);
acc_active.update(&txn).await?;
// 标记积分已发放
let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into();
reg_active2.points_granted = Set(true);
reg_active2.updated_at = Set(now);
reg_active2.version = Set(reg_active2.version.unwrap() + 1);
reg_active2.update(&txn).await?;
}
txn.commit().await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration")
.with_resource_id(event_id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 积分统计 — 管理端
// ---------------------------------------------------------------------------
/// 管理端:积分统计汇总
pub async fn get_points_statistics(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<PointsStatisticsResp> {
use sea_orm::FromQueryResult;
#[derive(Debug, FromQueryResult)]
struct AggRow {
total_issued: Option<i64>,
total_spent: Option<i64>,
total_expired: Option<i64>,
active_accounts: Option<i64>,
}
#[derive(Debug, FromQueryResult)]
struct TopEarnerRow {
id: Uuid,
patient_id: Uuid,
total_earned: Option<i32>,
}
// 聚合查询:总发放/总消费/总过期/活跃账户数
let agg_sql = r#"
SELECT
COALESCE(SUM(total_earned), 0) AS total_issued,
COALESCE(SUM(total_spent), 0) AS total_spent,
COALESCE(SUM(total_expired), 0) AS total_expired,
COUNT(*) AS active_accounts
FROM points_account
WHERE tenant_id = $1 AND deleted_at IS NULL
"#;
let agg = AggRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
agg_sql,
[tenant_id.into()],
),
)
.one(&state.db)
.await?
.unwrap_or(AggRow {
total_issued: Some(0),
total_spent: Some(0),
total_expired: Some(0),
active_accounts: Some(0),
});
// Top 10 积分获取者
let top_sql = r#"
SELECT id, patient_id, total_earned
FROM points_account
WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY total_earned DESC
LIMIT 10
"#;
let top_rows = TopEarnerRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
top_sql,
[tenant_id.into()],
),
)
.all(&state.db)
.await?;
let top_earners = top_rows.into_iter().map(|r| TopEarner {
account_id: r.id,
patient_id: r.patient_id,
total_earned: r.total_earned.unwrap_or(0),
}).collect();
Ok(PointsStatisticsResp {
total_issued: agg.total_issued.unwrap_or(0),
total_spent: agg.total_spent.unwrap_or(0),
total_expired: agg.total_expired.unwrap_or(0),
active_accounts: agg.active_accounts.unwrap_or(0),
top_earners,
})
}