diff --git a/src/api/posts.rs b/src/api/posts.rs deleted file mode 100644 index fb4ce39..0000000 --- a/src/api/posts.rs +++ /dev/null @@ -1,891 +0,0 @@ -#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)] - -use dioxus::prelude::*; - -#[cfg(feature = "server")] -use crate::api::error::AppError; -#[cfg(feature = "server")] -use crate::auth::session::get_session_from_ctx; -use crate::db::pool::get_conn; -use crate::models::post::{Post, PostStats, PostStatus, Tag}; -use crate::models::user::{User, UserRole}; -#[cfg(feature = "server")] -use crate::utils::text::{auto_summary, count_words}; -#[cfg(feature = "server")] -use crate::cache; - -// Re-export extracted modules -#[cfg(feature = "server")] -pub use crate::api::markdown::render_markdown_enhanced; -#[cfg(feature = "server")] -pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify}; - - -// ============================================================================ -// Server-side helpers (only compiled when server feature is enabled) -// ============================================================================ - -#[cfg(feature = "server")] -async fn get_current_admin_user() -> Result { - let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; - - let user = crate::api::auth::get_user_by_token(&token) - .await - .map_err(AppError::query)? - .ok_or(AppError::Unauthorized("会话已过期"))?; - - if user.role != UserRole::Admin { - return Err(AppError::Forbidden("权限不足")); - } - - Ok(user) -} - -// ============================================================================ -// Row to Post conversion -// ============================================================================ - -#[cfg(feature = "server")] -async fn row_to_post_list(_client: &tokio_postgres::Client, row: &tokio_postgres::Row) -> Post { - let id: i32 = row.get("id"); - let role_str: String = row.get("status"); - let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); - - let tags: Vec = row - .try_get::<_, Vec>("tags") - .unwrap_or_default() - .into_iter() - .filter(|t| !t.is_empty()) - .collect(); - - let content_md: String = row.get("content_md"); - let word_count = count_words(&content_md); - - Post { - id, - author_id: row.get("author_id"), - title: row.get("title"), - slug: row.get("slug"), - summary: row.get("summary"), - content_md, - content_html: row.get("content_html"), - status, - published_at: row.get("published_at"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - tags, - cover_image: row.get("cover_image"), - reading_time: (word_count / 200).max(1), - word_count, - toc_html: None, - prev_post: None, - next_post: None, - } -} - -#[cfg(feature = "server")] -async fn row_to_post_full(_client: &tokio_postgres::Client, row: &tokio_postgres::Row) -> Post { - let id: i32 = row.get("id"); - let role_str: String = row.get("status"); - let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); - - let tags: Vec = row - .try_get::<_, Vec>("tags") - .unwrap_or_default() - .into_iter() - .filter(|t| !t.is_empty()) - .collect(); - - let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") { - if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") { - Some(crate::models::post::PostNav { - title: prev_title, - slug: prev_slug, - }) - } else { - None - } - } else { - None - }; - - let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") { - if let Ok(next_slug) = row.try_get::<_, String>("next_slug") { - Some(crate::models::post::PostNav { - title: next_title, - slug: next_slug, - }) - } else { - None - } - } else { - None - }; - - let content_md: String = row.get("content_md"); - let word_count = count_words(&content_md); - let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); - - Post { - id, - author_id: row.get("author_id"), - title: row.get("title"), - slug: row.get("slug"), - summary: row.get("summary"), - content_md, - content_html: Some(rendered.html), - status, - published_at: row.get("published_at"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - tags, - cover_image: row.get("cover_image"), - reading_time: (word_count / 200).max(1), - word_count, - toc_html: if rendered.toc_html.is_empty() { - None - } else { - Some(rendered.toc_html) - }, - prev_post, - next_post, - } -} - -// ============================================================================ -// API Response structs -// ============================================================================ - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[allow(dead_code)] -pub struct CreatePostRequest { - pub title: String, - pub slug: Option, - pub summary: Option, - pub content_md: String, - pub status: String, - pub tags: Vec, - pub cover_image: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct CreatePostResponse { - pub success: bool, - pub message: String, - pub post_id: Option, - pub slug: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PostListResponse { - pub posts: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct TagListResponse { - pub tags: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PostStatsResponse { - pub stats: PostStats, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct SinglePostResponse { - pub post: Option, -} - -// ============================================================================ -// Server Functions -// ============================================================================ - -#[server(CreatePost, "/api")] -pub async fn create_post( - title: String, - slug: Option, - summary: Option, - content_md: String, - status: String, - tags: Vec, - cover_image: Option, -) -> Result { - let user = get_current_admin_user().await?; - - if title.trim().is_empty() { - return Ok(CreatePostResponse { - success: false, - message: "标题不能为空".to_string(), - post_id: None, - slug: None, - }); - } - - if content_md.trim().is_empty() { - return Ok(CreatePostResponse { - success: false, - message: "内容不能为空".to_string(), - post_id: None, - slug: None, - }); - } - - let base_slug = match slug { - Some(s) if !s.trim().is_empty() => { - let s = s.trim(); - if !crate::api::slug::is_valid_slug(s) { - return Ok(CreatePostResponse { - success: false, - message: "slug 格式无效,只能包含字母、数字、连字符和下划线".to_string(), - post_id: None, - slug: None, - }); - } - s.to_string() - } - _ => crate::api::slug::slugify(&title), - }; - - let mut client = get_conn().await.map_err(AppError::db_conn)?; - - let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?; - let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); - let content_html = rendered.html; - let summary = summary - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| auto_summary(&content_md)); - let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); - let cover_image = cover_image.filter(|s| !s.trim().is_empty()); - - let published_at = if post_status == PostStatus::Published { - Some(chrono::Utc::now()) - } else { - None - }; - - let tx = client.transaction().await.map_err(AppError::tx)?; - - let row = tx - .query_one( - "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, status, published_at, cover_image) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id", - &[ - &user.id, - &title.trim(), - &final_slug, - &summary, - &content_md, - &content_html, - &post_status.as_str(), - &published_at, - &cover_image, - ], - ) - .await - .map_err(AppError::tx)?; - - let post_id: i32 = row.get(0); - - let tags_cleaned: Vec = tags - .into_iter() - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - if !tags_cleaned.is_empty() { - for tag_name in &tags_cleaned { - let tag_id: i32 = { - let row = tx - .query_opt( - "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", - &[&tag_name.as_str()], - ) - .await - .map_err(AppError::tx)?; - - match row { - Some(r) => r.get(0), - None => { - let row = tx - .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) - .await - .map_err(AppError::query)?; - row.map(|r| r.get(0)).ok_or(AppError::NotFound("标签不存在"))? - } - } - }; - - tx.execute( - "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", - &[&post_id, &tag_id], - ) - .await - .map_err(AppError::tx)?; - } - } - - tx.commit().await.map_err(AppError::tx)?; - - // Invalidate caches after successful creation - #[cfg(feature = "server")] - { - cache::invalidate_post_lists(); - cache::invalidate_all_tags(); - cache::invalidate_post_stats(); - - for tag_name in &tags_cleaned { - cache::invalidate_posts_by_tag(tag_name).await; - } - } - - Ok(CreatePostResponse { - success: true, - message: "创建成功".to_string(), - post_id: Some(post_id), - slug: Some(final_slug), - }) -} - -#[server(UpdatePost, "/api")] -pub async fn update_post( - post_id: i32, - title: String, - slug: Option, - summary: Option, - content_md: String, - status: String, - tags: Vec, - cover_image: Option, -) -> Result { - let user = get_current_admin_user().await?; - - let mut client = get_conn().await.map_err(AppError::db_conn)?; - - let old_slug: Option = client - .query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id]) - .await - .map_err(AppError::query)? - .map(|r| r.get(0)); - - let exists: bool = client - .query_opt( - "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", - &[&post_id, &user.id], - ) - .await - .map_err(AppError::query)? - .is_some(); - - if !exists { - return Ok(CreatePostResponse { - success: false, - message: "文章不存在或无权限".to_string(), - post_id: None, - slug: None, - }); - } - - let base_slug = match slug { - Some(s) if !s.trim().is_empty() => { - let s = s.trim(); - if !crate::api::slug::is_valid_slug(s) { - return Ok(CreatePostResponse { - success: false, - message: "slug 格式无效".to_string(), - post_id: None, - slug: None, - }); - } - s.to_string() - } - _ => crate::api::slug::slugify(&title), - }; - - let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; - let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); - let content_html = rendered.html; - let summary = summary - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| auto_summary(&content_md)); - let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); - let cover_image = cover_image.filter(|s| !s.trim().is_empty()); - - let tx = client.transaction().await.map_err(AppError::tx)?; - - let old_tags: Vec = { - let rows = tx - .query( - "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", - &[&post_id], - ) - .await - .map_err(AppError::query)?; - rows.iter().map(|r| r.get(0)).collect() - }; - - let old_status_row = tx - .query_opt( - "SELECT status, published_at FROM posts WHERE id = $1", - &[&post_id], - ) - .await - .map_err(AppError::query)?; - - let published_at = if post_status == PostStatus::Published { - let was_published = old_status_row - .as_ref() - .map(|r| { - let s: String = r.get(0); - s == "published" - }) - .unwrap_or(false); - let existing_published: Option> = - old_status_row.as_ref().and_then(|r| r.get(1)); - - if was_published { - existing_published - } else { - Some(chrono::Utc::now()) - } - } else { - old_status_row.and_then(|r| r.get(1)) - }; - - tx.execute( - "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, status = $6, published_at = $7, cover_image = $8, updated_at = NOW() - WHERE id = $9", - &[ - &title.trim(), - &final_slug, - &summary, - &content_md, - &content_html, - &post_status.as_str(), - &published_at, - &cover_image, - &post_id, - ], - ) - .await - .map_err(AppError::tx)?; - - let tags_cleaned: Vec = tags - .into_iter() - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let tags_for_invalidation = tags_cleaned.clone(); - - tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id]) - .await - .map_err(AppError::tx)?; - - for tag_name in &tags_cleaned { - let tag_id: i32 = { - let row = tx - .query_opt( - "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", - &[&tag_name.as_str()], - ) - .await - .map_err(AppError::tx)?; - - match row { - Some(r) => r.get(0), - None => { - let row = tx - .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) - .await - .map_err(AppError::query)?; - row.map(|r| r.get(0)) - .ok_or(AppError::NotFound("标签不存在"))? - } - } - }; - - tx.execute( - "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", - &[&post_id, &tag_id], - ) - .await - .map_err(AppError::tx)?; - } - - tx.commit().await.map_err(AppError::tx)?; - - // Invalidate caches after successful update - #[cfg(feature = "server")] - { - cache::invalidate_post_lists(); - cache::invalidate_all_tags(); - cache::invalidate_post_by_slug(&final_slug).await; - cache::invalidate_post_stats(); - - // Invalidate caches for both old and new tags - let all_tags_to_invalidate: std::collections::HashSet = old_tags - .into_iter() - .chain(tags_for_invalidation.into_iter()) - .collect(); - for tag_name in &all_tags_to_invalidate { - cache::invalidate_posts_by_tag(tag_name).await; - } - - // Invalidate old slug if changed - if let Some(ref old) = old_slug { - if old != &final_slug { - cache::invalidate_post_by_slug(old).await; - } - } - } - - Ok(CreatePostResponse { - success: true, - message: "更新成功".to_string(), - post_id: Some(post_id), - slug: Some(final_slug), - }) -} - -#[server(GetPostById, "/api")] -pub async fn get_post_by_id(post_id: i32) -> Result { - let _user = get_current_admin_user().await?; - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let row = client - .query_opt( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.id = $1 AND p.deleted_at IS NULL - GROUP BY p.id", - &[&post_id], - ) - .await - .map_err(AppError::query)?; - - let post = match row { - Some(row) => Some(row_to_post_list(&client, &row).await), - None => None, - }; - - Ok(SinglePostResponse { post }) -} - -#[server(GetPostBySlug, "/api")] -pub async fn get_post_by_slug(slug: String) -> Result { - if let Some(cached) = cache::get_post_by_slug(&slug).await { - return Ok(SinglePostResponse { post: cached }); - } - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let row = client - .query_opt( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags, - prev.title as prev_title, prev.slug as prev_slug, - next.title as next_title, next.slug as next_slug - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - LEFT JOIN LATERAL ( - SELECT title, slug FROM posts - WHERE published_at < p.published_at - AND status = 'published' - AND deleted_at IS NULL - ORDER BY published_at DESC - LIMIT 1 - ) prev ON true - LEFT JOIN LATERAL ( - SELECT title, slug FROM posts - WHERE published_at > p.published_at - AND status = 'published' - AND deleted_at IS NULL - ORDER BY published_at ASC - LIMIT 1 - ) next ON true - WHERE p.slug = $1 AND p.deleted_at IS NULL - GROUP BY p.id, prev.title, prev.slug, next.title, next.slug", - &[&slug], - ) - .await - .map_err(AppError::query)?; - - let post = match row { - Some(row) => Some(row_to_post_full(&client, &row).await), - None => None, - }; - - if post.is_some() { - cache::set_post_by_slug(&slug, post.clone()).await; - } - Ok(SinglePostResponse { post }) -} - -#[server(ListPublishedPosts, "/api")] -pub async fn list_published_posts( - page: i32, - per_page: i32, -) -> Result { - let cache_key = cache::CacheKey::PublishedPosts { page, per_page }; - if let Some(cached) = cache::get_post_list(&cache_key).await { - return Ok(PostListResponse { posts: cached }); - } - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let offset = ((page - 1).max(0) as i64) * (per_page as i64); - let limit = per_page as i64; - let rows = client - .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.status = 'published' AND p.deleted_at IS NULL - GROUP BY p.id - ORDER BY p.published_at DESC - LIMIT $1 OFFSET $2", - &[&limit, &offset], - ) - .await - .map_err(AppError::query)?; - - let mut posts = Vec::new(); - for row in &rows { - posts.push(row_to_post_list(&client, row).await); - } - - cache::set_post_list(&cache_key, posts.clone()).await; - Ok(PostListResponse { posts }) -} - -#[server(ListPosts, "/api")] -pub async fn list_posts() -> Result { - let _user = get_current_admin_user().await?; - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let rows = client - .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.deleted_at IS NULL - GROUP BY p.id - ORDER BY p.created_at DESC", - &[], - ) - .await - .map_err(AppError::query)?; - - let mut posts = Vec::new(); - for row in &rows { - posts.push(row_to_post_list(&client, row).await); - } - - Ok(PostListResponse { posts }) -} - -#[server(DeletePost, "/api")] -pub async fn delete_post(post_id: i32) -> Result { - let _user = get_current_admin_user().await?; - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let result = client - .execute( - "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", - &[&post_id], - ) - .await - .map_err(AppError::query)?; - - if result == 0 { - return Ok(CreatePostResponse { - success: false, - message: "文章不存在".to_string(), - post_id: None, - slug: None, - }); - } - - // Invalidate all post-related caches - #[cfg(feature = "server")] - { - cache::invalidate_all_post_caches(); - } - - Ok(CreatePostResponse { - success: true, - message: "删除成功".to_string(), - post_id: Some(post_id), - slug: None, - }) -} - -#[server(ListTags, "/api")] -pub async fn list_tags() -> Result { - if let Some(cached) = cache::get_tag_list().await { - return Ok(TagListResponse { tags: cached }); - } - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let rows = client - .query( - "SELECT t.id, t.name, COUNT(pt.post_id) as post_count - FROM tags t - LEFT JOIN post_tags pt ON t.id = pt.tag_id - LEFT JOIN posts p ON pt.post_id = p.id AND p.deleted_at IS NULL AND p.status = 'published' - GROUP BY t.id, t.name - ORDER BY t.name", - &[], - ) - .await - .map_err(AppError::query)?; - - let tags: Vec = rows - .iter() - .map(|r| Tag { - id: r.get("id"), - name: r.get("name"), - post_count: r.get("post_count"), - }) - .collect(); - - cache::set_tag_list(tags.clone()).await; - Ok(TagListResponse { tags }) -} - -#[server(GetPostsByTag, "/api")] -pub async fn get_posts_by_tag(tag_name: String) -> Result { - if let Some(cached) = cache::get_posts_by_tag(&tag_name).await { - return Ok(PostListResponse { posts: cached }); - } - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let rows = client - .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags - FROM posts p - JOIN post_tags pt ON p.id = pt.post_id - JOIN tags t ON pt.tag_id = t.id - LEFT JOIN post_tags pt2 ON p.id = pt2.post_id - LEFT JOIN tags t2 ON pt2.tag_id = t2.id - WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL - GROUP BY p.id - ORDER BY p.published_at DESC", - &[&tag_name], - ) - .await - .map_err(AppError::query)?; - - let mut posts = Vec::new(); - for row in &rows { - posts.push(row_to_post_list(&client, row).await); - } - - cache::set_posts_by_tag(&tag_name, posts.clone()).await; - Ok(PostListResponse { posts }) -} - -#[server(GetPostStats, "/api")] -pub async fn get_post_stats() -> Result { - if let Some(cached) = cache::get_post_stats().await { - return Ok(PostStatsResponse { stats: cached }); - } - - let _user = get_current_admin_user().await?; - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let total: i64 = client - .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) - .await - .map_err(AppError::query)? - .get(0); - - let drafts: i64 = client - .query_one( - "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'", - &[], - ) - .await - .map_err(AppError::query)? - .get(0); - - let published: i64 = client - .query_one( - "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'", - &[], - ) - .await - .map_err(AppError::query)? - .get(0); - - let stats = PostStats { - total, - drafts, - published, - }; - cache::set_post_stats(stats.clone()).await; - Ok(PostStatsResponse { stats }) -} - -#[server(SearchPosts, "/api")] -pub async fn search_posts(query: String) -> Result { - let client = get_conn().await.map_err(AppError::db_conn)?; - - let q = query.trim(); - if q.is_empty() { - return Ok(PostListResponse { posts: Vec::new() }); - } - - let rows = client - .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, - p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags, - word_similarity(p.search_text, $1) AS sml - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.status = 'published' AND p.deleted_at IS NULL - AND p.search_text ILIKE '%' || $1 || '%' - GROUP BY p.id, p.search_text - ORDER BY sml DESC, p.published_at DESC - LIMIT 50", - &[&q], - ) - .await - .map_err(AppError::query)?; - - let mut posts = Vec::new(); - for row in &rows { - posts.push(row_to_post_list(&client, row).await); - } - - Ok(PostListResponse { posts }) -} diff --git a/src/api/posts/create.rs b/src/api/posts/create.rs new file mode 100644 index 0000000..9b1e1a6 --- /dev/null +++ b/src/api/posts/create.rs @@ -0,0 +1,129 @@ +use dioxus::prelude::*; + +use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; +use super::types::CreatePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; +use crate::models::post::PostStatus; + +#[server(CreatePost, "/api")] +pub async fn create_post( + title: String, + slug: Option, + summary: Option, + content_md: String, + status: String, + tags: Vec, + cover_image: Option, +) -> Result { + let user = get_current_admin_user().await?; + + if title.trim().is_empty() { + return Ok(CreatePostResponse { + success: false, + message: "标题不能为空".to_string(), + post_id: None, + slug: None, + }); + } + + if content_md.trim().is_empty() { + return Ok(CreatePostResponse { + success: false, + message: "内容不能为空".to_string(), + post_id: None, + slug: None, + }); + } + + let base_slug = match slug { + Some(ref s) if !s.trim().is_empty() => { + let s = s.trim(); + if !crate::api::slug::is_valid_slug(s) { + return Ok(CreatePostResponse { + success: false, + message: "slug 格式无效,只能包含字母、数字、连字符和下划线".to_string(), + post_id: None, + slug: None, + }); + } + s.to_string() + } + _ => crate::api::slug::slugify(&title), + }; + + #[cfg(feature = "server")] + { + let mut client = get_conn().await.map_err(AppError::db_conn)?; + + let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?; + let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); + let content_html = rendered.html; + let summary = summary + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); + let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); + let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + + let published_at = if post_status == PostStatus::Published { + Some(chrono::Utc::now()) + } else { + None + }; + + let tx = client.transaction().await.map_err(AppError::tx)?; + + let row = tx + .query_one( + "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, status, published_at, cover_image) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id", + &[ + &user.id, + &title.trim(), + &final_slug, + &summary, + &content_md, + &content_html, + &post_status.as_str(), + &published_at, + &cover_image, + ], + ) + .await + .map_err(AppError::tx)?; + + let post_id: i32 = row.get(0); + + let tags_cleaned = clean_tags(&tags); + sync_tags(&tx, post_id, &tags_cleaned).await?; + + tx.commit().await.map_err(AppError::tx)?; + + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_stats(); + + for tag_name in &tags_cleaned { + crate::cache::invalidate_posts_by_tag(tag_name).await; + } + + Ok(CreatePostResponse { + success: true, + message: "创建成功".to_string(), + post_id: Some(post_id), + slug: Some(final_slug), + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} diff --git a/src/api/posts/delete.rs b/src/api/posts/delete.rs new file mode 100644 index 0000000..7fd1a6c --- /dev/null +++ b/src/api/posts/delete.rs @@ -0,0 +1,53 @@ +use dioxus::prelude::*; + +use super::helpers::get_current_admin_user; +use super::types::CreatePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +#[server(DeletePost, "/api")] +pub async fn delete_post(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let result = client + .execute( + "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + if result == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不存在".to_string(), + post_id: None, + slug: None, + }); + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: "删除成功".to_string(), + post_id: Some(post_id), + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs new file mode 100644 index 0000000..3d05cb5 --- /dev/null +++ b/src/api/posts/helpers.rs @@ -0,0 +1,185 @@ +#[cfg(feature = "server")] +use crate::api::error::AppError; +#[cfg(feature = "server")] +use crate::models::post::{Post, PostStatus}; +#[cfg(feature = "server")] +use crate::models::user::{User, UserRole}; +#[cfg(feature = "server")] +use crate::utils::text::count_words; + +#[cfg(feature = "server")] +pub(super) async fn get_current_admin_user() -> Result { + let token = crate::auth::session::get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?; + + let user = crate::api::auth::get_user_by_token(&token) + .await + .map_err(AppError::query)? + .ok_or(AppError::Unauthorized("会话已过期"))?; + + if user.role != UserRole::Admin { + return Err(AppError::Forbidden("权限不足")); + } + + Ok(user) +} + +#[cfg(feature = "server")] +pub(super) async fn row_to_post_list( + _client: &tokio_postgres::Client, + row: &tokio_postgres::Row, +) -> Post { + let id: i32 = row.get("id"); + let role_str: String = row.get("status"); + let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); + + let tags: Vec = row + .try_get::<_, Vec>("tags") + .unwrap_or_default() + .into_iter() + .filter(|t| !t.is_empty()) + .collect(); + + let content_md: String = row.get("content_md"); + let word_count = count_words(&content_md); + + Post { + id, + author_id: row.get("author_id"), + title: row.get("title"), + slug: row.get("slug"), + summary: row.get("summary"), + content_md, + content_html: row.get("content_html"), + status, + published_at: row.get("published_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + tags, + cover_image: row.get("cover_image"), + reading_time: (word_count / 200).max(1), + word_count, + toc_html: None, + prev_post: None, + next_post: None, + } +} + +#[cfg(feature = "server")] +pub(super) async fn row_to_post_full( + _client: &tokio_postgres::Client, + row: &tokio_postgres::Row, +) -> Post { + let id: i32 = row.get("id"); + let role_str: String = row.get("status"); + let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft); + + let tags: Vec = row + .try_get::<_, Vec>("tags") + .unwrap_or_default() + .into_iter() + .filter(|t| !t.is_empty()) + .collect(); + + let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") { + if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") { + Some(crate::models::post::PostNav { + title: prev_title, + slug: prev_slug, + }) + } else { + None + } + } else { + None + }; + + let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") { + if let Ok(next_slug) = row.try_get::<_, String>("next_slug") { + Some(crate::models::post::PostNav { + title: next_title, + slug: next_slug, + }) + } else { + None + } + } else { + None + }; + + let content_md: String = row.get("content_md"); + let word_count = count_words(&content_md); + let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); + + Post { + id, + author_id: row.get("author_id"), + title: row.get("title"), + slug: row.get("slug"), + summary: row.get("summary"), + content_md, + content_html: Some(rendered.html), + status, + published_at: row.get("published_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + tags, + cover_image: row.get("cover_image"), + reading_time: (word_count / 200).max(1), + word_count, + toc_html: if rendered.toc_html.is_empty() { + None + } else { + Some(rendered.toc_html) + }, + prev_post, + next_post, + } +} + +#[cfg(feature = "server")] +pub(super) async fn sync_tags( + tx: &deadpool_postgres::Transaction<'_>, + post_id: i32, + tags: &[String], +) -> Result<(), AppError> { + for tag_name in tags { + let tag_id: i32 = { + let row = tx + .query_opt( + "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", + &[&tag_name.as_str()], + ) + .await + .map_err(AppError::tx)?; + + match row { + Some(r) => r.get(0), + None => { + let row = tx + .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) + .await + .map_err(AppError::query)?; + row.map(|r| r.get(0)) + .ok_or(AppError::NotFound("标签不存在"))? + } + } + }; + + tx.execute( + "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", + &[&post_id, &tag_id], + ) + .await + .map_err(AppError::tx)?; + } + + Ok(()) +} + +#[cfg(feature = "server")] +pub(super) fn clean_tags(tags: &[String]) -> Vec { + tags.iter() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() +} diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs new file mode 100644 index 0000000..f228b42 --- /dev/null +++ b/src/api/posts/list.rs @@ -0,0 +1,139 @@ +use dioxus::prelude::*; + +use super::helpers::{get_current_admin_user, row_to_post_list}; +use super::types::PostListResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +#[server(ListPublishedPosts, "/api")] +pub async fn list_published_posts( + page: i32, + per_page: i32, +) -> Result { + #[cfg(feature = "server")] + { + let cache_key = crate::cache::CacheKey::PublishedPosts { page, per_page }; + if let Some(cached) = crate::cache::get_post_list(&cache_key).await { + return Ok(PostListResponse { posts: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let offset = ((page - 1).max(0) as i64) * (per_page as i64); + let limit = per_page as i64; + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.status = 'published' AND p.deleted_at IS NULL + GROUP BY p.id + ORDER BY p.published_at DESC + LIMIT $1 OFFSET $2", + &[&limit, &offset], + ) + .await + .map_err(AppError::query)?; + + let mut posts = Vec::new(); + for row in &rows { + posts.push(row_to_post_list(&client, row).await); + } + + crate::cache::set_post_list(&cache_key, posts.clone()).await; + Ok(PostListResponse { posts }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostListResponse { posts: Vec::new() }) + } +} + +#[server(ListPosts, "/api")] +pub async fn list_posts() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.deleted_at IS NULL + GROUP BY p.id + ORDER BY p.created_at DESC", + &[], + ) + .await + .map_err(AppError::query)?; + + let mut posts = Vec::new(); + for row in &rows { + posts.push(row_to_post_list(&client, row).await); + } + + Ok(PostListResponse { posts }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostListResponse { posts: Vec::new() }) + } +} + +#[server(GetPostsByTag, "/api")] +pub async fn get_posts_by_tag(tag_name: String) -> Result { + #[cfg(feature = "server")] + { + if let Some(cached) = crate::cache::get_posts_by_tag(&tag_name).await { + return Ok(PostListResponse { posts: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags + FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + LEFT JOIN post_tags pt2 ON p.id = pt2.post_id + LEFT JOIN tags t2 ON pt2.tag_id = t2.id + WHERE t.name = $1 AND p.status = 'published' AND p.deleted_at IS NULL + GROUP BY p.id + ORDER BY p.published_at DESC", + &[&tag_name], + ) + .await + .map_err(AppError::query)?; + + let mut posts = Vec::new(); + for row in &rows { + posts.push(row_to_post_list(&client, row).await); + } + + crate::cache::set_posts_by_tag(&tag_name, posts.clone()).await; + Ok(PostListResponse { posts }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostListResponse { posts: Vec::new() }) + } +} diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs new file mode 100644 index 0000000..e941b58 --- /dev/null +++ b/src/api/posts/mod.rs @@ -0,0 +1,27 @@ +#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)] + +mod types; +mod helpers; +mod create; +mod update; +mod delete; +mod read; +mod list; +mod search; +mod tags; +mod stats; + +pub use types::*; +pub use create::create_post; +pub use update::update_post; +pub use delete::delete_post; +pub use read::{get_post_by_id, get_post_by_slug}; +pub use list::{list_published_posts, list_posts, get_posts_by_tag}; +pub use search::search_posts; +pub use tags::list_tags; +pub use stats::get_post_stats; + +#[cfg(feature = "server")] +pub use crate::api::markdown::render_markdown_enhanced; +#[cfg(feature = "server")] +pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify}; diff --git a/src/api/posts/read.rs b/src/api/posts/read.rs new file mode 100644 index 0000000..1b4fb2c --- /dev/null +++ b/src/api/posts/read.rs @@ -0,0 +1,106 @@ +use dioxus::prelude::*; + +use super::helpers::{get_current_admin_user, row_to_post_full, row_to_post_list}; +use super::types::SinglePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +#[server(GetPostById, "/api")] +pub async fn get_post_by_id(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.id = $1 AND p.deleted_at IS NULL + GROUP BY p.id", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let post = match row { + Some(row) => Some(row_to_post_list(&client, &row).await), + None => None, + }; + + Ok(SinglePostResponse { post }) + } + + #[cfg(not(feature = "server"))] + { + Ok(SinglePostResponse { post: None }) + } +} + +#[server(GetPostBySlug, "/api")] +pub async fn get_post_by_slug(slug: String) -> Result { + #[cfg(feature = "server")] + { + if let Some(cached) = crate::cache::get_post_by_slug(&slug).await { + return Ok(SinglePostResponse { post: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags, + prev.title as prev_title, prev.slug as prev_slug, + next.title as next_title, next.slug as next_slug + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + LEFT JOIN LATERAL ( + SELECT title, slug FROM posts + WHERE published_at < p.published_at + AND status = 'published' + AND deleted_at IS NULL + ORDER BY published_at DESC + LIMIT 1 + ) prev ON true + LEFT JOIN LATERAL ( + SELECT title, slug FROM posts + WHERE published_at > p.published_at + AND status = 'published' + AND deleted_at IS NULL + ORDER BY published_at ASC + LIMIT 1 + ) next ON true + WHERE p.slug = $1 AND p.deleted_at IS NULL + GROUP BY p.id, prev.title, prev.slug, next.title, next.slug", + &[&slug], + ) + .await + .map_err(AppError::query)?; + + let post = match row { + Some(row) => Some(row_to_post_full(&client, &row).await), + None => None, + }; + + if post.is_some() { + crate::cache::set_post_by_slug(&slug, post.clone()).await; + } + Ok(SinglePostResponse { post }) + } + + #[cfg(not(feature = "server"))] + { + Ok(SinglePostResponse { post: None }) + } +} diff --git a/src/api/posts/search.rs b/src/api/posts/search.rs new file mode 100644 index 0000000..f110899 --- /dev/null +++ b/src/api/posts/search.rs @@ -0,0 +1,52 @@ +use dioxus::prelude::*; + +use super::helpers::row_to_post_list; +use super::types::PostListResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +#[server(SearchPosts, "/api")] +pub async fn search_posts(query: String) -> Result { + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let q = query.trim(); + if q.is_empty() { + return Ok(PostListResponse { posts: Vec::new() }); + } + + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags, + word_similarity(p.search_text, $1) AS sml + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.status = 'published' AND p.deleted_at IS NULL + AND p.search_text ILIKE '%' || $1 || '%' + GROUP BY p.id, p.search_text + ORDER BY sml DESC, p.published_at DESC + LIMIT 50", + &[&q], + ) + .await + .map_err(AppError::query)?; + + let mut posts = Vec::new(); + for row in &rows { + posts.push(row_to_post_list(&client, row).await); + } + + Ok(PostListResponse { posts }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostListResponse { posts: Vec::new() }) + } +} diff --git a/src/api/posts/stats.rs b/src/api/posts/stats.rs new file mode 100644 index 0000000..d5a8186 --- /dev/null +++ b/src/api/posts/stats.rs @@ -0,0 +1,65 @@ +use dioxus::prelude::*; + +use super::helpers::get_current_admin_user; +use super::types::PostStatsResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; +use crate::models::post::PostStats; + +#[server(GetPostStats, "/api")] +pub async fn get_post_stats() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + if let Some(cached) = crate::cache::get_post_stats().await { + return Ok(PostStatsResponse { stats: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let total: i64 = client + .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) + .await + .map_err(AppError::query)? + .get(0); + + let drafts: i64 = client + .query_one( + "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'draft'", + &[], + ) + .await + .map_err(AppError::query)? + .get(0); + + let published: i64 = client + .query_one( + "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND status = 'published'", + &[], + ) + .await + .map_err(AppError::query)? + .get(0); + + let stats = PostStats { + total, + drafts, + published, + }; + crate::cache::set_post_stats(stats.clone()).await; + Ok(PostStatsResponse { stats }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostStatsResponse { + stats: PostStats { + total: 0, + drafts: 0, + published: 0, + }, + }) + } +} diff --git a/src/api/posts/tags.rs b/src/api/posts/tags.rs new file mode 100644 index 0000000..405a937 --- /dev/null +++ b/src/api/posts/tags.rs @@ -0,0 +1,49 @@ +use dioxus::prelude::*; + +use super::types::TagListResponse; +use crate::db::pool::get_conn; +use crate::models::post::Tag; +#[cfg(feature = "server")] +use crate::api::error::AppError; + +#[server(ListTags, "/api")] +pub async fn list_tags() -> Result { + #[cfg(feature = "server")] + { + if let Some(cached) = crate::cache::get_tag_list().await { + return Ok(TagListResponse { tags: cached }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = client + .query( + "SELECT t.id, t.name, COUNT(pt.post_id) as post_count + FROM tags t + LEFT JOIN post_tags pt ON t.id = pt.tag_id + LEFT JOIN posts p ON pt.post_id = p.id AND p.deleted_at IS NULL AND p.status = 'published' + GROUP BY t.id, t.name + ORDER BY t.name", + &[], + ) + .await + .map_err(AppError::query)?; + + let tags: Vec = rows + .iter() + .map(|r| Tag { + id: r.get("id"), + name: r.get("name"), + post_count: r.get("post_count"), + }) + .collect(); + + crate::cache::set_tag_list(tags.clone()).await; + Ok(TagListResponse { tags }) + } + + #[cfg(not(feature = "server"))] + { + Ok(TagListResponse { tags: Vec::new() }) + } +} diff --git a/src/api/posts/types.rs b/src/api/posts/types.rs new file mode 100644 index 0000000..769fe3d --- /dev/null +++ b/src/api/posts/types.rs @@ -0,0 +1,41 @@ +use crate::models::post::{Post, PostStats, Tag}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[allow(dead_code)] +pub struct CreatePostRequest { + pub title: String, + pub slug: Option, + pub summary: Option, + pub content_md: String, + pub status: String, + pub tags: Vec, + pub cover_image: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CreatePostResponse { + pub success: bool, + pub message: String, + pub post_id: Option, + pub slug: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PostListResponse { + pub posts: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TagListResponse { + pub tags: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PostStatsResponse { + pub stats: PostStats, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SinglePostResponse { + pub post: Option, +} diff --git a/src/api/posts/update.rs b/src/api/posts/update.rs new file mode 100644 index 0000000..555a0b9 --- /dev/null +++ b/src/api/posts/update.rs @@ -0,0 +1,182 @@ +use dioxus::prelude::*; + +use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; +use super::types::CreatePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +use crate::db::pool::get_conn; +use crate::models::post::PostStatus; + +#[server(UpdatePost, "/api")] +pub async fn update_post( + post_id: i32, + title: String, + slug: Option, + summary: Option, + content_md: String, + status: String, + tags: Vec, + cover_image: Option, +) -> Result { + let user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let mut client = get_conn().await.map_err(AppError::db_conn)?; + + let old_slug: Option = client + .query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id]) + .await + .map_err(AppError::query)? + .map(|r| r.get(0)); + + let exists: bool = client + .query_opt( + "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", + &[&post_id, &user.id], + ) + .await + .map_err(AppError::query)? + .is_some(); + + if !exists { + return Ok(CreatePostResponse { + success: false, + message: "文章不存在或无权限".to_string(), + post_id: None, + slug: None, + }); + } + + let base_slug = match slug { + Some(ref s) if !s.trim().is_empty() => { + let s = s.trim(); + if !crate::api::slug::is_valid_slug(s) { + return Ok(CreatePostResponse { + success: false, + message: "slug 格式无效".to_string(), + post_id: None, + slug: None, + }); + } + s.to_string() + } + _ => crate::api::slug::slugify(&title), + }; + + let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?; + let rendered = crate::api::markdown::render_markdown_enhanced(&content_md); + let content_html = rendered.html; + let summary = summary + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::utils::text::auto_summary(&content_md)); + let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft); + let cover_image = cover_image.filter(|s| !s.trim().is_empty()); + + let tx = client.transaction().await.map_err(AppError::tx)?; + + let old_tags: Vec = { + let rows = tx + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + rows.iter().map(|r| r.get(0)).collect() + }; + + let old_status_row = tx + .query_opt( + "SELECT status, published_at FROM posts WHERE id = $1", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let published_at = if post_status == PostStatus::Published { + let was_published = old_status_row + .as_ref() + .map(|r| { + let s: String = r.get(0); + s == "published" + }) + .unwrap_or(false); + let existing_published: Option> = + old_status_row.as_ref().and_then(|r| r.get(1)); + + if was_published { + existing_published + } else { + Some(chrono::Utc::now()) + } + } else { + old_status_row.and_then(|r| r.get(1)) + }; + + tx.execute( + "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, status = $6, published_at = $7, cover_image = $8, updated_at = NOW() + WHERE id = $9", + &[ + &title.trim(), + &final_slug, + &summary, + &content_md, + &content_html, + &post_status.as_str(), + &published_at, + &cover_image, + &post_id, + ], + ) + .await + .map_err(AppError::tx)?; + + let tags_cleaned = clean_tags(&tags); + let tags_for_invalidation = tags_cleaned.clone(); + + tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id]) + .await + .map_err(AppError::tx)?; + + sync_tags(&tx, post_id, &tags_cleaned).await?; + + tx.commit().await.map_err(AppError::tx)?; + + crate::cache::invalidate_post_lists(); + crate::cache::invalidate_all_tags(); + crate::cache::invalidate_post_by_slug(&final_slug).await; + crate::cache::invalidate_post_stats(); + + let all_tags_to_invalidate: std::collections::HashSet = old_tags + .into_iter() + .chain(tags_for_invalidation.into_iter()) + .collect(); + for tag_name in &all_tags_to_invalidate { + crate::cache::invalidate_posts_by_tag(tag_name).await; + } + + if let Some(ref old) = old_slug { + if old != &final_slug { + crate::cache::invalidate_post_by_slug(old).await; + } + } + + Ok(CreatePostResponse { + success: true, + message: "更新成功".to_string(), + post_id: Some(post_id), + slug: Some(final_slug), + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +}