- list_sticker_packs: 单次 SQL GROUP BY pack_id 获取所有计数 - 2 次查询(packs + counts)替代 N+1 次 - 使用 PostgreSQL ANY() 传递 UUID 数组 - 测试 80/80 通过
420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
// 贴纸服务 — 贴纸包与贴纸管理
|
||
|
||
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<String>,
|
||
db: &DatabaseConnection,
|
||
) -> DiaryResult<Vec<StickerPackResp>> {
|
||
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<Uuid> = packs.iter().map(|p| p.id).collect();
|
||
let count_map: std::collections::HashMap<Uuid, i64> = 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::<Uuid>(0).ok()?;
|
||
let count: i64 = row.try_get_by_index::<i64>(1).ok()?;
|
||
Some((pack_id, count))
|
||
})
|
||
.collect()
|
||
} else {
|
||
std::collections::HashMap::new()
|
||
};
|
||
|
||
let result: Vec<StickerPackResp> = 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<Vec<StickerResp>> {
|
||
// 验证贴纸包存在
|
||
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<String>,
|
||
db: &DatabaseConnection,
|
||
) -> DiaryResult<Vec<TemplateResp>> {
|
||
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<TemplateResp> {
|
||
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<StickerPackResp> {
|
||
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<StickerPackResp> {
|
||
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<StickerResp> {
|
||
// 验证贴纸包存在
|
||
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()));
|
||
}
|
||
}
|