feat(project): init

This commit is contained in:
2026-02-02 14:27:56 +08:00
commit ed3219deb4
46 changed files with 7235 additions and 0 deletions

81
src/api/docs.rs Normal file
View File

@@ -0,0 +1,81 @@
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi
.components
.get_or_insert_with(utoipa::openapi::Components::new);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
#[derive(OpenApi)]
#[openapi(
modifiers(&SecurityAddon),
info(
title = "CMS Service API",
version = "0.1.0",
description = include_str!("../../docs/API.md")
),
paths(
crate::api::handlers::column::create_column_handler,
crate::api::handlers::column::list_columns_handler,
crate::api::handlers::column::get_column_handler,
crate::api::handlers::column::update_column_handler,
crate::api::handlers::column::delete_column_handler,
crate::api::handlers::tag::create_tag_handler,
crate::api::handlers::tag::list_tags_handler,
crate::api::handlers::tag::get_tag_handler,
crate::api::handlers::tag::update_tag_handler,
crate::api::handlers::tag::delete_tag_handler,
crate::api::handlers::media::create_media_handler,
crate::api::handlers::media::list_media_handler,
crate::api::handlers::media::get_media_handler,
crate::api::handlers::media::delete_media_handler,
crate::api::handlers::article::create_article_handler,
crate::api::handlers::article::list_articles_handler,
crate::api::handlers::article::get_article_handler,
crate::api::handlers::article::update_article_handler,
crate::api::handlers::article::publish_article_handler,
crate::api::handlers::article::rollback_article_handler,
crate::api::handlers::article::list_versions_handler
),
components(
schemas(
crate::api::handlers::column::CreateColumnRequest,
crate::api::handlers::column::UpdateColumnRequest,
crate::api::handlers::tag::CreateTagRequest,
crate::api::handlers::tag::UpdateTagRequest,
crate::api::handlers::media::CreateMediaRequest,
crate::api::handlers::article::CreateArticleRequest,
crate::api::handlers::article::UpdateArticleRequest,
crate::api::handlers::article::RollbackRequest,
crate::domain::models::Column,
crate::domain::models::Tag,
crate::domain::models::Media,
crate::domain::models::Article,
crate::domain::models::ArticleVersion,
crate::infrastructure::repositories::article::ArticleWithTags
)
),
tags(
(name = "System", description = "系统:健康检查/文档"),
(name = "Column", description = "栏目管理"),
(name = "Article", description = "文章管理"),
(name = "Media", description = "媒体库"),
(name = "Tag", description = "标签与分类"),
(name = "Version", description = "版本与回滚")
)
)]
pub struct ApiDoc;

334
src/api/handlers/article.rs Normal file
View File

