From a9bd850ce2022c1649871a14343de34f1acb8537 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 10 May 2026 15:15:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E5=AE=9E=E7=8E=B0=E8=BD=AE?= =?UTF-8?q?=E6=92=AD=E5=9B=BE=20service=20=E2=80=94=20CRUD=20+=20=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=20+=20=E7=AD=BE=E5=90=8D=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_banners: 列出轮播图,可选状态筛选,批量加载 media_item 避免 N+1 - create_banner: 创建轮播图,验证 media_item 存在且未删除 - update_banner: 更新轮播图,带乐观锁 - delete_banner: 软删除轮播图 - sort_banners: 批量更新排序 - list_public_banners: 公开端点,查询生效轮播图 + HMAC-SHA256 签名 URL - generate_signed_url: 同步函数,生成签名 URL token Co-Authored-By: Claude Opus 4.7 --- .../erp-health/src/service/banner_service.rs | 406 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + 2 files changed, 407 insertions(+) create mode 100644 crates/erp-health/src/service/banner_service.rs diff --git a/crates/erp-health/src/service/banner_service.rs b/crates/erp-health/src/service/banner_service.rs new file mode 100644 index 0000000..2d63e39 --- /dev/null +++ b/crates/erp-health/src/service/banner_service.rs @@ -0,0 +1,406 @@ +//! 轮播图 Service — CRUD + 排序 + 签名 URL 公开端点 + +use chrono::Utc; +use hmac::{Hmac, Mac}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, Condition, QueryOrder}; +use sha2::Sha256; +use std::collections::HashMap; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; + +use crate::dto::banner_dto::*; +use crate::entity::{banner, media_item}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +type HmacSha256 = Hmac; + +/// 签名 URL 密钥占位符,生产环境通过配置注入 +const SIGN_URL_SECRET: &str = "CHANGE_ME_IN_PRODUCTION"; + +// --------------------------------------------------------------------------- +// 查询 +// --------------------------------------------------------------------------- + +/// 列出租户所有轮播图(未软删除),可选按状态筛选 +pub async fn list_banners( + state: &HealthState, + tenant_id: Uuid, + status: Option, +) -> HealthResult> { + let mut query = banner::Entity::find() + .filter(banner::Column::TenantId.eq(tenant_id)) + .filter(banner::Column::DeletedAt.is_null()); + + if let Some(ref s) = status { + query = query.filter(banner::Column::Status.eq(s.as_str())); + } + + let banners = query + .order_by_asc(banner::Column::SortOrder) + .order_by_desc(banner::Column::CreatedAt) + .all(&state.db) + .await?; + + // 批量加载关联的 media_item,避免 N+1 + let media_ids: Vec = banners.iter().map(|b| b.media_item_id).collect(); + let media_map = load_media_map(state, &media_ids).await?; + + let result: Vec = banners + .into_iter() + .map(|b| banner_to_resp(&b, &media_map)) + .collect(); + + Ok(result) +} + +/// 创建轮播图 +pub async fn create_banner( + state: &HealthState, + tenant_id: Uuid, + operator_id: Uuid, + req: CreateBannerReq, +) -> HealthResult { + // 验证 media_item 存在且未删除 + let media = media_item::Entity::find_by_id(req.media_item_id) + .one(&state.db) + .await? + .ok_or(HealthError::MediaNotFound)?; + + if media.deleted_at.is_some() { + return Err(HealthError::Validation( + "关联的媒体文件已被删除".to_string(), + )); + } + + let id = Uuid::now_v7(); + let now = Utc::now(); + + let model = banner::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + media_item_id: Set(req.media_item_id), + title: Set(req.title), + subtitle: Set(req.subtitle), + link_type: Set(req.link_type), + link_target: Set(req.link_target), + sort_order: Set(req.sort_order), + status: Set(req.status), + start_time: Set(req.start_time), + end_time: Set(req.end_time), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Some(operator_id)), + updated_by: Set(Some(operator_id)), + deleted_at: Set(None), + version: Set(1), + }; + let m = model.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "banner.created", "banner") + .with_resource_id(m.id), + &state.db, + ) + .await; + + // 构造响应 + let mut media_map = HashMap::new(); + media_map.insert(media.id, media); + + Ok(BannerResp { + id: m.id, + tenant_id: m.tenant_id, + media_item_id: m.media_item_id, + title: m.title, + subtitle: m.subtitle, + link_type: m.link_type, + link_target: m.link_target, + sort_order: m.sort_order, + status: m.status, + start_time: m.start_time, + end_time: m.end_time, + image_url: media_map + .get(&m.media_item_id) + .and_then(|mi| mi.storage_path.clone().into()), + thumbnail_url: media_map + .get(&m.media_item_id) + .and_then(|mi| mi.thumbnail_path.clone()), + media_deleted: media_map + .get(&m.media_item_id) + .is_some_and(|mi| mi.deleted_at.is_some()), + created_at: m.created_at, + updated_at: m.updated_at, + created_by: m.created_by, + updated_by: m.updated_by, + version: m.version, + }) +} + +/// 更新轮播图,带乐观锁 +pub async fn update_banner( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Uuid, + req: UpdateBannerReq, +) -> HealthResult { + let model = find_banner(state, tenant_id, id).await?; + let next_ver = + check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + // 如果更新了 media_item_id,验证新的 media_item 存在 + if let Some(new_media_id) = req.media_item_id { + let media = media_item::Entity::find_by_id(new_media_id) + .one(&state.db) + .await? + .ok_or(HealthError::MediaNotFound)?; + + if media.deleted_at.is_some() { + return Err(HealthError::Validation( + "关联的媒体文件已被删除".to_string(), + )); + } + } + + let mut active: banner::ActiveModel = model.into(); + + if let Some(v) = req.media_item_id { + active.media_item_id = Set(v); + } + if let Some(v) = req.title { + active.title = Set(Some(v)); + } + if let Some(v) = req.subtitle { + active.subtitle = Set(Some(v)); + } + if let Some(v) = req.link_type { + active.link_type = Set(Some(v)); + } + if let Some(v) = req.link_target { + active.link_target = Set(Some(v)); + } + if let Some(v) = req.sort_order { + active.sort_order = Set(v); + } + if let Some(v) = req.status { + active.status = Set(v); + } + active.start_time = Set(req.start_time); + active.end_time = Set(req.end_time); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(Some(operator_id)); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "banner.updated", "banner") + .with_resource_id(m.id), + &state.db, + ) + .await; + + // 加载 media_item 信息 + let media_map = load_media_map(state, &[m.media_item_id]).await?; + + Ok(banner_to_resp(&m, &media_map)) +} + +/// 软删除轮播图 +pub async fn delete_banner( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Uuid, + version: i32, +) -> HealthResult<()> { + let model = find_banner(state, tenant_id, id).await?; + let next_ver = + check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + let mut active: banner::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(Some(operator_id)); + active.version = Set(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "banner.deleted", "banner") + .with_resource_id(id), + &state.db, + ) + .await; + + Ok(()) +} + +/// 批量更新轮播图排序 +pub async fn sort_banners( + state: &HealthState, + tenant_id: Uuid, + items: Vec, +) -> HealthResult<()> { + for item in items { + banner::Entity::update_many() + .col_expr(banner::Column::SortOrder, Expr::value(item.sort_order)) + .col_expr(banner::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(banner::Column::Id.eq(item.id)) + .filter(banner::Column::TenantId.eq(tenant_id)) + .filter(banner::Column::DeletedAt.is_null()) + .exec(&state.db) + .await?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 公开端点(小程序) +// --------------------------------------------------------------------------- + +/// 查询当前生效的轮播图(公开端点),按 sort_order 升序 +pub async fn list_public_banners( + state: &HealthState, + tenant_id: Uuid, + base_url: &str, +) -> HealthResult> { + let now = Utc::now(); + + let banners = banner::Entity::find() + .filter(banner::Column::TenantId.eq(tenant_id)) + .filter(banner::Column::DeletedAt.is_null()) + .filter(banner::Column::Status.eq("active")) + .filter( + Condition::any() + .add(banner::Column::StartTime.is_null()) + .add(banner::Column::StartTime.lte(now)), + ) + .filter( + Condition::any() + .add(banner::Column::EndTime.is_null()) + .add(banner::Column::EndTime.gte(now)), + ) + .order_by_asc(banner::Column::SortOrder) + .all(&state.db) + .await?; + + let media_ids: Vec = banners.iter().map(|b| b.media_item_id).collect(); + let media_map = load_media_map(state, &media_ids).await?; + + let result: Vec = banners + .into_iter() + .filter_map(|b| { + let media = media_map.get(&b.media_item_id)?; + // 跳过已删除的媒体文件 + if media.deleted_at.is_some() { + return None; + } + + let (token, expires) = generate_signed_url(&media.storage_path, SIGN_URL_SECRET, 3600); + let image_url = format!( + "{}{}?expires={}&token={}", + base_url.trim_end_matches('/'), + media.storage_path, + expires, + token + ); + + Some(PublicBannerResp { + id: b.id, + title: b.title, + subtitle: b.subtitle, + image_url: Some(image_url), + link_type: b.link_type, + link_target: b.link_target, + }) + }) + .collect(); + + Ok(result) +} + +// --------------------------------------------------------------------------- +// 签名 URL +// --------------------------------------------------------------------------- + +/// 生成 HMAC-SHA256 签名 URL token(同步函数) +pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (String, i64) { + let expires = Utc::now().timestamp() + ttl_secs as i64; + let message = format!("{}\n{}", path, expires); + let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC key length valid"); + mac.update(message.as_bytes()); + let token = hex::encode(mac.finalize().into_bytes()); + (token, expires) +} + +// --------------------------------------------------------------------------- +// 内部辅助函数 +// --------------------------------------------------------------------------- + +/// 查找轮播图(未软删除) +async fn find_banner( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + banner::Entity::find() + .filter(banner::Column::Id.eq(id)) + .filter(banner::Column::TenantId.eq(tenant_id)) + .filter(banner::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| HealthError::Validation("轮播图不存在".to_string())) +} + +/// 批量加载 media_item,返回 id -> Model 映射 +async fn load_media_map( + state: &HealthState, + media_ids: &[Uuid], +) -> HealthResult> { + if media_ids.is_empty() { + return Ok(HashMap::new()); + } + + let items = media_item::Entity::find() + .filter(media_item::Column::Id.is_in(media_ids.iter().copied())) + .all(&state.db) + .await?; + + let map: HashMap = items.into_iter().map(|m| (m.id, m)).collect(); + + Ok(map) +} + +/// 将 banner Model 转换为 BannerResp DTO +fn banner_to_resp(b: &banner::Model, media_map: &HashMap) -> BannerResp { + let media = media_map.get(&b.media_item_id); + let media_deleted = media.is_some_and(|m| m.deleted_at.is_some()); + + BannerResp { + id: b.id, + tenant_id: b.tenant_id, + media_item_id: b.media_item_id, + title: b.title.clone(), + subtitle: b.subtitle.clone(), + link_type: b.link_type.clone(), + link_target: b.link_target.clone(), + sort_order: b.sort_order, + status: b.status.clone(), + start_time: b.start_time, + end_time: b.end_time, + image_url: media.map(|m| m.storage_path.clone()), + thumbnail_url: media.and_then(|m| m.thumbnail_path.clone()), + media_deleted, + created_at: b.created_at, + updated_at: b.updated_at, + created_by: b.created_by, + updated_by: b.updated_by, + version: b.version, + } +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index f2d911e..990f99a 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -9,6 +9,7 @@ pub mod appointment_service; pub mod article_category_service; pub mod article_service; pub mod article_tag_service; +pub mod banner_service; pub mod ble_gateway_service; pub mod care_plan_service; pub mod consent_service;