Compare commits
25 Commits
d826afbe15
...
31d5a99d2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d5a99d2a | |||
| 70e61ab65d | |||
| 018b3876b7 | |||
| 2d45e0991b | |||
| dc7eb77fad | |||
| abdfd2e3f9 | |||
| 27ce878771 | |||
| 21b665f41d | |||
| fe6e5bd045 | |||
| b48590ced3 | |||
| 7bad3ce382 | |||
| 28fd38e6b8 | |||
| 3df6633428 | |||
| 48888a9886 | |||
| 08895f504e | |||
| 73aa680202 | |||
| 36055f99fa | |||
| 260b26c693 | |||
| d1c9cea683 | |||
| 07cb0a5b0d | |||
| a7fb8405c3 | |||
| a4954a6c1b | |||
| fc9fce1f4d | |||
| 472d8e91fa | |||
| 0545412cf3 |
@ -25,3 +25,9 @@ DB_POOL_SIZE=20
|
|||||||
|
|
||||||
# SSR page cache duration in seconds (default: 3600)
|
# SSR page cache duration in seconds (default: 3600)
|
||||||
SSR_CACHE_SECS=3600
|
SSR_CACHE_SECS=3600
|
||||||
|
|
||||||
|
# Image serving cache headers (hardcoded defaults)
|
||||||
|
# Uploaded image assets are served with Cache-Control: public, max-age=31536000, immutable.
|
||||||
|
# Processed variants (?w=, ?format=, etc.) are cached for 24 hours.
|
||||||
|
# To invalidate a cached raw upload, change its file path.
|
||||||
|
# To refresh a processed variant, change its processing parameters.
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 图片响应新增 `Cache-Control` 与 `ETag` 头:原始上传文件使用 `immutable, max-age=31536000`,处理变体使用 `max-age=86400`。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复 `/uploads/{*path}` 路径因缺少 `ConnectInfo` 扩展而返回 HTTP 500 的问题。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 图片解码、缩放、编码逻辑通过 `tokio::task::spawn_blocking` 移至阻塞线程池,避免阻塞异步运行时。
|
||||||
|
- 为非图片路由启用 `CompressionLayer` 与 `TimeoutLayer`,`/uploads/*` 图片路由跳过压缩与全局超时。
|
||||||
|
|
||||||
## [0.2.0] - 2026-06-10
|
## [0.2.0] - 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
118
Cargo.lock
generated
118
Cargo.lock
generated
@ -17,6 +17,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@ -77,6 +92,18 @@ version = "0.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4"
|
checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-lock"
|
name = "async-lock"
|
||||||
version = "3.4.2"
|
version = "3.4.2"
|
||||||
@ -318,6 +345,27 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@ -358,6 +406,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -474,6 +524,26 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
"zstd",
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -2408,6 +2478,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.99"
|
||||||
@ -3048,6 +3128,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@ -4322,6 +4408,7 @@ version = "0.6.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -4340,6 +4427,7 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -5312,7 +5400,6 @@ dependencies = [
|
|||||||
"governor",
|
"governor",
|
||||||
"hex",
|
"hex",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
|
||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"lol_html",
|
"lol_html",
|
||||||
@ -5328,6 +5415,7 @@ dependencies = [
|
|||||||
"syntect",
|
"syntect",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
"tokio-postgres",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
@ -5474,6 +5562,34 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@ -21,7 +21,8 @@ tracing-subscriber = { version = "0.3", optional = true }
|
|||||||
rand = { version = "0.8", features = ["getrandom"], optional = true }
|
rand = { version = "0.8", features = ["getrandom"], optional = true }
|
||||||
http = { version = "1", optional = true }
|
http = { version = "1", optional = true }
|
||||||
axum = { version = "0.8", optional = true, features = ["multipart"] }
|
axum = { version = "0.8", optional = true, features = ["multipart"] }
|
||||||
http-body-util = { version = "0.1", optional = true }
|
tower-http = { version = "0.6", optional = true, features = ["compression-full", "timeout", "trace"] }
|
||||||
|
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
sha2 = { version = "0.10", optional = true }
|
sha2 = { version = "0.10", optional = true }
|
||||||
hex = { version = "0.4", optional = true }
|
hex = { version = "0.4", optional = true }
|
||||||
@ -72,10 +73,10 @@ server = [
|
|||||||
"dep:dotenvy",
|
"dep:dotenvy",
|
||||||
"dep:tracing",
|
"dep:tracing",
|
||||||
"dep:tracing-subscriber",
|
"dep:tracing-subscriber",
|
||||||
"dep:http-body-util",
|
|
||||||
"dep:lol_html",
|
"dep:lol_html",
|
||||||
"dep:syntect",
|
"dep:syntect",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
|
"dep:tower-http",
|
||||||
"dep:image",
|
"dep:image",
|
||||||
"dep:zenwebp",
|
"dep:zenwebp",
|
||||||
"dep:moka",
|
"dep:moka",
|
||||||
|
|||||||
@ -73,7 +73,6 @@ Replace `create.rs:190-221` with:
|
|||||||
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
||||||
|
|
||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
|
||||||
|
|
||||||
Ok(CommentResponse {
|
Ok(CommentResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@ -181,9 +180,9 @@ mod check;
|
|||||||
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
pub use create::create_comment;
|
pub use create::create_comment;
|
||||||
pub use read::{get_comments, get_comment_count};
|
pub use read::get_comments;
|
||||||
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status};
|
||||||
pub use list::{get_pending_comments, get_pending_count, get_all_comments};
|
pub use list::{get_pending_count, get_all_comments};
|
||||||
pub use check::check_pending_status;
|
pub use check::check_pending_status;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
|||||||
@ -21,7 +21,6 @@ CREATE TABLE IF NOT EXISTS posts (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
|
CREATE INDEX idx_posts_status_published ON posts(status, published_at DESC) WHERE deleted_at IS NULL;
|
||||||
CREATE INDEX idx_posts_slug ON posts(slug) WHERE deleted_at IS NULL;
|
|
||||||
CREATE UNIQUE INDEX idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
|
CREATE UNIQUE INDEX idx_posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
-- 补充索引(已在 002_posts.sql 中创建的索引不再重复定义)
|
-- 补充索引(已在 002_posts.sql 中创建的索引不再重复定义)
|
||||||
|
|
||||||
-- 标签名称查询
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
|
|
||||||
|
|
||||||
-- 文章标签关联查询(tag 方向)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id);
|
|
||||||
|
|
||||||
-- 用户会话查询
|
-- 用户会话查询
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
|||||||
6
migrations/009_cleanup_duplicate_indexes.sql
Normal file
6
migrations/009_cleanup_duplicate_indexes.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- 清理早期迁移重复创建的索引
|
||||||
|
-- 这些索引在新数据库中已不会再被创建
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_posts_slug;
|
||||||
|
DROP INDEX IF EXISTS idx_tags_name;
|
||||||
|
DROP INDEX IF EXISTS idx_post_tags_tag_id;
|
||||||
@ -275,9 +275,8 @@ pub async fn create_comment(
|
|||||||
// 根据邮箱生成 Gravatar 头像链接。
|
// 根据邮箱生成 Gravatar 头像链接。
|
||||||
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email);
|
||||||
|
|
||||||
// 新评论可能影响文章评论列表、评论计数与待审核计数,清空相关缓存。
|
// 新评论可能影响文章评论列表与待审核计数,清空相关缓存。
|
||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
|
||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
|
|
||||||
Ok(CommentResponse {
|
Ok(CommentResponse {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
//! 评论列表查询接口:后台管理用的待审核列表、全部评论列表与待审核计数。
|
//! 评论列表查询接口:后台管理用的全部评论列表与待审核计数。
|
||||||
//!
|
//!
|
||||||
//! 所有接口均需管理员身份,Dioxus server function 注册在 `/api` 路径下。
|
//! 所有接口均需管理员身份,Dioxus server function 注册在 `/api` 路径下。
|
||||||
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
@ -6,56 +6,6 @@
|
|||||||
use crate::api::comments::types::*;
|
use crate::api::comments::types::*;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
/// 获取待审核评论分页列表。
|
|
||||||
///
|
|
||||||
/// 每页 20 条,按创建时间倒序排列,并返回总数用于分页。
|
|
||||||
#[server(GetPendingComments, "/api")]
|
|
||||||
pub async fn get_pending_comments(page: i32) -> Result<PendingCommentsResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::auth::get_current_admin_user;
|
|
||||||
use crate::api::comments::helpers::row_to_admin_comment;
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
|
|
||||||
let _admin = get_current_admin_user().await?;
|
|
||||||
|
|
||||||
let page = page.max(1);
|
|
||||||
let per_page: i64 = 20;
|
|
||||||
let offset: i64 = (page as i64 - 1) * per_page;
|
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
let total: i64 = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT COUNT(*) FROM comments WHERE status = 'pending' AND deleted_at IS NULL",
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
let rows = client
|
|
||||||
.query(
|
|
||||||
"SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, c.author_email, \
|
|
||||||
c.author_url, c.content_md, c.status, c.created_at, \
|
|
||||||
p.title as post_title, p.slug as post_slug \
|
|
||||||
FROM comments c JOIN posts p ON c.post_id = p.id \
|
|
||||||
WHERE c.status = 'pending' AND c.deleted_at IS NULL \
|
|
||||||
ORDER BY c.created_at DESC LIMIT $1 OFFSET $2",
|
|
||||||
&[&per_page, &offset],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?;
|
|
||||||
|
|
||||||
let comments = rows.iter().map(row_to_admin_comment).collect();
|
|
||||||
|
|
||||||
Ok(PendingCommentsResponse { comments, total })
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取待审核评论总数。
|
/// 获取待审核评论总数。
|
||||||
///
|
///
|
||||||
/// 优先从缓存读取,未命中时查询数据库并写入缓存。
|
/// 优先从缓存读取,未命中时查询数据库并写入缓存。
|
||||||
|
|||||||
@ -21,15 +21,9 @@ pub use create::create_comment;
|
|||||||
/// 获取全部评论分页列表。
|
/// 获取全部评论分页列表。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use list::get_all_comments;
|
pub use list::get_all_comments;
|
||||||
/// 获取待审核评论分页列表。
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use list::get_pending_comments;
|
|
||||||
/// 获取待审核评论总数。
|
/// 获取待审核评论总数。
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use list::get_pending_count;
|
pub use list::get_pending_count;
|
||||||
/// 获取指定文章的已审核评论数量。
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use read::get_comment_count;
|
|
||||||
/// 获取指定文章的已审核评论列表。
|
/// 获取指定文章的已审核评论列表。
|
||||||
pub use read::get_comments;
|
pub use read::get_comments;
|
||||||
/// 评论 API 的请求与响应数据结构。
|
/// 评论 API 的请求与响应数据结构。
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
//! 前端评论读取接口:已审核评论列表与评论计数。
|
//! 前端评论读取接口:已审核评论列表。
|
||||||
//!
|
//!
|
||||||
//! 结果按文章 id 缓存,Dioxus server function 注册在 `/api` 路径下。
|
//! 结果按文章 id 缓存,Dioxus server function 注册在 `/api` 路径下。
|
||||||
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
//! 仅在 `feature = "server"` 启用的服务端构建中查询数据库。
|
||||||
@ -52,36 +52,3 @@ pub async fn get_comments(post_id: i32) -> Result<CommentTreeResponse, ServerFnE
|
|||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定文章的已审核评论数量。
|
|
||||||
///
|
|
||||||
/// 优先命中缓存;未命中时执行 COUNT 查询并写入缓存。
|
|
||||||
#[server(GetCommentCount, "/api")]
|
|
||||||
pub async fn get_comment_count(post_id: i32) -> Result<CommentCountResponse, ServerFnError> {
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
{
|
|
||||||
use crate::api::error::AppError;
|
|
||||||
use crate::cache;
|
|
||||||
use crate::db::pool::get_conn;
|
|
||||||
|
|
||||||
if let Some(cached) = cache::get_comment_count(post_id).await {
|
|
||||||
return Ok(CommentCountResponse { count: cached });
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
|
||||||
|
|
||||||
let count: i64 = client
|
|
||||||
.query_one(
|
|
||||||
"SELECT COUNT(*) FROM comments WHERE post_id = $1 AND status = 'approved' AND deleted_at IS NULL",
|
|
||||||
&[&post_id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::query)?
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
cache::set_comment_count(post_id, count).await;
|
|
||||||
|
|
||||||
Ok(CommentCountResponse { count })
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -32,30 +32,6 @@ pub struct CommentTreeResponse {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 评论计数响应。
|
|
||||||
///
|
|
||||||
/// 此类型仅在服务端函数体中构造;保留 `#[allow(dead_code)]` 以避免 WASM 构建中
|
|
||||||
/// 因函数体被剥离而产生的未使用警告。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct CommentCountResponse {
|
|
||||||
/// 评论数量。
|
|
||||||
pub count: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 待审核评论列表响应。
|
|
||||||
///
|
|
||||||
/// 当前前端未直接调用 `get_pending_comments`,此类型仅在服务端函数体中构造;
|
|
||||||
/// 保留 `#[allow(dead_code)]` 以避免 WASM 构建中因函数体被剥离而产生的未使用警告。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct PendingCommentsResponse {
|
|
||||||
/// 待审核评论列表。
|
|
||||||
pub comments: Vec<AdminComment>,
|
|
||||||
/// 总数。
|
|
||||||
pub total: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 全部评论列表响应。
|
/// 全部评论列表响应。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AllCommentsResponse {
|
pub struct AllCommentsResponse {
|
||||||
|
|||||||
@ -70,7 +70,6 @@ pub async fn approve_comment(id: i64) -> Result<CommentResponse, ServerFnError>
|
|||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
|
||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
|
|
||||||
Ok(CommentResponse {
|
Ok(CommentResponse {
|
||||||
@ -124,7 +123,6 @@ pub async fn spam_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
|||||||
|
|
||||||
if old_status == "approved" {
|
if old_status == "approved" {
|
||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
|
||||||
}
|
}
|
||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
}
|
}
|
||||||
@ -178,7 +176,6 @@ pub async fn trash_comment(id: i64) -> Result<CommentResponse, ServerFnError> {
|
|||||||
.map_err(AppError::query)?;
|
.map_err(AppError::query)?;
|
||||||
|
|
||||||
cache::invalidate_comments_by_post(post_id).await;
|
cache::invalidate_comments_by_post(post_id).await;
|
||||||
cache::invalidate_comment_count(post_id).await;
|
|
||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,7 +263,6 @@ pub async fn batch_update_comment_status(
|
|||||||
cache::invalidate_pending_count().await;
|
cache::invalidate_pending_count().await;
|
||||||
for pid in post_ids {
|
for pid in post_ids {
|
||||||
cache::invalidate_comments_by_post(pid).await;
|
cache::invalidate_comments_by_post(pid).await;
|
||||||
cache::invalidate_comment_count(pid).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BatchStatusResponse {
|
Ok(BatchStatusResponse {
|
||||||
|
|||||||
288
src/api/image.rs
288
src/api/image.rs
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Path, Query},
|
extract::{ConnectInfo, Extension, Path, Query},
|
||||||
http::{header, HeaderValue, StatusCode},
|
http::{header, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@ -20,6 +20,25 @@ use serde::Deserialize;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn etag_for(data: &[u8]) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let hash = Sha256::digest(data);
|
||||||
|
format!("\"{}\"", hex::encode(&hash[..16]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn etag_matches(if_none_match: &str, etag: &str) -> bool {
|
||||||
|
let trimmed = if_none_match.trim();
|
||||||
|
if trimmed == "*" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().trim_start_matches("W/"))
|
||||||
|
.any(|candidate| candidate == etag)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub const MAX_IMAGE_DIMENSION: u32 = 4096;
|
pub const MAX_IMAGE_DIMENSION: u32 = 4096;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -165,6 +184,44 @@ fn content_type(format: image::ImageFormat) -> HeaderValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn image_response(
|
||||||
|
data: Vec<u8>,
|
||||||
|
content_type: HeaderValue,
|
||||||
|
cache_control: &'static str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let etag = etag_for(&data);
|
||||||
|
|
||||||
|
if let Some(if_none_match) = headers
|
||||||
|
.get(header::IF_NONE_MATCH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
if etag_matches(if_none_match, &etag) {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_MODIFIED,
|
||||||
|
[
|
||||||
|
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
||||||
|
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
(header::CACHE_CONTROL, HeaderValue::from_static(cache_control)),
|
||||||
|
(header::ETAG, HeaderValue::from_str(&etag).unwrap()),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
|
fn check_image_dimensions(width: u32, height: u32) -> Result<(), StatusCode> {
|
||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 {
|
||||||
@ -267,6 +324,43 @@ fn process_image(
|
|||||||
Ok((buf.into_inner(), ct))
|
Ok((buf.into_inner(), ct))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn process_image_blocking(
|
||||||
|
data: Vec<u8>,
|
||||||
|
params: ImageParams,
|
||||||
|
path: String,
|
||||||
|
) -> Result<(Vec<u8>, HeaderValue), StatusCode> {
|
||||||
|
let original_format = detect_format(&path);
|
||||||
|
|
||||||
|
let img = if original_format == image::ImageFormat::WebP {
|
||||||
|
match crate::webp::decode(&data) {
|
||||||
|
Ok(img) => {
|
||||||
|
check_image_dimensions(img.width(), img.height())?;
|
||||||
|
img
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("WebP decode failed ({}), returning raw bytes", e);
|
||||||
|
let ct = content_type(original_format);
|
||||||
|
return Ok((data, ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let cursor = std::io::Cursor::new(&data);
|
||||||
|
let mut reader = image::ImageReader::with_format(cursor, original_format);
|
||||||
|
reader.limits(image_reader_limits());
|
||||||
|
match reader.decode() {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Image decode failed ({}), returning raw bytes", e);
|
||||||
|
let ct = content_type(original_format);
|
||||||
|
return Ok((data, ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process_image(img, ¶ms, original_format)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
fn is_path_safe(path: &str) -> bool {
|
fn is_path_safe(path: &str) -> bool {
|
||||||
// Reject paths with parent directory references or null bytes
|
// Reject paths with parent directory references or null bytes
|
||||||
@ -333,12 +427,13 @@ async fn write_disk_cache(cache_key: &str, cached: &CachedImage) {
|
|||||||
/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 →
|
/// 依次执行:限流 → 路径安全校验 → 参数校验 → 无参数时直接返回原文件 →
|
||||||
/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
|
/// 查询内存缓存 → 查询磁盘缓存 → 读取并解码 → 处理 → 写入两级缓存 → 返回。
|
||||||
pub async fn serve_image(
|
pub async fn serve_image(
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||||
Path(path): Path<String>,
|
Path(path): Path<String>,
|
||||||
Query(params): Query<ImageParams>,
|
Query(params): Query<ImageParams>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, Some(addr));
|
let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
|
||||||
|
let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer);
|
||||||
if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
|
if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
|
||||||
return status.into_response();
|
return status.into_response();
|
||||||
}
|
}
|
||||||
@ -354,12 +449,12 @@ pub async fn serve_image(
|
|||||||
return status.into_response();
|
return status.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// No processing params: return raw file
|
// No processing params: return raw file with long-lived cache headers.
|
||||||
if params.is_empty() {
|
if params.is_empty() {
|
||||||
return match tokio::fs::read(&file_path).await {
|
return match tokio::fs::read(&file_path).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let ct = content_type(detect_format(&path));
|
let ct = content_type(detect_format(&path));
|
||||||
(StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response()
|
image_response(data, ct, "public, max-age=31536000, immutable", &headers)
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
};
|
};
|
||||||
@ -367,12 +462,7 @@ pub async fn serve_image(
|
|||||||
|
|
||||||
let cache_key = params.cache_key(&path);
|
let cache_key = params.cache_key(&path);
|
||||||
if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
|
if let Some(cached) = IMAGE_CACHE.get(&cache_key).await {
|
||||||
return (
|
return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers);
|
||||||
StatusCode::OK,
|
|
||||||
[(header::CONTENT_TYPE, cached.content_type)],
|
|
||||||
cached.data,
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cached) = read_disk_cache(&cache_key).await {
|
if let Some(cached) = read_disk_cache(&cache_key).await {
|
||||||
@ -385,12 +475,7 @@ pub async fn serve_image(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
return (
|
return image_response(cached.data, cached.content_type, "public, max-age=86400", &headers);
|
||||||
StatusCode::OK,
|
|
||||||
[(header::CONTENT_TYPE, cached.content_type)],
|
|
||||||
cached.data,
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = match tokio::fs::read(&file_path).await {
|
let data = match tokio::fs::read(&file_path).await {
|
||||||
@ -398,40 +483,24 @@ pub async fn serve_image(
|
|||||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// WebP 解码使用 zenwebp,其它格式使用 image crate。
|
// Offload decode + resize + encode to the blocking pool so the async
|
||||||
let original_format = detect_format(&path);
|
// runtime stays responsive to other requests.
|
||||||
let img = if original_format == image::ImageFormat::WebP {
|
let data_for_blocking = data.clone();
|
||||||
match crate::webp::decode(&data) {
|
let path_for_blocking = path.clone();
|
||||||
Ok(img) => {
|
let params_for_blocking = params.clone();
|
||||||
if let Err(status) = check_image_dimensions(img.width(), img.height()) {
|
let (processed, content_type) =
|
||||||
return status.into_response();
|
match tokio::task::spawn_blocking(move || {
|
||||||
}
|
process_image_blocking(data_for_blocking, params_for_blocking, path_for_blocking)
|
||||||
img
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(r)) => r,
|
||||||
|
Ok(Err(status)) => return status.into_response(),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!("Image processing task panicked");
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
};
|
||||||
tracing::warn!("WebP decode failed ({}), returning raw bytes", e);
|
|
||||||
let ct = content_type(original_format);
|
|
||||||
return (StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let cursor = std::io::Cursor::new(&data);
|
|
||||||
let mut reader = image::ImageReader::with_format(cursor, original_format);
|
|
||||||
reader.limits(image_reader_limits());
|
|
||||||
match reader.decode() {
|
|
||||||
Ok(img) => img,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Image decode failed ({}), returning raw bytes", e);
|
|
||||||
let ct = content_type(original_format);
|
|
||||||
return (StatusCode::OK, [(header::CONTENT_TYPE, ct)], data).into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (processed, content_type) = match process_image(img, ¶ms, original_format) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(status) => return status.into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let cached = CachedImage {
|
let cached = CachedImage {
|
||||||
data: processed.clone(),
|
data: processed.clone(),
|
||||||
@ -440,12 +509,7 @@ pub async fn serve_image(
|
|||||||
let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
|
let _ = IMAGE_CACHE.insert(cache_key.clone(), cached.clone()).await;
|
||||||
write_disk_cache(&cache_key, &cached).await;
|
write_disk_cache(&cache_key, &cached).await;
|
||||||
|
|
||||||
(
|
image_response(processed, content_type, "public, max-age=86400", &headers)
|
||||||
StatusCode::OK,
|
|
||||||
[(header::CONTENT_TYPE, content_type)],
|
|
||||||
processed,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
#[cfg(all(test, feature = "server"))]
|
||||||
@ -672,4 +736,116 @@ mod tests {
|
|||||||
let base2 = disk_cache_base("path|w=1200");
|
let base2 = disk_cache_base("path|w=1200");
|
||||||
assert_ne!(base1, base2);
|
assert_ne!(base1, base2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_image_blocking_resizes_png() {
|
||||||
|
let img = image::DynamicImage::new_rgb8(100, 100);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||||
|
let data = buf.into_inner();
|
||||||
|
|
||||||
|
let params = ImageParams {
|
||||||
|
w: Some(50),
|
||||||
|
format: Some("webp".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (out, ct) = process_image_blocking(data, params, "test.png".to_string()).unwrap();
|
||||||
|
assert!(!out.is_empty());
|
||||||
|
assert_eq!(ct, HeaderValue::from_static("image/webp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_response_includes_cache_headers() {
|
||||||
|
let resp = image_response(
|
||||||
|
vec![1, 2, 3],
|
||||||
|
HeaderValue::from_static("image/webp"),
|
||||||
|
"public, max-age=86400",
|
||||||
|
&HeaderMap::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let headers = resp.headers();
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(header::CONTENT_TYPE).unwrap(),
|
||||||
|
"image/webp"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(header::CACHE_CONTROL).unwrap(),
|
||||||
|
"public, max-age=86400"
|
||||||
|
);
|
||||||
|
assert!(headers.get(header::ETAG).unwrap().to_str().unwrap().starts_with('"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_response_returns_304_when_etag_matches() {
|
||||||
|
let data = vec![1, 2, 3];
|
||||||
|
let etag = etag_for(&data);
|
||||||
|
let mut req_headers = HeaderMap::new();
|
||||||
|
req_headers.insert(
|
||||||
|
header::IF_NONE_MATCH,
|
||||||
|
HeaderValue::from_str(&etag).unwrap(),
|
||||||
|
);
|
||||||
|
let resp = image_response(
|
||||||
|
data,
|
||||||
|
HeaderValue::from_static("image/webp"),
|
||||||
|
"public, max-age=86400",
|
||||||
|
&req_headers,
|
||||||
|
);
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
|
||||||
|
let headers = resp.headers();
|
||||||
|
assert_eq!(headers.get(header::ETAG).unwrap(), etag.as_str());
|
||||||
|
assert_eq!(headers.get(header::CONTENT_TYPE).unwrap(), "image/webp");
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(header::CACHE_CONTROL).unwrap(),
|
||||||
|
"public, max-age=86400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_matches_single() {
|
||||||
|
assert!(etag_matches("\"abc\"", "\"abc\""));
|
||||||
|
assert!(!etag_matches("\"abc\"", "\"def\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_matches_list() {
|
||||||
|
assert!(etag_matches("\"abc\", \"def\"", "\"def\""));
|
||||||
|
assert!(!etag_matches("\"abc\", \"def\"", "\"ghi\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_matches_weak_prefix() {
|
||||||
|
assert!(etag_matches("W/\"abc\"", "\"abc\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_matches_wildcard() {
|
||||||
|
assert!(etag_matches("*", "\"anything\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_response_raw_file_is_immutable() {
|
||||||
|
let resp = image_response(
|
||||||
|
vec![1, 2, 3],
|
||||||
|
HeaderValue::from_static("image/jpeg"),
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
&HeaderMap::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let cache_control = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert!(cache_control.contains("immutable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etag_for_same_data_is_stable() {
|
||||||
|
let a = etag_for(b"hello");
|
||||||
|
let b = etag_for(b"hello");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_ne!(a, etag_for(b"world"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/cache.rs
65
src/cache.rs
@ -44,10 +44,6 @@ const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
const TTL_COMMENTS: Duration = Duration::from_secs(60);
|
const TTL_COMMENTS: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// 评论数量缓存 TTL:60 秒。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
const TTL_COMMENT_COUNT: Duration = Duration::from_secs(60);
|
|
||||||
|
|
||||||
/// 待审核评论数量缓存 TTL:10 秒,因管理后台需要较实时数据。
|
/// 待审核评论数量缓存 TTL:10 秒,因管理后台需要较实时数据。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
|
const TTL_PENDING_COUNT: Duration = Duration::from_secs(10);
|
||||||
@ -74,8 +70,6 @@ pub enum CacheKey {
|
|||||||
PostStats,
|
PostStats,
|
||||||
/// 某篇文章下的评论列表。
|
/// 某篇文章下的评论列表。
|
||||||
CommentsByPost { post_id: i32 },
|
CommentsByPost { post_id: i32 },
|
||||||
/// 某篇文章的评论数量。
|
|
||||||
CommentCount { post_id: i32 },
|
|
||||||
/// 待审核评论总数。
|
/// 待审核评论总数。
|
||||||
PendingCommentCount,
|
PendingCommentCount,
|
||||||
}
|
}
|
||||||
@ -149,10 +143,6 @@ static TAG_POSTS_CACHE: LazyLock<PostListCache> = LazyLock::new(|| {
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
|
pub type CommentListCache = Cache<CacheKey, Vec<PublicComment>>;
|
||||||
|
|
||||||
/// 评论数量缓存类型。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub type CommentCountCache = Cache<CacheKey, i64>;
|
|
||||||
|
|
||||||
/// 全局评论列表缓存实例,最大容量 200。
|
/// 全局评论列表缓存实例,最大容量 200。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
|
static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
|
||||||
@ -162,18 +152,9 @@ static COMMENT_CACHE: LazyLock<CommentListCache> = LazyLock::new(|| {
|
|||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 全局评论数量缓存实例,最大容量 200。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static COMMENT_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
|
|
||||||
Cache::builder()
|
|
||||||
.max_capacity(200)
|
|
||||||
.time_to_live(TTL_COMMENT_COUNT)
|
|
||||||
.build()
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 全局待审核评论数量缓存实例,最大容量 10。
|
/// 全局待审核评论数量缓存实例,最大容量 10。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
static PENDING_COUNT_CACHE: LazyLock<CommentCountCache> = LazyLock::new(|| {
|
static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
|
||||||
Cache::builder()
|
Cache::builder()
|
||||||
.max_capacity(10)
|
.max_capacity(10)
|
||||||
.time_to_live(TTL_PENDING_COUNT)
|
.time_to_live(TTL_PENDING_COUNT)
|
||||||
@ -333,22 +314,6 @@ pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按文章主键读取评论数量缓存。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn get_comment_count(post_id: i32) -> Option<i64> {
|
|
||||||
COMMENT_COUNT_CACHE
|
|
||||||
.get(&CacheKey::CommentCount { post_id })
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按文章主键写入评论数量缓存。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn set_comment_count(post_id: i32, count: i64) {
|
|
||||||
let _ = COMMENT_COUNT_CACHE
|
|
||||||
.insert(CacheKey::CommentCount { post_id }, count)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取待审核评论总数缓存。
|
/// 读取待审核评论总数缓存。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub async fn get_pending_count() -> Option<i64> {
|
pub async fn get_pending_count() -> Option<i64> {
|
||||||
@ -373,14 +338,6 @@ pub async fn invalidate_comments_by_post(post_id: i32) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按文章主键失效评论数量缓存。
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
pub async fn invalidate_comment_count(post_id: i32) {
|
|
||||||
COMMENT_COUNT_CACHE
|
|
||||||
.invalidate(&CacheKey::CommentCount { post_id })
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 失效待审核评论总数缓存。
|
/// 失效待审核评论总数缓存。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub async fn invalidate_pending_count() {
|
pub async fn invalidate_pending_count() {
|
||||||
@ -554,16 +511,6 @@ mod tests {
|
|||||||
assert_eq!(cached.unwrap().len(), 1);
|
assert_eq!(cached.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn comment_count_cache_roundtrip() {
|
|
||||||
set_comment_count(42, 15).await;
|
|
||||||
let cached = get_comment_count(42).await;
|
|
||||||
|
|
||||||
assert!(cached.is_some());
|
|
||||||
assert_eq!(cached.unwrap(), 15);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn pending_count_cache_roundtrip() {
|
async fn pending_count_cache_roundtrip() {
|
||||||
@ -584,16 +531,6 @@ mod tests {
|
|||||||
assert!(get_comments_by_post(99).await.is_none());
|
assert!(get_comments_by_post(99).await.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn comment_count_invalidation() {
|
|
||||||
set_comment_count(99, 5).await;
|
|
||||||
assert!(get_comment_count(99).await.is_some());
|
|
||||||
|
|
||||||
invalidate_comment_count(99).await;
|
|
||||||
assert!(get_comment_count(99).await.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn pending_count_invalidation() {
|
async fn pending_count_invalidation() {
|
||||||
|
|||||||
55
src/main.rs
55
src/main.rs
@ -53,6 +53,10 @@ fn main() {
|
|||||||
// 启动 Dioxus 服务端,返回构建好的 Axum Router
|
// 启动 Dioxus 服务端,返回构建好的 Axum Router
|
||||||
dioxus::server::serve(|| async move {
|
dioxus::server::serve(|| async move {
|
||||||
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
|
use dioxus::server::{axum, DioxusRouterExt, ServeConfig};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::timeout::TimeoutLayer;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
// 启动后台定时任务:IP 信息清理
|
// 启动后台定时任务:IP 信息清理
|
||||||
tokio::spawn(async {
|
tokio::spawn(async {
|
||||||
@ -81,26 +85,47 @@ fn main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 自定义 API 路由:图片上传,设置最大请求体大小为 10 MiB
|
// 自定义 API 路由:图片上传(大文件,需要更长超时)
|
||||||
// (包含 multipart 开销,实际文件限制由 upload_image 内 MAX_FILE_SIZE 控制)
|
let upload_route = axum::Router::new()
|
||||||
let api_routes = axum::Router::new().route(
|
.route(
|
||||||
"/api/upload",
|
"/api/upload",
|
||||||
axum::routing::post(crate::api::upload::upload_image)
|
axum::routing::post(crate::api::upload::upload_image),
|
||||||
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)),
|
)
|
||||||
);
|
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||||
|
.layer(TimeoutLayer::with_status_code(
|
||||||
// 静态资源路由:图片文件服务,支持动态裁剪/旋转/格式转换
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
let static_routes = axum::Router::new().route(
|
Duration::from_secs(300),
|
||||||
"/uploads/{*path}",
|
));
|
||||||
axum::routing::get(crate::api::image::serve_image),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
||||||
let dioxus_app =
|
let dioxus_app =
|
||||||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||||||
|
|
||||||
// 合并三条路由:自定义 API、静态资源、Dioxus 主应用
|
// 合并 Dioxus + 压缩/30s 超时中间件
|
||||||
let router = api_routes.merge(static_routes).merge(dioxus_app);
|
let app_routes = dioxus_app
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(TimeoutLayer::with_status_code(
|
||||||
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 静态资源路由:图片文件服务。
|
||||||
|
// 注意:axum 0.8 没有 ConnectInfoLayer,且 dioxus::server::serve 不会把
|
||||||
|
// ConnectInfo 扩展传播到手动 merge 的路由,所以 serve_image 使用
|
||||||
|
// Option<Extension<ConnectInfo<SocketAddr>>> 优雅降级。生产环境应在反向代理后
|
||||||
|
// 部署并配置 TRUSTED_PROXY_COUNT,使限流能拿到真实客户端 IP。
|
||||||
|
let static_routes = axum::Router::new()
|
||||||
|
.route(
|
||||||
|
"/uploads/{*path}",
|
||||||
|
axum::routing::get(crate::api::image::serve_image),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/uploads",
|
||||||
|
axum::routing::get(|| async { StatusCode::NOT_FOUND }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 合并:upload 路由保持自己独立的 300s 超时;app routes 加压缩/30s;static routes 无任何中间件
|
||||||
|
let router = upload_route.merge(app_routes).merge(static_routes);
|
||||||
|
|
||||||
Ok(router)
|
Ok(router)
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user