@@ -0,0 +1,334 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use common_telemetry::{AppError, AppResponse};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::api::{AppState, handlers::common::extract_bearer_token};
use auth_kit::middleware::{tenant::TenantId, auth::AuthContext};
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateArticleRequest {
pub column_id: Option<Uuid>,
pub title: String,
pub slug: String,
pub summary: Option<String>,
pub content: String,
pub tag_ids: Option<Vec<Uuid>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateArticleRequest {
pub column_id: Option<Option<Uuid>>,
pub title: Option<String>,
pub slug: Option<String>,
pub summary: Option<Option<String>>,
pub content: Option<String>,
pub tag_ids: Option<Vec<Uuid>>,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ListArticlesQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub q: Option<String>,
pub status: Option<String>,
pub column_id: Option<Uuid>,
pub tag_id: Option<Uuid>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct RollbackRequest {
pub to_version: i32,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ListVersionsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", post(create_article_handler).get(list_articles_handler))
.route(
"/{id}",
get(get_article_handler).patch(update_article_handler),
)
.route("/{id}/publish", post(publish_article_handler))
.route("/{id}/rollback", post(rollback_article_handler))
.route("/{id}/versions", get(list_versions_handler))
}
#[utoipa::path(
post,
path = "/v1/articles",
tag = "Article",
request_body = CreateArticleRequest,
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "创建文章(草稿)", body = crate::infrastructure::repositories::article::ArticleWithTags)
)
)]
pub async fn create_article_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(body): Json<CreateArticleRequest>,
) -> Result<AppResponse<crate::infrastructure::repositories::article::ArticleWithTags>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:write", &token)
.await?;
let article = state
.services
.create_article(
tenant_id,
body.column_id,
body.title,
body.slug,
body.summary,
body.content,
body.tag_ids.unwrap_or_default(),
Some(user_id),
)
.await?;
Ok(AppResponse::ok(article))
}
#[utoipa::path(
get,
path = "/v1/articles",
tag = "Article",
params(ListArticlesQuery),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "文章列表/搜索", body = crate::infrastructure::repositories::column::Paged<crate::domain::models::Article>)
)
)]
pub async fn list_articles_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(query): Query<ListArticlesQuery>,
) -> Result<AppResponse<crate::infrastructure::repositories::column::Paged<crate::domain::models::Article>>, AppError>
{
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:read", &token)
.await?;
let result = state
.services
.list_articles(
tenant_id,
crate::infrastructure::repositories::article::ListArticlesQuery {
page: query.page.unwrap_or(1),
page_size: query.page_size.unwrap_or(20),
q: query.q,
status: query.status,
column_id: query.column_id,
tag_id: query.tag_id,
},
)
.await?;
Ok(AppResponse::ok(result))
}
#[utoipa::path(
get,
path = "/v1/articles/{id}",
tag = "Article",
params(
("id" = String, Path, description = "文章ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "文章详情", body = crate::infrastructure::repositories::article::ArticleWithTags)
)
)]
pub async fn get_article_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<crate::infrastructure::repositories::article::ArticleWithTags>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:read", &token)
.await?;
let article = state.services.get_article(tenant_id, id).await?;
Ok(AppResponse::ok(article))
}
#[utoipa::path(
patch,
path = "/v1/articles/{id}",
tag = "Article",
request_body = UpdateArticleRequest,
params(
("id" = String, Path, description = "文章ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "更新文章", body = crate::infrastructure::repositories::article::ArticleWithTags)
)
)]
pub async fn update_article_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(body): Json<UpdateArticleRequest>,
) -> Result<AppResponse<crate::infrastructure::repositories::article::ArticleWithTags>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:write", &token)
.await?;
let article = state
.services
.update_article(
tenant_id,
id,
body.column_id,
body.title,
body.slug,
body.summary,
body.content,
body.tag_ids,
Some(user_id),
)
.await?;
Ok(AppResponse::ok(article))
}
#[utoipa::path(
post,
path = "/v1/articles/{id}/publish",
tag = "Article",
params(
("id" = String, Path, description = "文章ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "发布文章", body = crate::domain::models::Article)
)
)]
pub async fn publish_article_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<crate::domain::models::Article>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:publish", &token)
.await?;
let article = state.services.publish_article(tenant_id, id, Some(user_id)).await?;
Ok(AppResponse::ok(article))
}
#[utoipa::path(
post,
path = "/v1/articles/{id}/rollback",
tag = "Version",
request_body = RollbackRequest,
params(
("id" = String, Path, description = "文章ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "回滚到指定版本并生成新版本", body = crate::domain::models::Article)
)
)]
pub async fn rollback_article_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(body): Json<RollbackRequest>,
) -> Result<AppResponse<crate::domain::models::Article>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:rollback", &token)
.await?;
let article = state
.services
.rollback_article(tenant_id, id, body.to_version, Some(user_id))
.await?;
Ok(AppResponse::ok(article))
}
#[utoipa::path(
get,
path = "/v1/articles/{id}/versions",
tag = "Version",
params(
("id" = String, Path, description = "文章ID"),
ListVersionsQuery
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "版本列表", body = crate::infrastructure::repositories::column::Paged<crate::domain::models::ArticleVersion>)
)
)]
pub async fn list_versions_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Query(query): Query<ListVersionsQuery>,
) -> Result<AppResponse<crate::infrastructure::repositories::column::Paged<crate::domain::models::ArticleVersion>>, AppError>
{
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:article:read", &token)
.await?;
let versions = state
.services
.list_versions(
tenant_id,
id,
query.page.unwrap_or(1),
query.page_size.unwrap_or(20),
)
.await?;
Ok(AppResponse::ok(versions))
}

247
src/api/handlers/column.rs Normal file
View File

