Files
nj/crates/erp-diary/src/service/sticker_service.rs
iven b6ffc60331
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
perf(diary): sticker_service 批量 GROUP BY 替代 N+1 贴纸计数 — 8a-C04
- list_sticker_packs: 单次 SQL GROUP BY pack_id 获取所有计数
- 2 次查询(packs + counts)替代 N+1 次
- 使用 PostgreSQL ANY() 传递 UUID 数组
- 测试 80/80 通过
2026-06-03 15:51:05 +08:00

420 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 贴纸服务 — 贴纸包与贴纸管理
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()));
}
}