Files
termi-blog/backend/src/controllers/admin_taxonomy.rs
limitcool 7de4ddc3ee
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
feat: refresh content workflow and verification settings
2026-04-01 18:47:17 +08:00

484 lines
14 KiB
Rust

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
models::_entities::{categories, posts, tags},
services::content,
};
#[derive(Clone, Debug, Deserialize)]
pub struct TaxonomyPayload {
pub name: Option<String>,
#[serde(default)]
pub slug: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub cover_image: Option<String>,
#[serde(default)]
pub accent_color: Option<String>,
#[serde(default)]
pub seo_title: Option<String>,
#[serde(default)]
pub seo_description: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminCategoryRecord {
pub id: i32,
pub name: String,
pub slug: String,
pub count: usize,
pub description: Option<String>,
pub cover_image: Option<String>,
pub accent_color: Option<String>,
pub seo_title: Option<String>,
pub seo_description: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminTagRecord {
pub id: i32,
pub name: String,
pub slug: String,
pub count: usize,
pub description: Option<String>,
pub cover_image: Option<String>,
pub accent_color: Option<String>,
pub seo_title: Option<String>,
pub seo_description: Option<String>,
pub created_at: String,
pub updated_at: String,
}
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in value.trim().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
slug.trim_matches('-').to_string()
}
fn normalized_name(params: &TaxonomyPayload, label: &str) -> Result<String> {
params
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.ok_or_else(|| Error::BadRequest(format!("{label}名称不能为空")))
}
fn normalized_slug(value: Option<&str>, fallback: &str, label: &str) -> Result<String> {
let slug = value
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| slugify(fallback));
if slug.is_empty() {
return Err(Error::BadRequest(format!(
"{label} slug 不能为空,请填写英文字母 / 数字 / 连字符"
)));
}
Ok(slug)
}
fn normalized_token(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn post_tag_values(post: &posts::Model) -> Vec<String> {
post.tags
.as_ref()
.and_then(|value| serde_json::from_value::<Vec<String>>(value.clone()).ok())
.unwrap_or_default()
.into_iter()
.map(|item| normalized_token(&item))
.filter(|item| !item.is_empty())
.collect()
}
fn category_name(item: &categories::Model) -> String {
item.name.clone().unwrap_or_else(|| item.slug.clone())
}
fn tag_name(item: &tags::Model) -> String {
item.name.clone().unwrap_or_else(|| item.slug.clone())
}
fn build_category_record(
item: &categories::Model,
post_items: &[posts::Model],
) -> AdminCategoryRecord {
let name = category_name(item);
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
let count = post_items
.iter()
.filter(|post| {
post.category
.as_deref()
.map(normalized_token)
.is_some_and(|value| aliases.iter().any(|alias| alias == &value))
})
.count();
AdminCategoryRecord {
id: item.id,
name,
slug: item.slug.clone(),
count,
description: item.description.clone(),
cover_image: item.cover_image.clone(),
accent_color: item.accent_color.clone(),
seo_title: item.seo_title.clone(),
seo_description: item.seo_description.clone(),
created_at: item.created_at.to_rfc3339(),
updated_at: item.updated_at.to_rfc3339(),
}
}
fn build_tag_record(item: &tags::Model, post_items: &[posts::Model]) -> AdminTagRecord {
let name = tag_name(item);
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
let count = post_items
.iter()
.filter(|post| {
post_tag_values(post)
.into_iter()
.any(|value| aliases.iter().any(|alias| alias == &value))
})
.count();
AdminTagRecord {
id: item.id,
name,
slug: item.slug.clone(),
count,
description: item.description.clone(),
cover_image: item.cover_image.clone(),
accent_color: item.accent_color.clone(),
seo_title: item.seo_title.clone(),
seo_description: item.seo_description.clone(),
created_at: item.created_at.to_rfc3339(),
updated_at: item.updated_at.to_rfc3339(),
}
}
async fn load_category(ctx: &AppContext, id: i32) -> Result<categories::Model> {
categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)
}
async fn load_tag(ctx: &AppContext, id: i32) -> Result<tags::Model> {
tags::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)
}
async fn ensure_category_slug_unique(
ctx: &AppContext,
slug: &str,
exclude_id: Option<i32>,
) -> Result<()> {
if let Some(existing) = categories::Entity::find()
.filter(categories::Column::Slug.eq(slug))
.one(&ctx.db)
.await?
{
if Some(existing.id) != exclude_id {
return Err(Error::BadRequest("分类 slug 已存在".to_string()));
}
}
Ok(())
}
async fn ensure_tag_slug_unique(
ctx: &AppContext,
slug: &str,
exclude_id: Option<i32>,
) -> Result<()> {
if let Some(existing) = tags::Entity::find()
.filter(tags::Column::Slug.eq(slug))
.one(&ctx.db)
.await?
{
if Some(existing.id) != exclude_id {
return Err(Error::BadRequest("标签 slug 已存在".to_string()));
}
}
Ok(())
}
async fn load_posts(ctx: &AppContext) -> Result<Vec<posts::Model>> {
Ok(posts::Entity::find().all(&ctx.db).await?)
}
#[debug_handler]
pub async fn list_categories(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let items = categories::Entity::find()
.order_by_asc(categories::Column::Slug)
.all(&ctx.db)
.await?;
let post_items = load_posts(&ctx).await?;
format::json(
items
.into_iter()
.map(|item| build_category_record(&item, &post_items))
.collect::<Vec<_>>(),
)
}
#[debug_handler]
pub async fn create_category(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<TaxonomyPayload>,
) -> Result<Response> {
check_auth(&headers)?;
let name = normalized_name(&payload, "分类")?;
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
ensure_category_slug_unique(&ctx, &slug, None).await?;
let item = categories::ActiveModel {
name: Set(Some(name)),
slug: Set(slug),
description: Set(trim_to_option(payload.description)),
cover_image: Set(trim_to_option(payload.cover_image)),
accent_color: Set(trim_to_option(payload.accent_color)),
seo_title: Set(trim_to_option(payload.seo_title)),
seo_description: Set(trim_to_option(payload.seo_description)),
..Default::default()
}
.insert(&ctx.db)
.await?;
let post_items = load_posts(&ctx).await?;
format::json(build_category_record(&item, &post_items))
}
#[debug_handler]
pub async fn update_category(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(payload): Json<TaxonomyPayload>,
) -> Result<Response> {
check_auth(&headers)?;
let name = normalized_name(&payload, "分类")?;
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
ensure_category_slug_unique(&ctx, &slug, Some(id)).await?;
let item = load_category(&ctx, id).await?;
let previous_name = item.name.clone();
let previous_slug = item.slug.clone();
if previous_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
!= Some(name.as_str())
{
content::rewrite_category_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)
.await?;
}
let mut active = item.into_active_model();
active.name = Set(Some(name));
active.slug = Set(slug);
active.description = Set(trim_to_option(payload.description));
active.cover_image = Set(trim_to_option(payload.cover_image));
active.accent_color = Set(trim_to_option(payload.accent_color));
active.seo_title = Set(trim_to_option(payload.seo_title));
active.seo_description = Set(trim_to_option(payload.seo_description));
let updated = active.update(&ctx.db).await?;
let post_items = load_posts(&ctx).await?;
format::json(build_category_record(&updated, &post_items))
}
#[debug_handler]
pub async fn delete_category(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let item = load_category(&ctx, id).await?;
content::rewrite_category_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
format::empty()
}
#[debug_handler]
pub async fn list_tags(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let items = tags::Entity::find()
.order_by_asc(tags::Column::Slug)
.all(&ctx.db)
.await?;
let post_items = load_posts(&ctx).await?;
format::json(
items
.into_iter()
.map(|item| build_tag_record(&item, &post_items))
.collect::<Vec<_>>(),
)
}
#[debug_handler]
pub async fn create_tag(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<TaxonomyPayload>,
) -> Result<Response> {
check_auth(&headers)?;
let name = normalized_name(&payload, "标签")?;
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
ensure_tag_slug_unique(&ctx, &slug, None).await?;
let item = tags::ActiveModel {
name: Set(Some(name)),
slug: Set(slug),
description: Set(trim_to_option(payload.description)),
cover_image: Set(trim_to_option(payload.cover_image)),
accent_color: Set(trim_to_option(payload.accent_color)),
seo_title: Set(trim_to_option(payload.seo_title)),
seo_description: Set(trim_to_option(payload.seo_description)),
..Default::default()
}
.insert(&ctx.db)
.await?;
let post_items = load_posts(&ctx).await?;
format::json(build_tag_record(&item, &post_items))
}
#[debug_handler]
pub async fn update_tag(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Json(payload): Json<TaxonomyPayload>,
) -> Result<Response> {
check_auth(&headers)?;
let name = normalized_name(&payload, "标签")?;
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
ensure_tag_slug_unique(&ctx, &slug, Some(id)).await?;
let item = load_tag(&ctx, id).await?;
let previous_name = item.name.clone();
let previous_slug = item.slug.clone();
if previous_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
!= Some(name.as_str())
{
content::rewrite_tag_references(
&ctx,
previous_name.as_deref(),
&previous_slug,
Some(&name),
)
.await?;
}
let mut active = item.into_active_model();
active.name = Set(Some(name));
active.slug = Set(slug);
active.description = Set(trim_to_option(payload.description));
active.cover_image = Set(trim_to_option(payload.cover_image));
active.accent_color = Set(trim_to_option(payload.accent_color));
active.seo_title = Set(trim_to_option(payload.seo_title));
active.seo_description = Set(trim_to_option(payload.seo_description));
let updated = active.update(&ctx.db).await?;
let post_items = load_posts(&ctx).await?;
format::json(build_tag_record(&updated, &post_items))
}
#[debug_handler]
pub async fn delete_tag(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let item = load_tag(&ctx, id).await?;
content::rewrite_tag_references(&ctx, item.name.as_deref(), &item.slug, None).await?;
item.delete(&ctx.db).await?;
format::empty()
}
pub fn routes() -> Routes {
Routes::new()
.add(
"/api/admin/categories",
get(list_categories).post(create_category),
)
.add(
"/api/admin/categories/{id}",
patch(update_category).delete(delete_category),
)
.add("/api/admin/tags", get(list_tags).post(create_tag))
.add("/api/admin/tags/{id}", patch(update_tag).delete(delete_tag))
}