@@ -0,0 +1,247 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use common_telemetry::{AppError, AppResponse};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::api::{AppState, handlers::common::extract_bearer_token};
use auth_kit::middleware::{tenant::TenantId, auth::AuthContext};
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateColumnRequest {
pub name: String,
pub slug: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateColumnRequest {
pub name: Option<String>,
pub slug: Option<String>,
pub description: Option<Option<String>>,
pub parent_id: Option<Option<Uuid>>,
pub sort_order: Option<i32>,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ListColumnsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub search: Option<String>,
pub parent_id: Option<Uuid>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", post(create_column_handler).get(list_columns_handler))
.route(
"/{id}",
get(get_column_handler)
.patch(update_column_handler)
.delete(delete_column_handler),
)
}
#[utoipa::path(
post,
path = "/v1/columns",
tag = "Column",
request_body = CreateColumnRequest,
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "创建栏目", body = crate::domain::models::Column),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
)
)]
pub async fn create_column_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(body): Json<CreateColumnRequest>,
) -> Result<AppResponse<crate::domain::models::Column>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:column:write", &token)
.await?;
let column = state
.services
.create_column(
tenant_id,
body.name,
body.slug,
body.description,
body.parent_id,
body.sort_order.unwrap_or(0),
)
.await?;
Ok(AppResponse::ok(column))
}
#[utoipa::path(
get,
path = "/v1/columns",
tag = "Column",
params(ListColumnsQuery),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "栏目列表", body = crate::infrastructure::repositories::column::Paged<crate::domain::models::Column>),
(status = 401, description = "未认证"),
(status = 403, description = "无权限")
)
)]
pub async fn list_columns_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(query): Query<ListColumnsQuery>,
) -> Result<AppResponse<crate::infrastructure::repositories::column::Paged<crate::domain::models::Column>>, AppError>
{
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:column:read", &token)
.await?;
let result = state
.services
.list_columns(
tenant_id,
crate::infrastructure::repositories::column::ListColumnsQuery {
page: query.page.unwrap_or(1),
page_size: query.page_size.unwrap_or(20),
search: query.search,
parent_id: query.parent_id,
},
)
.await?;
Ok(AppResponse::ok(result))
}
#[utoipa::path(
get,
path = "/v1/columns/{id}",
tag = "Column",
params(
("id" = String, Path, description = "栏目ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "栏目详情", body = crate::domain::models::Column),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "不存在")
)
)]
pub async fn get_column_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<crate::domain::models::Column>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:column:read", &token)
.await?;
let column = state.services.get_column(tenant_id, id).await?;
Ok(AppResponse::ok(column))
}
#[utoipa::path(
patch,
path = "/v1/columns/{id}",
tag = "Column",
request_body = UpdateColumnRequest,
params(
("id" = String, Path, description = "栏目ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "更新栏目", body = crate::domain::models::Column),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "不存在")
)
)]
pub async fn update_column_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(body): Json<UpdateColumnRequest>,
) -> Result<AppResponse<crate::domain::models::Column>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:column:write", &token)
.await?;
let column = state
.services
.update_column(
tenant_id,
id,
body.name,
body.slug,
body.description,
body.parent_id,
body.sort_order,
)
.await?;
Ok(AppResponse::ok(column))
}
#[utoipa::path(
delete,
path = "/v1/columns/{id}",
tag = "Column",
params(
("id" = String, Path, description = "栏目ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "删除成功"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "不存在")
)
)]
pub async fn delete_column_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:column:write", &token)
.await?;
state.services.delete_column(tenant_id, id).await?;
Ok(AppResponse::ok(serde_json::json!({"deleted": true})))
}

View File

@@ -0,0 +1,11 @@
use axum::http::HeaderMap;
use common_telemetry::AppError;
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::MissingAuthHeader)?;
Ok(token.to_string())
}

175
src/api/handlers/media.rs Normal file
View File

@@ -0,0 +1,175 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use common_telemetry::{AppError, AppResponse};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::api::{AppState, handlers::common::extract_bearer_token};
use auth_kit::middleware::{tenant::TenantId, auth::AuthContext};
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateMediaRequest {
pub url: String,
pub mime_type: Option<String>,
pub size_bytes: Option<i64>,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ListMediaQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub search: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", post(create_media_handler).get(list_media_handler))
.route("/{id}", get(get_media_handler).delete(delete_media_handler))
}
#[utoipa::path(
post,
path = "/v1/media",
tag = "Media",
request_body = CreateMediaRequest,
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "创建媒体记录", body = crate::domain::models::Media)
)
)]
pub async fn create_media_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(body): Json<CreateMediaRequest>,
) -> Result<AppResponse<crate::domain::models::Media>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:media:manage", &token)
.await?;
let media = state
.services
.create_media(
tenant_id,
body.url,
body.mime_type,
body.size_bytes,
body.width,
body.height,
Some(user_id),
)
.await?;
Ok(AppResponse::ok(media))
}
#[utoipa::path(
get,
path = "/v1/media",
tag = "Media",
params(ListMediaQuery),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "媒体列表", body = crate::infrastructure::repositories::column::Paged<crate::domain::models::Media>)
)
)]
pub async fn list_media_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(query): Query<ListMediaQuery>,
) -> Result<AppResponse<crate::infrastructure::repositories::column::Paged<crate::domain::models::Media>>, AppError>
{
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:media:read", &token)
.await?;
let result = state
.services
.list_media(
tenant_id,
crate::infrastructure::repositories::media::ListMediaQuery {
page: query.page.unwrap_or(1),
page_size: query.page_size.unwrap_or(20),
search: query.search,
},
)
.await?;
Ok(AppResponse::ok(result))
}
#[utoipa::path(
get,
path = "/v1/media/{id}",
tag = "Media",
params(
("id" = String, Path, description = "媒体ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "媒体详情", body = crate::domain::models::Media)
)
)]
pub async fn get_media_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<crate::domain::models::Media>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:media:read", &token)
.await?;
let media = state.services.get_media(tenant_id, id).await?;
Ok(AppResponse::ok(media))
}
#[utoipa::path(
delete,
path = "/v1/media/{id}",
tag = "Media",
params(
("id" = String, Path, description = "媒体ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "删除成功")
)
)]
pub async fn delete_media_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:media:manage", &token)
.await?;
state.services.delete_media(tenant_id, id).await?;
Ok(AppResponse::ok(serde_json::json!({"deleted": true})))
}

