diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index f910a08..63d39ef 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -380,6 +380,51 @@ pub struct TemplateResp { pub is_free: bool, } +// ========== 发现页 ========== + +/// 发现页聚合响应 — 一次返回全部板块数据 +#[derive(Debug, Serialize, ToSchema)] +pub struct DiscoverResp { + /// 每日推荐(无共享日记时为 null) + pub daily_inspiration: Option, + /// 热门话题(标签频率 TOP 8) + pub hot_topics: Vec, + /// 精选模板(官方模板) + pub featured_templates: Vec, + /// 达人日记(不同作者最近共享日记) + pub expert_diaries: Vec, +} + +/// 每日推荐条目 +#[derive(Debug, Serialize, ToSchema)] +pub struct InspirationItem { + pub journal_id: uuid::Uuid, + pub title: String, + pub author_name: String, + pub mood: String, + pub date: chrono::NaiveDate, +} + +/// 热门话题 +#[derive(Debug, Serialize, ToSchema)] +pub struct TagCount { + pub tag: String, + pub count: i64, +} + +/// 达人日记条目 +#[derive(Debug, Serialize, ToSchema)] +pub struct ExpertDiaryItem { + pub journal_id: uuid::Uuid, + pub title: String, + pub author_id: uuid::Uuid, + pub author_name: String, + pub author_emoji: String, + pub content_preview: String, + pub like_count: i64, + pub created_at: chrono::DateTime, +} + /// 成就响应 #[derive(Debug, Serialize, ToSchema)] pub struct AchievementResp { diff --git a/crates/erp-diary/src/handler/discover_handler.rs b/crates/erp-diary/src/handler/discover_handler.rs new file mode 100644 index 0000000..c92aa63 --- /dev/null +++ b/crates/erp-diary/src/handler/discover_handler.rs @@ -0,0 +1,40 @@ +// 发现页 API 处理器 + +use axum::extract::{Extension, FromRef, State}; +use axum::response::Json; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::DiscoverResp; +use crate::service::discover_service::DiscoverService; +use crate::state::DiaryState; + +#[utoipa::path( + get, + path = "/api/v1/diary/discover", + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "发现页" +)] +/// GET /api/v1/diary/discover +/// +/// 获取发现页全部数据(每日推荐、热门话题、精选模板、达人日记)。 +/// 需要 `diary.journal.read` 权限。 +pub async fn get_discover( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = DiscoverService::get_discover(ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs index 362f180..eb975a2 100644 --- a/crates/erp-diary/src/handler/mod.rs +++ b/crates/erp-diary/src/handler/mod.rs @@ -9,3 +9,4 @@ pub mod sticker_handler; pub mod achievement_handler; pub mod stats_handler; pub mod parent_handler; +pub mod discover_handler; diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index 9f9a776..4acf73f 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -12,7 +12,7 @@ use erp_core::module::ErpModule; use crate::handler::{ journal_handler, sync_handler, class_handler, topic_handler, comment_handler, - sticker_handler, achievement_handler, stats_handler, parent_handler, + sticker_handler, achievement_handler, stats_handler, parent_handler, discover_handler, }; /// 暖记日记业务模块 @@ -268,5 +268,10 @@ impl DiaryModule { "/diary/parent/bindings/{binding_id}/reject", axum::routing::post(parent_handler::reject_binding), ) + // 发现页 — 灵感、热门话题、精选模板、达人日记 + .route( + "/diary/discover", + axum::routing::get(discover_handler::get_discover), + ) } } diff --git a/crates/erp-diary/src/service/discover_service.rs b/crates/erp-diary/src/service/discover_service.rs new file mode 100644 index 0000000..4fc900e --- /dev/null +++ b/crates/erp-diary/src/service/discover_service.rs @@ -0,0 +1,312 @@ +// 发现页服务 — 聚合热门话题、精选模板、每日推荐、达人日记 + +use sea_orm::{ + ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, + QuerySelect, Statement, +}; +use uuid::Uuid; + +use crate::dto::{DiscoverResp, ExpertDiaryItem, InspirationItem, TagCount, TemplateResp}; +use crate::entity::template; +use crate::error::DiaryResult; + +/// 发现页服务 — 聚合查询,一次返回全部板块数据 +pub struct DiscoverService; + +/// 心情 → emoji 映射 +fn mood_to_emoji(mood: &str) -> &'static str { + match mood { + "happy" => "😊", + "calm" => "😌", + "sad" => "😢", + "angry" => "😤", + "thinking" => "🤔", + _ => "📝", + } +} + +impl DiscoverService { + /// 获取发现页全部数据(4 个板块并发查询) + pub async fn get_discover( + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + let (inspiration, topics, templates, experts) = tokio::join!( + Self::daily_inspiration(tenant_id, db), + Self::hot_topics(tenant_id, db), + Self::featured_templates(tenant_id, db), + Self::expert_diaries(tenant_id, db), + ); + + Ok(DiscoverResp { + daily_inspiration: inspiration?, + hot_topics: topics?, + featured_templates: templates?, + expert_diaries: experts?, + }) + } + + /// 每日推荐 — 基于日期种子的确定性随机,选取一篇共享日记 + /// + /// 使用日期字符串作为盐,与 UUID 拼接后取哈希,得到每天固定但不同的结果。 + /// 无共享日记时返回 None。 + async fn daily_inspiration( + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let date_seed = chrono::Utc::now().format("%Y-%m-%d").to_string(); + + let sql = r#" + SELECT id, title, author_id, mood, date + FROM journal_entries + WHERE tenant_id = $1 + AND is_private = false + AND shared_to_class = true + AND deleted_at IS NULL + ORDER BY ( + ('x' || md5(id::text || $2))::bit(32)::int + ) DESC + LIMIT 1 + "#; + + let stmt = Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), date_seed.into()], + ); + + let rows = db.query_all(stmt).await?; + + if let Some(row) = rows.into_iter().next() { + let journal_id: Uuid = row.try_get_by_index::(0)?; + let title: String = row.try_get_by_index::(1)?; + let author_id: Uuid = row.try_get_by_index::(2)?; + let mood: String = row.try_get_by_index::(3)?; + let date: chrono::NaiveDate = row.try_get_by_index::(4)?; + + // Phase 1: 用 author_id 前 4 位作为昵称后缀 + let author_hex = author_id.to_string().replace('-', ""); + let suffix = &author_hex[..4]; + let author_name = format!("小暖·{}", suffix); + + Ok(Some(InspirationItem { + journal_id, + title, + author_name, + mood, + date, + })) + } else { + Ok(None) + } + } + + /// 热门话题 — 统计所有非私密日记的标签频率,返回 TOP 8 + async fn hot_topics( + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let sql = r#" + SELECT tag, COUNT(*) AS count + FROM ( + SELECT jsonb_array_elements_text(tags) AS tag + FROM journal_entries + WHERE tenant_id = $1 + AND is_private = false + AND deleted_at IS NULL + AND tags IS NOT NULL + ) sub + GROUP BY tag + ORDER BY count DESC + LIMIT 8 + "#; + + let stmt = Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ); + + let rows = db.query_all(stmt).await?; + + let topics = rows + .into_iter() + .filter_map(|row| { + let tag: String = row.try_get_by_index::(0).ok()?; + let count: i64 = row.try_get_by_index::(1).ok()?; + Some(TagCount { tag, count }) + }) + .collect(); + + Ok(topics) + } + + /// 精选模板 — 官方模板,按名称排序,最多 6 个 + async fn featured_templates( + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let templates = template::Entity::find() + .filter(template::Column::TenantId.eq(tenant_id)) + .filter(template::Column::IsOfficial.eq(true)) + .filter(template::Column::DeletedAt.is_null()) + .order_by_asc(template::Column::Name) + .limit(6) + .all(db) + .await?; + + Ok(templates + .into_iter() + .map(|t| TemplateResp { + id: t.id, + name: t.name, + description: None, + preview_url: t.thumbnail_url, + template_data: None, // 发现页不需要完整布局数据 + category: t.category, + is_free: true, + }) + .collect()) + } + + /// 达人日记 — 不同作者最近共享的日记,以评论数作为热度代理 + async fn expert_diaries( + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let sql = r#" + SELECT + j.id, j.title, j.author_id, j.mood, + j.created_at, + COUNT(c.id) AS comment_count + FROM journal_entries j + LEFT JOIN comments c + ON c.journal_id = j.id + AND c.deleted_at IS NULL + WHERE j.tenant_id = $1 + AND j.is_private = false + AND j.shared_to_class = true + AND j.deleted_at IS NULL + GROUP BY j.id + ORDER BY j.created_at DESC + LIMIT 20 + "#; + + let stmt = Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ); + + let rows = db.query_all(stmt).await?; + + // 去重:每个作者只保留最新一篇,最多 5 位作者 + let mut seen_authors = std::collections::HashSet::new(); + let mut experts = Vec::new(); + + for row in rows { + let author_id: Uuid = row.try_get_by_index::(2)?; + if seen_authors.contains(&author_id) { + continue; + } + if experts.len() >= 5 { + break; + } + seen_authors.insert(author_id); + + let journal_id: Uuid = row.try_get_by_index::(0)?; + let title: String = row.try_get_by_index::(1)?; + let mood: String = row.try_get_by_index::(3)?; + let created_at: chrono::DateTime = + row.try_get_by_index::>(4)?; + let comment_count: i64 = row.try_get_by_index::(5)?; + + let author_hex = author_id.to_string().replace('-', ""); + let suffix = &author_hex[..4]; + let author_name = format!("日记达人·{}", suffix); + + experts.push(ExpertDiaryItem { + journal_id, + title, + author_id, + author_name, + author_emoji: mood_to_emoji(&mood).to_string(), + content_preview: String::new(), // Phase 1: 无 content_preview 列,暂留空 + like_count: comment_count, + created_at, + }); + } + + Ok(experts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mood_to_emoji_maps_correctly() { + assert_eq!(mood_to_emoji("happy"), "😊"); + assert_eq!(mood_to_emoji("calm"), "😌"); + assert_eq!(mood_to_emoji("sad"), "😢"); + assert_eq!(mood_to_emoji("angry"), "😤"); + assert_eq!(mood_to_emoji("thinking"), "🤔"); + assert_eq!(mood_to_emoji("unknown"), "📝"); + } + + #[test] + fn discover_resp_structure() { + let resp = DiscoverResp { + daily_inspiration: Some(InspirationItem { + journal_id: Uuid::nil(), + title: "测试日记".into(), + author_name: "小暖·a3f2".into(), + mood: "happy".into(), + date: chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap(), + }), + hot_topics: vec![ + TagCount { + tag: "期末备考".into(), + count: 42, + }, + ], + featured_templates: vec![], + expert_diaries: vec![], + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"daily_inspiration\"")); + assert!(json.contains("\"hot_topics\"")); + assert!(json.contains("\"期末备考\"")); + assert!(json.contains("\"count\":42")); + } + + #[test] + fn discover_resp_null_inspiration() { + let resp = DiscoverResp { + daily_inspiration: None, + hot_topics: vec![], + featured_templates: vec![], + expert_diaries: vec![], + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"daily_inspiration\":null")); + } + + #[test] + fn expert_diary_item_serializes() { + let item = ExpertDiaryItem { + journal_id: Uuid::nil(), + title: "春日漫步手账".into(), + author_id: Uuid::nil(), + author_name: "日记达人·abcd".into(), + author_emoji: "🌸".into(), + content_preview: "记录春天的每一朵花开...".into(), + like_count: 342, + created_at: chrono::Utc::now(), + }; + let json = serde_json::to_string(&item).unwrap(); + assert!(json.contains("\"like_count\":342")); + assert!(json.contains("\"author_emoji\":\"🌸\"")); + } +} diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs index b506e1e..59200d7 100644 --- a/crates/erp-diary/src/service/mod.rs +++ b/crates/erp-diary/src/service/mod.rs @@ -11,3 +11,4 @@ pub mod achievement_service; pub mod mood_stats_service; pub mod content_safety_service; pub mod parent_service; +pub mod discover_service; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index bc9cdf5..cc885c8 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -208,6 +208,7 @@ struct MessageApiDoc; erp_diary::handler::parent_handler::list_pending_bindings, erp_diary::handler::parent_handler::confirm_binding, erp_diary::handler::parent_handler::reject_binding, + erp_diary::handler::discover_handler::get_discover, ), components(schemas( erp_diary::dto::CreateJournalReq, @@ -241,6 +242,10 @@ struct MessageApiDoc; erp_diary::handler::parent_handler::DeleteChildDataReq, erp_diary::handler::parent_handler::BindingResp, erp_diary::handler::parent_handler::DeleteResultResp, + erp_diary::dto::DiscoverResp, + erp_diary::dto::InspirationItem, + erp_diary::dto::TagCount, + erp_diary::dto::ExpertDiaryItem, )) )] struct DiaryApiDoc;