// 成就服务 — 成就定义与解锁逻辑 use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use crate::dto::AchievementResp; use crate::entity::{achievement, user_achievement}; use crate::error::{DiaryError, DiaryResult}; use crate::service::notification_service::NotificationService; use erp_core::events::EventBus; /// 成就服务 — 规则引擎 + 徽章解锁 /// /// Phase 1 成就规则(客户端触发): /// - first_diary: 写第一篇日记 /// - streak_7: 连续写日记 7 天 /// - streak_30: 连续写日记 30 天 /// - sticker_collector: 收集 10 张贴纸 /// - social_butterfly: 分享 5 篇日记到班级 pub struct AchievementService; impl AchievementService { /// 获取所有成就列表(含用户解锁状态) pub async fn list_achievements( tenant_id: Uuid, user_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult> { // 查询所有成就定义 let achievements = achievement::Entity::find() .filter(achievement::Column::TenantId.eq(tenant_id)) .filter(achievement::Column::DeletedAt.is_null()) .order_by_asc(achievement::Column::SortOrder) .all(db) .await?; // 查询用户已解锁的成就 let unlocked = user_achievement::Entity::find() .filter(user_achievement::Column::UserId.eq(user_id)) .filter(user_achievement::Column::TenantId.eq(tenant_id)) .filter(user_achievement::Column::DeletedAt.is_null()) .all(db) .await?; // 构建已解锁集合 let unlocked_map: std::collections::HashMap> = unlocked .into_iter() .map(|ua| (ua.achievement_id, ua.unlocked_at)) .collect(); Ok(achievements .into_iter() .map(|a| { let (is_unlocked, unlocked_at) = unlocked_map .get(&a.id) .map(|t| (true, Some(*t))) .unwrap_or((false, None)); AchievementResp { id: a.id, code: a.code, name: a.name, description: a.description, icon: a.icon, category: a.category, is_unlocked, unlocked_at, } }) .collect()) } /// 解锁成就 /// /// 幂等操作:如果已解锁则直接返回。解锁后发送 SSE 通知。 pub async fn unlock_achievement( tenant_id: Uuid, user_id: Uuid, achievement_code: &str, db: &DatabaseConnection, event_bus: &EventBus, ) -> DiaryResult { // 查找成就定义 let ach = achievement::Entity::find() .filter(achievement::Column::TenantId.eq(tenant_id)) .filter(achievement::Column::Code.eq(achievement_code)) .filter(achievement::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| { DiaryError::NotFound(format!("成就 {} 不存在", achievement_code)) })?; // 检查是否已解锁 let existing = user_achievement::Entity::find() .filter(user_achievement::Column::UserId.eq(user_id)) .filter(user_achievement::Column::AchievementId.eq(ach.id)) .filter(user_achievement::Column::DeletedAt.is_null()) .one(db) .await?; if existing.is_some() { // 已解锁,幂等返回 return Ok(AchievementResp { id: ach.id, code: ach.code.clone(), name: ach.name.clone(), description: ach.description.clone(), icon: ach.icon.clone(), category: ach.category.clone(), is_unlocked: true, unlocked_at: existing.map(|e| e.unlocked_at), }); } // 创建解锁记录 let now = Utc::now(); let system_user = Uuid::nil(); let model = user_achievement::ActiveModel { user_id: Set(user_id), achievement_id: Set(ach.id), tenant_id: Set(tenant_id), unlocked_at: Set(now), created_at: Set(now), updated_at: Set(now), created_by: Set(system_user), updated_by: Set(system_user), deleted_at: Set(None), version: Set(1), }; model.insert(db).await?; // 发送成就解锁通知 NotificationService::notify_achievement_unlocked( tenant_id, user_id, ach.code.clone(), ach.name.clone(), db, event_bus, ) .await; Ok(AchievementResp { id: ach.id, code: ach.code, name: ach.name, description: ach.description, icon: ach.icon, category: ach.category, is_unlocked: true, unlocked_at: Some(now), }) } } #[cfg(test)] mod tests { use super::*; #[test] fn achievement_not_found_error() { let err = DiaryError::NotFound("成就 xxx 不存在".to_string()); assert!(err.to_string().contains("xxx")); } #[test] fn achievement_resp_structure() { let resp = AchievementResp { id: Uuid::nil(), code: "first_diary".into(), name: "初次落笔".into(), description: Some("写下第一篇日记".into()), icon: Some("✏️".into()), category: "writing".into(), is_unlocked: false, unlocked_at: None, }; assert_eq!(resp.code, "first_diary"); assert!(!resp.is_unlocked); assert!(resp.unlocked_at.is_none()); } #[test] fn achievement_resp_serializes() { let resp = AchievementResp { id: Uuid::nil(), code: "streak_7".into(), name: "坚持一周".into(), description: None, icon: None, category: "writing".into(), is_unlocked: true, unlocked_at: Some(Utc::now()), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"is_unlocked\":true")); assert!(json.contains("\"code\":\"streak_7\"")); } #[test] fn unlocked_map_construction() { // 测试 unlocked_map 构建逻辑 use std::collections::HashMap; let mut map: HashMap> = HashMap::new(); let id = Uuid::now_v7(); let now = Utc::now(); map.insert(id, now); assert!(map.contains_key(&id)); assert_eq!(map.len(), 1); let missing = Uuid::now_v7(); assert!(!map.contains_key(&missing)); } }