5
src/api/handlers/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod article;
pub mod column;
pub mod common;
pub mod media;
pub mod tag;

214
src/api/handlers/tag.rs Normal file
View File

@@ -0,0 +1,214 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use common_telemetry::{AppError, AppResponse};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::api::{AppState, handlers::common::extract_bearer_token};
use auth_kit::middleware::{tenant::TenantId, auth::AuthContext};
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateTagRequest {
pub kind: String,
pub name: String,
pub slug: String,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateTagRequest {
pub name: Option<String>,
pub slug: Option<String>,
}
#[derive(Debug, serde::Deserialize, IntoParams)]
pub struct ListTagsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub search: Option<String>,
pub kind: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", post(create_tag_handler).get(list_tags_handler))
.route(
"/{id}",
get(get_tag_handler)
.patch(update_tag_handler)
.delete(delete_tag_handler),
)
}
#[utoipa::path(
post,
path = "/v1/tags",
tag = "Tag",
request_body = CreateTagRequest,
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "创建标签/分类", body = crate::domain::models::Tag)
)
)]
pub async fn create_tag_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(body): Json<CreateTagRequest>,
) -> Result<AppResponse<crate::domain::models::Tag>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:tag:write", &token)
.await?;
let tag = state
.services
.create_tag(tenant_id, body.kind, body.name, body.slug)
.await?;
Ok(AppResponse::ok(tag))
}
#[utoipa::path(
get,
path = "/v1/tags",
tag = "Tag",
params(ListTagsQuery),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "标签/分类列表", body = crate::infrastructure::repositories::column::Paged<crate::domain::models::Tag>)
)
)]
pub async fn list_tags_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(query): Query<ListTagsQuery>,
) -> Result<AppResponse<crate::infrastructure::repositories::column::Paged<crate::domain::models::Tag>>, AppError>
{
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:tag:read", &token)
.await?;
let result = state
.services
.list_tags(
tenant_id,
crate::infrastructure::repositories::tag::ListTagsQuery {
page: query.page.unwrap_or(1),
page_size: query.page_size.unwrap_or(20),
search: query.search,
kind: query.kind,
},
)
.await?;
Ok(AppResponse::ok(result))
}
#[utoipa::path(
get,
path = "/v1/tags/{id}",
tag = "Tag",
params(
("id" = String, Path, description = "标签/分类ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "详情", body = crate::domain::models::Tag)
)
)]
pub async fn get_tag_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<crate::domain::models::Tag>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:tag:read", &token)
.await?;
let tag = state.services.get_tag(tenant_id, id).await?;
Ok(AppResponse::ok(tag))
}
#[utoipa::path(
patch,
path = "/v1/tags/{id}",
tag = "Tag",
request_body = UpdateTagRequest,
params(
("id" = String, Path, description = "标签/分类ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "更新", body = crate::domain::models::Tag)
)
)]
pub async fn update_tag_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(body): Json<UpdateTagRequest>,
) -> Result<AppResponse<crate::domain::models::Tag>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:tag:write", &token)
.await?;
let tag = state
.services
.update_tag(tenant_id, id, body.name, body.slug)
.await?;
Ok(AppResponse::ok(tag))
}
#[utoipa::path(
delete,
path = "/v1/tags/{id}",
tag = "Tag",
params(
("id" = String, Path, description = "标签/分类ID")
),
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "删除成功")
)
)]
pub async fn delete_tag_handler(
TenantId(tenant_id): TenantId,
AuthContext { user_id, .. }: AuthContext,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
let token = extract_bearer_token(&headers)?;
state
.iam_client
.require_permission(tenant_id, user_id, "cms:tag:write", &token)
.await?;
state.services.delete_tag(tenant_id, id).await?;
Ok(AppResponse::ok(serde_json::json!({"deleted": true})))
}

