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:
iven
2026-05-10 15:15:11 +08:00
parent 601d977438
commit a9bd850ce2
2 changed files with 407 additions and 0 deletions

View 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,
}
}

View File

@@ -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;