前端改动: - 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护) - 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
220 lines
6.9 KiB
Rust
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));
|
|
}
|
|
}
|