128
src/api/middleware/mod.rs Normal file
View File

@@ -0,0 +1,128 @@
use axum::{
extract::{MatchedPath, Request},
middleware::Next,
response::{IntoResponse, Response},
};
use common_telemetry::AppError;
use futures_util::FutureExt;
use http::HeaderValue;
use std::{panic::AssertUnwindSafe, time::Instant};
pub async fn ensure_request_id(mut req: Request, next: Next) -> Response {
let request_id = req
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
if let Ok(v) = HeaderValue::from_str(&request_id) {
req.headers_mut().insert("x-request-id", v);
}
let mut resp = next.run(req).await;
if let Ok(v) = HeaderValue::from_str(&request_id) {
resp.headers_mut().insert("x-request-id", v);
}
resp
}
pub async fn request_logger(req: Request, next: Next) -> Response {
let started = Instant::now();
let method = req.method().to_string();
let path = req.uri().path().to_string();
let request_id = req
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_string();
let action = req
.extensions()
.get::<MatchedPath>()
.map(|m| format!("{} {}", method, m.as_str()))
.unwrap_or_else(|| format!("{} {}", method, path));
let tenant_id = req
.extensions()
.get::<auth_kit::middleware::tenant::TenantId>()
.map(|t| t.0.to_string())
.unwrap_or_else(|| "unknown".to_string());
let user_id = req
.extensions()
.get::<auth_kit::middleware::auth::AuthContext>()
.map(|c| c.user_id.to_string())
.unwrap_or_else(|| "unknown".to_string());
let resp = next.run(req).await;
let latency_ms = started.elapsed().as_millis() as u64;
let status = resp.status().as_u16();
let error_code = match status {
200..=399 => "ok",
400 => "bad_request",
401 => "unauthorized",
403 => "permission_denied",
404 => "not_found",
409 => "conflict",
429 => "rate_limited",
500..=599 => "server_error",
_ => "unknown",
};
tracing::info!(
trace_id = %request_id,
tenant_id = %tenant_id,
user_id = %user_id,
action = %action,
latency_ms = latency_ms,
error_code = %error_code,
status = status
);
resp
}
pub async fn catch_panic(req: Request, next: Next) -> Response {
let request_id = req
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_string();
let method = req.method().to_string();
let path = req.uri().path().to_string();
let action = req
.extensions()
.get::<MatchedPath>()
.map(|m| format!("{} {}", method, m.as_str()))
.unwrap_or_else(|| format!("{} {}", method, path));
let tenant_id = req
.extensions()
.get::<auth_kit::middleware::tenant::TenantId>()
.map(|t| t.0.to_string())
.unwrap_or_else(|| "unknown".to_string());
let user_id = req
.extensions()
.get::<auth_kit::middleware::auth::AuthContext>()
.map(|c| c.user_id.to_string())
.unwrap_or_else(|| "unknown".to_string());
let result = AssertUnwindSafe(next.run(req)).catch_unwind().await;
match result {
Ok(resp) => resp,
Err(_) => {
tracing::error!(
trace_id = %request_id,
tenant_id = %tenant_id,
user_id = %user_id,
action = %action,
latency_ms = 0_u64,
error_code = "panic"
);
AppError::AnyhowError(anyhow::anyhow!("panic")).into_response()
}
}
}

40
src/api/mod.rs Normal file
View File

@@ -0,0 +1,40 @@
pub mod docs;
pub mod handlers;
pub mod middleware;
use axum::routing::get;
use axum::Router;
use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable};
use crate::api::docs::ApiDoc;
use crate::api::middleware::{catch_panic, request_logger};
use crate::application::services::CmsServices;
use crate::infrastructure::iam_client::IamClient;
#[derive(Clone)]
pub struct AppState {
pub services: CmsServices,
pub iam_client: IamClient,
}
pub fn build_router(state: AppState) -> Router {
let health = Router::new().route("/healthz", get(|| async { axum::http::StatusCode::OK }));
let v1 = Router::new()
.nest("/columns", handlers::column::router())
.nest("/tags", handlers::tag::router())
.nest("/media", handlers::media::router())
.nest("/articles", handlers::article::router());
let app = Router::new()
.route("/favicon.ico", get(|| async { axum::http::StatusCode::NO_CONTENT }))
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.merge(health)
.nest("/v1", v1)
.layer(axum::middleware::from_fn(catch_panic))
.layer(axum::middleware::from_fn(request_logger))
.with_state(state);
app
}