chore: checkpoint admin editor and perf work

This commit is contained in:
2026-03-31 00:12:02 +08:00
parent 92a85eef20
commit 99b308e800
45 changed files with 7265 additions and 833 deletions

View File

@@ -1,3 +1,4 @@
use axum::extract::{Multipart, Query};
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
@@ -14,7 +15,7 @@ use crate::{
site_settings::{self, SiteSettingsPayload},
},
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
services::{ai, content},
services::{ai, analytics, content, storage},
};
#[derive(Clone, Debug, Deserialize)]
@@ -130,6 +131,10 @@ pub struct AdminSiteSettingsResponse {
pub ai_api_base: Option<String>,
pub ai_api_key: Option<String>,
pub ai_chat_model: Option<String>,
pub ai_image_provider: Option<String>,
pub ai_image_api_base: Option<String>,
pub ai_image_api_key: Option<String>,
pub ai_image_model: Option<String>,
pub ai_providers: Vec<site_settings::AiProviderConfig>,
pub ai_active_provider_id: Option<String>,
pub ai_embedding_model: Option<String>,
@@ -139,6 +144,12 @@ pub struct AdminSiteSettingsResponse {
pub ai_last_indexed_at: Option<String>,
pub ai_chunks_count: u64,
pub ai_local_embedding: String,
pub media_storage_provider: Option<String>,
pub media_r2_account_id: Option<String>,
pub media_r2_bucket: Option<String>,
pub media_r2_public_base_url: Option<String>,
pub media_r2_access_key_id: Option<String>,
pub media_r2_secret_access_key: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -160,6 +171,67 @@ pub struct AdminAiProviderTestResponse {
pub reply_preview: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminAiImageProviderTestRequest {
pub provider: String,
pub api_base: String,
pub api_key: String,
pub image_model: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiImageProviderTestResponse {
pub provider: String,
pub endpoint: String,
pub image_model: String,
pub result_preview: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminImageUploadResponse {
pub url: String,
pub key: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminR2ConnectivityResponse {
pub bucket: String,
pub public_base_url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaObjectResponse {
pub key: String,
pub url: String,
pub size_bytes: i64,
pub last_modified: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaListResponse {
pub provider: String,
pub bucket: String,
pub public_base_url: String,
pub items: Vec<AdminMediaObjectResponse>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDeleteResponse {
pub deleted: bool,
pub key: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
pub prefix: Option<String>,
pub limit: Option<i32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDeleteQuery {
pub key: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostMetadataRequest {
pub markdown: String,
@@ -170,6 +242,30 @@ pub struct AdminPostPolishRequest {
pub markdown: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest {
pub title: String,
pub review_type: String,
pub rating: i32,
pub review_date: Option<String>,
pub status: String,
#[serde(default)]
pub tags: Vec<String>,
pub description: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostCoverImageRequest {
pub title: String,
pub description: Option<String>,
pub category: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub post_type: String,
pub slug: Option<String>,
pub markdown: String,
}
fn format_timestamp(
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
pattern: &str,
@@ -242,6 +338,10 @@ fn build_settings_response(
ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key,
ai_chat_model: item.ai_chat_model,
ai_image_provider: item.ai_image_provider,
ai_image_api_base: item.ai_image_api_base,
ai_image_api_key: item.ai_image_api_key,
ai_image_model: item.ai_image_model,
ai_providers,
ai_active_provider_id,
ai_embedding_model: item.ai_embedding_model,
@@ -251,6 +351,12 @@ fn build_settings_response(
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
ai_chunks_count,
ai_local_embedding: ai::local_embedding_label().to_string(),
media_storage_provider: item.media_storage_provider,
media_r2_account_id: item.media_r2_account_id,
media_r2_bucket: item.media_r2_bucket,
media_r2_public_base_url: item.media_r2_public_base_url,
media_r2_access_key_id: item.media_r2_access_key_id,
media_r2_secret_access_key: item.media_r2_secret_access_key,
}
}
@@ -410,6 +516,12 @@ pub async fn dashboard(State(ctx): State<AppContext>) -> Result<Response> {
})
}
#[debug_handler]
pub async fn analytics_overview(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
format::json(analytics::build_admin_analytics(&ctx).await?)
}
#[debug_handler]
pub async fn get_site_settings(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
@@ -428,7 +540,7 @@ pub async fn update_site_settings(
let current = site_settings::load_current(&ctx).await?;
let mut item = current;
params.apply(&mut item);
let item = item.into_active_model();
let item = item.into_active_model().reset_all();
let updated = item.update(&ctx.db).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
@@ -469,6 +581,88 @@ pub async fn test_ai_provider(Json(payload): Json<AdminAiProviderTestRequest>) -
})
}
#[debug_handler]
pub async fn test_ai_image_provider(
Json(payload): Json<AdminAiImageProviderTestRequest>,
) -> Result<Response> {
check_auth()?;
let result = ai::test_image_provider_connectivity(
&payload.provider,
&payload.api_base,
&payload.api_key,
&payload.image_model,
)
.await?;
format::json(AdminAiImageProviderTestResponse {
provider: result.provider,
endpoint: result.endpoint,
image_model: result.image_model,
result_preview: result.result_preview,
})
}
#[debug_handler]
pub async fn test_r2_storage(State(ctx): State<AppContext>) -> Result<Response> {
check_auth()?;
let settings = storage::require_r2_settings(&ctx).await?;
let bucket = storage::test_r2_connectivity(&ctx).await?;
format::json(AdminR2ConnectivityResponse {
bucket,
public_base_url: settings.public_base_url,
})
}
#[debug_handler]
pub async fn list_media_objects(
State(ctx): State<AppContext>,
Query(query): Query<AdminMediaListQuery>,
) -> Result<Response> {
check_auth()?;
let settings = storage::require_r2_settings(&ctx).await?;
let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
.await?
.into_iter()
.map(|item| AdminMediaObjectResponse {
key: item.key,
url: item.url,
size_bytes: item.size_bytes,
last_modified: item.last_modified,
})
.collect::<Vec<_>>();
format::json(AdminMediaListResponse {
provider: settings.provider_name,
bucket: settings.bucket,
public_base_url: settings.public_base_url,
items,
})
}
#[debug_handler]
pub async fn delete_media_object(
State(ctx): State<AppContext>,
Query(query): Query<AdminMediaDeleteQuery>,
) -> Result<Response> {
check_auth()?;
let key = query.key.trim();
if key.is_empty() {
return Err(Error::BadRequest("缺少对象 key".to_string()));
}
storage::delete_object(&ctx, key).await?;
format::json(AdminMediaDeleteResponse {
deleted: true,
key: key.to_string(),
})
}
#[debug_handler]
pub async fn generate_post_metadata(
State(ctx): State<AppContext>,
@@ -487,6 +681,127 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
}
#[debug_handler]
pub async fn polish_review_description(
State(ctx): State<AppContext>,
Json(payload): Json<AdminReviewPolishRequest>,
) -> Result<Response> {
check_auth()?;
format::json(
ai::polish_review_description(
&ctx,
&payload.title,
&payload.review_type,
payload.rating,
payload.review_date.as_deref(),
&payload.status,
&payload.tags,
&payload.description,
)
.await?,
)
}
#[debug_handler]
pub async fn generate_post_cover_image(
State(ctx): State<AppContext>,
Json(payload): Json<AdminPostCoverImageRequest>,
) -> Result<Response> {
check_auth()?;
format::json(
ai::generate_post_cover_image(
&ctx,
&payload.title,
payload.description.as_deref(),
payload.category.as_deref(),
&payload.tags,
&payload.post_type,
payload.slug.as_deref(),
&payload.markdown,
)
.await?,
)
}
fn review_cover_extension(
file_name: Option<&str>,
content_type: Option<&str>,
) -> Option<&'static str> {
let from_file_name = file_name
.and_then(|name| name.rsplit('.').next())
.map(|ext| ext.trim().to_ascii_lowercase());
match from_file_name.as_deref() {
Some("png") => return Some("png"),
Some("jpg") | Some("jpeg") => return Some("jpg"),
Some("webp") => return Some("webp"),
Some("gif") => return Some("gif"),
Some("avif") => return Some("avif"),
Some("svg") => return Some("svg"),
_ => {}
}
match content_type
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
"image/avif" => Some("avif"),
"image/svg+xml" => Some("svg"),
_ => None,
}
}
#[debug_handler]
pub async fn upload_review_cover_image(
State(ctx): State<AppContext>,
mut multipart: Multipart,
) -> Result<Response> {
check_auth()?;
let field = multipart
.next_field()
.await
.map_err(|error| Error::BadRequest(error.to_string()))?
.ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
let file_name = field.file_name().map(ToString::to_string);
let content_type = field.content_type().map(ToString::to_string);
let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
.ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
let bytes = field
.bytes()
.await
.map_err(|error| Error::BadRequest(error.to_string()))?;
if bytes.is_empty() {
return Err(Error::BadRequest("上传的图片内容为空".to_string()));
}
let key = crate::services::storage::build_object_key(
"review-covers",
file_name.as_deref().unwrap_or("review-cover"),
extension,
);
let stored = crate::services::storage::upload_bytes_to_r2(
&ctx,
&key,
bytes.to_vec(),
content_type.as_deref(),
Some("public, max-age=31536000, immutable"),
)
.await?;
format::json(AdminImageUploadResponse {
url: stored.url,
key: stored.key,
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
@@ -494,11 +809,21 @@ pub fn routes() -> Routes {
.add("/session", delete(session_logout))
.add("/session/login", post(session_login))
.add("/dashboard", get(dashboard))
.add("/analytics", get(analytics_overview))
.add("/site-settings", get(get_site_settings))
.add("/site-settings", patch(update_site_settings))
.add("/site-settings", put(update_site_settings))
.add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider))
.add("/ai/test-image-provider", post(test_ai_image_provider))
.add("/storage/r2/test", post(test_r2_storage))
.add(
"/storage/media",
get(list_media_objects).delete(delete_media_object),
)
.add("/ai/post-metadata", post(generate_post_metadata))
.add("/ai/polish-post", post(polish_post_markdown))
.add("/ai/polish-review", post(polish_review_description))
.add("/ai/post-cover", post(generate_post_cover_image))
.add("/storage/review-cover", post(upload_review_cover_image))
}