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:
@@ -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 {
|
||||
|
||||
40
crates/erp-diary/src/handler/discover_handler.rs
Normal file
40
crates/erp-diary/src/handler/discover_handler.rs
Normal 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)))
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod sticker_handler;
|
||||
pub mod achievement_handler;
|
||||
pub mod stats_handler;
|
||||
pub mod parent_handler;
|
||||
pub mod discover_handler;
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
312
crates/erp-diary/src/service/discover_service.rs
Normal file
312
crates/erp-diary/src/service/discover_service.rs
Normal 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\":\"🌸\""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user