// 贴纸服务 — 贴纸包与贴纸管理 use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use crate::dto::{CreateStickerPackReq, CreateStickerReq, StickerPackResp, StickerResp, TemplateResp, UpdateStickerPackReq}; use crate::entity::{sticker, sticker_pack, template}; use crate::error::{DiaryError, DiaryResult}; /// 贴纸服务 — 贴纸包浏览、贴纸查询、模板管理 pub struct StickerService; impl StickerService { /// 获取贴纸包列表 /// /// 使用 SQL GROUP BY 批量获取贴纸计数,替代逐包 COUNT 查询。 /// 性能: 2 次查询(packs + counts)替代 N+1 次。 pub async fn list_sticker_packs( tenant_id: Uuid, category: Option, db: &DatabaseConnection, ) -> DiaryResult> { let mut query = sticker_pack::Entity::find() .filter(sticker_pack::Column::TenantId.eq(tenant_id)) .filter(sticker_pack::Column::DeletedAt.is_null()); if let Some(ref cat) = category { query = query.filter(sticker_pack::Column::Category.eq(cat)); } let packs = query .order_by_asc(sticker_pack::Column::Category) .order_by_asc(sticker_pack::Column::Name) .all(db) .await?; // 批量获取所有贴纸包的贴纸计数 — 单次 SQL GROUP BY(替代 N+1 查询) let pack_ids: Vec = packs.iter().map(|p| p.id).collect(); let count_map: std::collections::HashMap = if !pack_ids.is_empty() { let sql = r#" SELECT pack_id, COUNT(*) AS count FROM stickers WHERE pack_id = ANY($1) AND deleted_at IS NULL GROUP BY pack_id "#; let stmt = sea_orm::Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, sql, [pack_ids.into()], ); let rows = db.query_all(stmt).await?; rows.into_iter() .filter_map(|row| { let pack_id: Uuid = row.try_get_by_index::(0).ok()?; let count: i64 = row.try_get_by_index::(1).ok()?; Some((pack_id, count)) }) .collect() } else { std::collections::HashMap::new() }; let result: Vec = packs .into_iter() .map(|pack| StickerPackResp { id: pack.id, name: pack.name, description: pack.description, cover_image_url: pack.thumbnail_url, sticker_count: *count_map.get(&pack.id).unwrap_or(&0) as i32, is_free: pack.is_free, category: pack.category, }) .collect(); Ok(result) } /// 获取贴纸包内的贴纸列表 pub async fn list_stickers_in_pack( tenant_id: Uuid, pack_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult> { // 验证贴纸包存在 let _pack = sticker_pack::Entity::find() .filter(sticker_pack::Column::Id.eq(pack_id)) .filter(sticker_pack::Column::TenantId.eq(tenant_id)) .filter(sticker_pack::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; let stickers = sticker::Entity::find() .filter(sticker::Column::PackId.eq(pack_id)) .filter(sticker::Column::TenantId.eq(tenant_id)) .filter(sticker::Column::DeletedAt.is_null()) .all(db) .await?; Ok(stickers .into_iter() .map(|s| StickerResp { id: s.id, pack_id: s.pack_id, name: s.name, image_url: s.image_url, category: s.category, }) .collect()) } /// 获取模板列表 /// /// 返回所有可用模板,包括官方模板和用户创建的模板。 pub async fn list_templates( tenant_id: Uuid, category: Option, db: &DatabaseConnection, ) -> DiaryResult> { let mut query = template::Entity::find() .filter(template::Column::TenantId.eq(tenant_id)) .filter(template::Column::DeletedAt.is_null()); if let Some(ref cat) = category { query = query.filter(template::Column::Category.eq(cat)); } let templates = query .order_by_desc(template::Column::IsOfficial) .order_by_asc(template::Column::Name) .all(db) .await?; Ok(templates .into_iter() .map(|t| TemplateResp { id: t.id, name: t.name, description: None, // template entity 无 description 字段 preview_url: t.thumbnail_url, template_data: t.layout_data, category: t.category, is_free: true, // Phase 1 所有模板免费 }) .collect()) } /// 获取模板详情 pub async fn get_template( tenant_id: Uuid, template_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult { let model = template::Entity::find() .filter(template::Column::Id.eq(template_id)) .filter(template::Column::TenantId.eq(tenant_id)) .filter(template::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("模板 {} 不存在", template_id)))?; Ok(TemplateResp { id: model.id, name: model.name, description: None, preview_url: model.thumbnail_url, template_data: model.layout_data, category: model.category, is_free: true, }) } /// 创建贴纸包(管理端) pub async fn create_sticker_pack( tenant_id: Uuid, user_id: Uuid, req: &CreateStickerPackReq, db: &DatabaseConnection, ) -> DiaryResult { let now = chrono::Utc::now(); let id = Uuid::now_v7(); let model = sticker_pack::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), name: Set(req.name.clone()), description: Set(req.description.clone()), thumbnail_url: Set(req.thumbnail_url.clone()), is_free: Set(req.is_free), price: Set(req.price), category: Set(req.category.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(user_id), updated_by: Set(user_id), deleted_at: Set(None), version: Set(1), }; let inserted = model.insert(db).await?; Ok(StickerPackResp { id: inserted.id, name: inserted.name, description: inserted.description, cover_image_url: inserted.thumbnail_url, sticker_count: 0, is_free: inserted.is_free, category: inserted.category, }) } /// 更新贴纸包(部分更新,仅修改传入的字段) pub async fn update_sticker_pack( tenant_id: Uuid, pack_id: Uuid, user_id: Uuid, req: &UpdateStickerPackReq, db: &DatabaseConnection, ) -> DiaryResult { let model = sticker_pack::Entity::find() .filter(sticker_pack::Column::Id.eq(pack_id)) .filter(sticker_pack::Column::TenantId.eq(tenant_id)) .filter(sticker_pack::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; let now = chrono::Utc::now(); let mut active: sticker_pack::ActiveModel = model.into(); if let Some(ref name) = req.name { active.name = Set(name.clone()); } if let Some(ref description) = req.description { active.description = Set(Some(description.clone())); } if let Some(ref thumbnail_url) = req.thumbnail_url { active.thumbnail_url = Set(Some(thumbnail_url.clone())); } if let Some(is_free) = req.is_free { active.is_free = Set(is_free); } if let Some(price) = req.price { active.price = Set(price); } if let Some(ref category) = req.category { active.category = Set(Some(category.clone())); } active.updated_at = Set(now); active.updated_by = Set(user_id); let updated = active.update(db).await?; // 查询贴纸数量 let sticker_count = sticker::Entity::find() .filter(sticker::Column::PackId.eq(updated.id)) .filter(sticker::Column::DeletedAt.is_null()) .count(db) .await? as i32; Ok(StickerPackResp { id: updated.id, name: updated.name, description: updated.description, cover_image_url: updated.thumbnail_url, sticker_count, is_free: updated.is_free, category: updated.category, }) } /// 删除贴纸包(软删除) pub async fn delete_sticker_pack( tenant_id: Uuid, pack_id: Uuid, user_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult<()> { let model = sticker_pack::Entity::find() .filter(sticker_pack::Column::Id.eq(pack_id)) .filter(sticker_pack::Column::TenantId.eq(tenant_id)) .filter(sticker_pack::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; let now = chrono::Utc::now(); let mut active: sticker_pack::ActiveModel = model.into(); active.deleted_at = Set(Some(now)); active.updated_at = Set(now); active.updated_by = Set(user_id); active.update(db).await?; Ok(()) } /// 创建贴纸(管理端) pub async fn create_sticker( tenant_id: Uuid, user_id: Uuid, pack_id: Uuid, req: &CreateStickerReq, db: &DatabaseConnection, ) -> DiaryResult { // 验证贴纸包存在 let _pack = sticker_pack::Entity::find() .filter(sticker_pack::Column::Id.eq(pack_id)) .filter(sticker_pack::Column::TenantId.eq(tenant_id)) .filter(sticker_pack::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?; let now = chrono::Utc::now(); let id = Uuid::now_v7(); let model = sticker::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), pack_id: Set(pack_id), name: Set(req.name.clone()), image_url: Set(req.image_url.clone()), category: Set(req.category.clone()), tags: Set(None), created_at: Set(now), updated_at: Set(now), created_by: Set(user_id), updated_by: Set(user_id), deleted_at: Set(None), version: Set(1), }; let inserted = model.insert(db).await?; Ok(StickerResp { id: inserted.id, pack_id: inserted.pack_id, name: inserted.name, image_url: inserted.image_url, category: inserted.category, }) } } #[cfg(test)] mod tests { use super::*; // ===== DTO 序列化测试 ===== #[test] fn sticker_pack_resp_serializes() { let resp = StickerPackResp { id: Uuid::nil(), name: "可爱猫咪".into(), description: Some("超萌的猫咪贴纸".into()), cover_image_url: Some("https://example.com/cat.png".into()), sticker_count: 24, is_free: true, category: Some("动物".into()), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"sticker_count\":24")); assert!(json.contains("\"is_free\":true")); assert!(json.contains("\"category\":\"动物\"")); } #[test] fn sticker_resp_serializes() { let resp = StickerResp { id: Uuid::nil(), pack_id: Uuid::nil(), name: "笑脸猫".into(), image_url: "https://example.com/cat-smile.png".into(), category: Some("表情".into()), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"name\":\"笑脸猫\"")); } #[test] fn template_resp_serializes() { let resp = TemplateResp { id: Uuid::nil(), name: "今日心情".into(), description: Some("记录今天的心情".into()), preview_url: None, template_data: Some(serde_json::json!({"layout": "grid"})), category: Some("日常".into()), is_free: true, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"is_free\":true")); assert!(json.contains("\"layout\":\"grid\"")); } // ===== 错误处理测试 ===== #[test] fn sticker_pack_not_found_error() { let pack_id = Uuid::now_v7(); let err = DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)); assert!(err.to_string().contains(&pack_id.to_string())); } #[test] fn template_not_found_error() { let template_id = Uuid::now_v7(); let err = DiaryError::NotFound(format!("模板 {} 不存在", template_id)); assert!(err.to_string().contains(&template_id.to_string())); } }