Files
nj/crates/erp-diary/src/service/achievement_service.rs
iven 8331db63ba feat(app): 设置页 UI + Mood/成就/贴纸 BLoC 接入 API + B7 测试扩展
前端改动:
- 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护)
- SettingsBloc 注册到 MultiRepositoryProvider 全局可访问
- MoodBloc 修复编译错误 + 接入 /diary/stats/mood API
- MoodPage 添加错误状态展示和重试按钮
- AchievementBloc + 页面改造接入 /diary/achievements API
- StickerBloc + 页面改造接入 /diary/sticker-packs API
- TemplateBloc + 页面改造接入 /diary/templates API
- ProfilePage 设置入口改为跳转 /settings
- 添加 /settings 路由

后端改动:
- 扩展 mood_stats_service 测试 (连续天数算法/心情计数/边界场景)
- 新增 class_service 测试 (班级码生成/唯一性/错误映射)
- 新增 achievement_service 测试 (DTO 结构/序列化/map 构建)
- 新增 sticker_service 测试 (DTO 序列化/错误处理)
- 扩展 dto.rs 测试 (achievement/mood_stats/sticker/template/notification)
- 清理 2 个 unused import warning

验证:
- cargo check 0 error 0 warning
- flutter analyze 0 error
2026-06-01 11:19:43 +08:00

220 lines
6.9 KiB
Rust

// 成就服务 — 成就定义与解锁逻辑
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<Vec<AchievementResp>> {
// 查询所有成就定义
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<Uuid, chrono::DateTime<Utc>> = 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<AchievementResp> {
// 查找成就定义
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<Uuid, chrono::DateTime<Utc>> = 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));
}
}