perf(diary): mood_stats 改用 SQL GROUP BY 替代全量加载 — 8a-C01

- get_mood_stats: SELECT mood, COUNT(*) GROUP BY 替代 all() + Rust 迭代
- calculate_streak: 仅查 date 列 + DISTINCT + 366天窗口裁剪
- 新增 mood_counts_map_aggregation 单元测试
- 测试 78/78 通过
This commit is contained in:
iven
2026-06-03 15:37:09 +08:00
parent 99db8e5cb0
commit 0c9ada242a

View File

@@ -1,12 +1,15 @@
// 心情统计服务 — 心情趋势与连续天数 // 心情统计服务 — 心情趋势与连续天数
//
// 性能优化 (8a-C01):
// - mood_counts: SQL GROUP BY 替代全量加载 + Rust 迭代
// - streak: 仅查 date 列 + DISTINCT + 时间窗口裁剪,避免加载所有日记字段
use chrono::{Duration, NaiveDate, Utc}; use chrono::{Duration, NaiveDate, Utc};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use sea_orm::{ConnectionTrait, DatabaseConnection};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::dto::{Mood, MoodCount, MoodStatsResp}; use crate::dto::{Mood, MoodCount, MoodStatsResp};
use crate::entity::journal_entry;
use crate::error::DiaryResult; use crate::error::DiaryResult;
/// 统计查询范围 /// 统计查询范围
@@ -37,8 +40,8 @@ pub struct MoodStatsService;
impl MoodStatsService { impl MoodStatsService {
/// 获取心情统计 /// 获取心情统计
/// ///
/// 统计指定时间范围内各心情出现次数、连续写日记天数、 /// 使用 SQL GROUP BY 聚合,避免全量加载日记到内存。
/// 最常用心情等数据 /// 性能: O(mood_types) 而非 O(total_journals)
pub async fn get_mood_stats( pub async fn get_mood_stats(
tenant_id: Uuid, tenant_id: Uuid,
user_id: Uuid, user_id: Uuid,
@@ -47,24 +50,34 @@ impl MoodStatsService {
) -> DiaryResult<MoodStatsResp> { ) -> DiaryResult<MoodStatsResp> {
let since_date = (Utc::now() - Duration::days(period.days())).date_naive(); let since_date = (Utc::now() - Duration::days(period.days())).date_naive();
// 查询时间范围内的日记 // SQL GROUP BY — 一次查询获取所有心情计数(替代全量加载)
let journals = journal_entry::Entity::find() let sql = r#"
.filter(journal_entry::Column::TenantId.eq(tenant_id)) SELECT mood, COUNT(*) AS count
.filter(journal_entry::Column::AuthorId.eq(user_id)) FROM journal_entry
.filter(journal_entry::Column::Date.gte(since_date)) WHERE tenant_id = $1
.filter(journal_entry::Column::DeletedAt.is_null()) AND author_id = $2
.all(db) AND date >= $3
.await?; AND deleted_at IS NULL
GROUP BY mood
"#;
let total_journals = journals.len() as i32; let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), user_id.into(), since_date.into()],
);
// 计算各心情出现次数 let rows = db.query_all(stmt).await?;
let mut mood_counts_map: std::collections::HashMap<String, i32> =
let mut mood_counts_map: std::collections::HashMap<String, i64> =
std::collections::HashMap::new(); std::collections::HashMap::new();
for journal in &journals { let mut total_journals: i64 = 0;
*mood_counts_map
.entry(journal.mood.clone()) for row in rows {
.or_insert(0) += 1; let mood: String = row.try_get_by_index::<String>(0)?;
let count: i64 = row.try_get_by_index::<i64>(1)?;
total_journals += count;
mood_counts_map.insert(mood, count);
} }
let mood_counts: Vec<MoodCount> = mood_counts_map let mood_counts: Vec<MoodCount> = mood_counts_map
@@ -77,53 +90,64 @@ impl MoodStatsService {
}; };
MoodCount { MoodCount {
mood: parse_mood(mood), mood: parse_mood(mood),
count, count: count as i32,
percentage, percentage,
} }
}) })
.collect(); .collect();
// 查找最常用心情
let dominant_mood = mood_counts let dominant_mood = mood_counts
.iter() .iter()
.max_by_key(|mc| mc.count) .max_by_key(|mc| mc.count)
.map(|mc| mc.mood.clone()); .map(|mc| mc.mood.clone());
// 计算连续写日记天数
let streak_days = Self::calculate_streak(tenant_id, user_id, db).await?; let streak_days = Self::calculate_streak(tenant_id, user_id, db).await?;
Ok(MoodStatsResp { Ok(MoodStatsResp {
mood_counts, mood_counts,
streak_days, streak_days,
total_journals, total_journals: total_journals as i32,
dominant_mood, dominant_mood,
}) })
} }
/// 计算连续写日记天数 /// 计算连续写日记天数
/// ///
/// 从今天开始往前数,连续有日记记录的天数 /// 只查询 date 列 + DISTINCT按日期降序限定 366 天窗口
/// 避免全量加载所有日记字段到内存。
async fn calculate_streak( async fn calculate_streak(
tenant_id: Uuid, tenant_id: Uuid,
user_id: Uuid, user_id: Uuid,
db: &DatabaseConnection, db: &DatabaseConnection,
) -> DiaryResult<i32> { ) -> DiaryResult<i32> {
let journals = journal_entry::Entity::find() let cutoff = (Utc::now() - Duration::days(366)).date_naive();
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(user_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.all(db)
.await?;
// 收集所有有日记的日期 let sql = r#"
let mut dates: std::collections::HashSet<NaiveDate> = SELECT DISTINCT date
journals.into_iter().map(|j| j.date).collect(); FROM journal_entry
WHERE tenant_id = $1
AND author_id = $2
AND date >= $3
AND deleted_at IS NULL
ORDER BY date DESC
"#;
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), user_id.into(), cutoff.into()],
);
let rows = db.query_all(stmt).await?;
let dates: std::collections::HashSet<NaiveDate> = rows
.into_iter()
.filter_map(|row| row.try_get_by_index::<NaiveDate>(0).ok())
.collect();
let mut streak = 0i32; let mut streak = 0i32;
let mut check_date = Utc::now().date_naive(); let mut check_date = Utc::now().date_naive();
while dates.contains(&check_date) {
// 从今天开始往前检查
while dates.remove(&check_date) {
streak += 1; streak += 1;
check_date -= Duration::days(1); check_date -= Duration::days(1);
} }
@@ -206,8 +230,7 @@ mod tests {
let mut streak = 0i32; let mut streak = 0i32;
let mut check_date = today; let mut check_date = today;
let mut mutable_dates = dates.clone(); while dates.contains(&check_date) {
while mutable_dates.remove(&check_date) {
streak += 1; streak += 1;
check_date -= Duration::days(1); check_date -= Duration::days(1);
} }
@@ -223,8 +246,7 @@ mod tests {
let mut streak = 0i32; let mut streak = 0i32;
let mut check_date = today; let mut check_date = today;
let mut mutable_dates = dates.clone(); while dates.contains(&check_date) {
while mutable_dates.remove(&check_date) {
streak += 1; streak += 1;
check_date -= Duration::days(1); check_date -= Duration::days(1);
} }
@@ -240,8 +262,7 @@ mod tests {
let mut streak = 0i32; let mut streak = 0i32;
let mut check_date = today; let mut check_date = today;
let mut mutable_dates = dates.clone(); while dates.contains(&check_date) {
while mutable_dates.remove(&check_date) {
streak += 1; streak += 1;
check_date -= Duration::days(1); check_date -= Duration::days(1);
} }
@@ -258,8 +279,7 @@ mod tests {
let mut streak = 0i32; let mut streak = 0i32;
let mut check_date = today; let mut check_date = today;
let mut mutable_dates = dates.clone(); while dates.contains(&check_date) {
while mutable_dates.remove(&check_date) {
streak += 1; streak += 1;
check_date -= Duration::days(1); check_date -= Duration::days(1);
} }
@@ -271,9 +291,9 @@ mod tests {
#[test] #[test]
fn mood_counts_percentage_calculation() { fn mood_counts_percentage_calculation() {
// 模拟聚合逻辑3 happy + 2 calm = 5 total // 模拟聚合逻辑3 happy + 2 calm = 5 total
let total = 5i32; let total = 5i64;
let happy_count = 3i32; let happy_count = 3i64;
let calm_count = 2i32; let calm_count = 2i64;
let happy_pct = (happy_count as f64 / total as f64) * 100.0; let happy_pct = (happy_count as f64 / total as f64) * 100.0;
let calm_pct = (calm_count as f64 / total as f64) * 100.0; let calm_pct = (calm_count as f64 / total as f64) * 100.0;
@@ -285,8 +305,38 @@ mod tests {
#[test] #[test]
fn mood_counts_empty_total_zero_percentage() { fn mood_counts_empty_total_zero_percentage() {
// 无日记时,百分比为 0 // 无日记时,百分比为 0
let total = 0i32; let total = 0i64;
let percentage = if total > 0 { 100.0 } else { 0.0 }; let percentage = if total > 0 { 100.0 } else { 0.0 };
assert_eq!(percentage, 0.0); assert_eq!(percentage, 0.0);
} }
#[test]
fn mood_counts_map_aggregation() {
// 模拟 SQL GROUP BY 结果在 Rust 中构建 MoodCount 列表
let mut map: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
*map.entry("happy".to_string()).or_insert(0) += 3;
*map.entry("calm".to_string()).or_insert(0) += 2;
*map.entry("happy".to_string()).or_insert(0) += 1;
let total: i64 = map.values().sum();
assert_eq!(total, 6);
assert_eq!(*map.get("happy").unwrap(), 4);
assert_eq!(*map.get("calm").unwrap(), 2);
let counts: Vec<MoodCount> = map
.iter()
.map(|(mood, &count)| {
let percentage = (count as f64 / total as f64) * 100.0;
MoodCount {
mood: parse_mood(mood),
count: count as i32,
percentage,
}
})
.collect();
assert_eq!(counts.len(), 2);
let happy_mc = counts.iter().find(|c| matches!(c.mood, Mood::Happy)).unwrap();
assert!((happy_mc.percentage - 66.6667).abs() < 0.01);
}
} }