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,
|
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)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct AchievementResp {
|
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 achievement_handler;
|
||||||
pub mod stats_handler;
|
pub mod stats_handler;
|
||||||
pub mod parent_handler;
|
pub mod parent_handler;
|
||||||
|
pub mod discover_handler;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use erp_core::module::ErpModule;
|
|||||||
|
|
||||||
use crate::handler::{
|
use crate::handler::{
|
||||||
journal_handler, sync_handler, class_handler, topic_handler, comment_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",
|
"/diary/parent/bindings/{binding_id}/reject",
|
||||||
axum::routing::post(parent_handler::reject_binding),
|
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 mood_stats_service;
|
||||||
pub mod content_safety_service;
|
pub mod content_safety_service;
|
||||||
pub mod parent_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::list_pending_bindings,
|
||||||
erp_diary::handler::parent_handler::confirm_binding,
|
erp_diary::handler::parent_handler::confirm_binding,
|
||||||
erp_diary::handler::parent_handler::reject_binding,
|
erp_diary::handler::parent_handler::reject_binding,
|
||||||
|
erp_diary::handler::discover_handler::get_discover,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
erp_diary::dto::CreateJournalReq,
|
erp_diary::dto::CreateJournalReq,
|
||||||
@@ -241,6 +242,10 @@ struct MessageApiDoc;
|
|||||||
erp_diary::handler::parent_handler::DeleteChildDataReq,
|
erp_diary::handler::parent_handler::DeleteChildDataReq,
|
||||||
erp_diary::handler::parent_handler::BindingResp,
|
erp_diary::handler::parent_handler::BindingResp,
|
||||||
erp_diary::handler::parent_handler::DeleteResultResp,
|
erp_diary::handler::parent_handler::DeleteResultResp,
|
||||||
|
erp_diary::dto::DiscoverResp,
|
||||||
|
erp_diary::dto::InspirationItem,
|
||||||
|
erp_diary::dto::TagCount,
|
||||||
|
erp_diary::dto::ExpertDiaryItem,
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
struct DiaryApiDoc;
|
struct DiaryApiDoc;
|
||||||
|
|||||||
Reference in New Issue
Block a user