feat(health): 实现轮播图 service — CRUD + 排序 + 签名 URL
- 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 <noreply@anthropic.com>
This commit is contained in:
406
crates/erp-health/src/service/banner_service.rs
Normal file
406
crates/erp-health/src/service/banner_service.rs
Normal file
@@ -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<Sha256>;
|
||||
|
||||
/// 签名 URL 密钥占位符,生产环境通过配置注入
|
||||
const SIGN_URL_SECRET: &str = "CHANGE_ME_IN_PRODUCTION";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 列出租户所有轮播图(未软删除),可选按状态筛选
|
||||
pub async fn list_banners(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
status: Option<String>,
|
||||
) -> HealthResult<Vec<BannerResp>> {
|
||||
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<Uuid> = banners.iter().map(|b| b.media_item_id).collect();
|
||||
let media_map = load_media_map(state, &media_ids).await?;
|
||||
|
||||
let result: Vec<BannerResp> = 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<BannerResp> {
|
||||
// 验证 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<BannerResp> {
|
||||
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<SortItem>,
|
||||
) -> 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<Vec<PublicBannerResp>> {
|
||||
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<Uuid> = banners.iter().map(|b| b.media_item_id).collect();
|
||||
let media_map = load_media_map(state, &media_ids).await?;
|
||||
|
||||
let result: Vec<PublicBannerResp> = 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::Model> {
|
||||
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<HashMap<Uuid, media_item::Model>> {
|
||||
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<Uuid, media_item::Model> = 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<Uuid, media_item::Model>) -> 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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user