feat(project): init
This commit is contained in:
81
src/api/docs.rs
Normal file
81
src/api/docs.rs
Normal 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
334
src/api/handlers/article.rs
Normal 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
247
src/api/handlers/column.rs
Normal 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})))
|
||||
}
|
||||
11
src/api/handlers/common.rs
Normal file
11
src/api/handlers/common.rs
Normal 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
175
src/api/handlers/media.rs
Normal 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
5
src/api/handlers/mod.rs
Normal 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
214
src/api/handlers/tag.rs
Normal 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
128
src/api/middleware/mod.rs
Normal 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
40
src/api/mod.rs
Normal 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
|
||||
}
|
||||
1
src/application/mod.rs
Normal file
1
src/application/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod services;
|
||||
250
src/application/services/mod.rs
Normal file
250
src/application/services/mod.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use crate::domain::models::{Article, ArticleVersion, Column, Media, Tag};
|
||||
use crate::infrastructure::repositories;
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CmsServices {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl CmsServices {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn create_column(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
name: String,
|
||||
slug: String,
|
||||
description: Option<String>,
|
||||
parent_id: Option<Uuid>,
|
||||
sort_order: i32,
|
||||
) -> Result<Column, AppError> {
|
||||
repositories::column::create_column(
|
||||
&self.pool,
|
||||
tenant_id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
parent_id,
|
||||
sort_order,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_columns(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
q: repositories::column::ListColumnsQuery,
|
||||
) -> Result<repositories::column::Paged<Column>, AppError> {
|
||||
repositories::column::list_columns(&self.pool, tenant_id, q).await
|
||||
}
|
||||
|
||||
pub async fn get_column(&self, tenant_id: Uuid, id: Uuid) -> Result<Column, AppError> {
|
||||
repositories::column::get_column(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn update_column(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
description: Option<Option<String>>,
|
||||
parent_id: Option<Option<Uuid>>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<Column, AppError> {
|
||||
repositories::column::update_column(
|
||||
&self.pool,
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
parent_id,
|
||||
sort_order,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_column(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
repositories::column::delete_column(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
kind: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
) -> Result<Tag, AppError> {
|
||||
repositories::tag::create_tag(&self.pool, tenant_id, kind, name, slug).await
|
||||
}
|
||||
|
||||
pub async fn list_tags(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
q: repositories::tag::ListTagsQuery,
|
||||
) -> Result<repositories::column::Paged<Tag>, AppError> {
|
||||
repositories::tag::list_tags(&self.pool, tenant_id, q).await
|
||||
}
|
||||
|
||||
pub async fn get_tag(&self, tenant_id: Uuid, id: Uuid) -> Result<Tag, AppError> {
|
||||
repositories::tag::get_tag(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
) -> Result<Tag, AppError> {
|
||||
repositories::tag::update_tag(&self.pool, tenant_id, id, name, slug).await
|
||||
}
|
||||
|
||||
pub async fn delete_tag(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
repositories::tag::delete_tag(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn create_media(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
url: String,
|
||||
mime_type: Option<String>,
|
||||
size_bytes: Option<i64>,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<Media, AppError> {
|
||||
repositories::media::create_media(
|
||||
&self.pool,
|
||||
tenant_id,
|
||||
url,
|
||||
mime_type,
|
||||
size_bytes,
|
||||
width,
|
||||
height,
|
||||
created_by,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_media(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
q: repositories::media::ListMediaQuery,
|
||||
) -> Result<repositories::column::Paged<Media>, AppError> {
|
||||
repositories::media::list_media(&self.pool, tenant_id, q).await
|
||||
}
|
||||
|
||||
pub async fn get_media(&self, tenant_id: Uuid, id: Uuid) -> Result<Media, AppError> {
|
||||
repositories::media::get_media(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_media(&self, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
repositories::media::delete_media(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn create_article(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
column_id: Option<Uuid>,
|
||||
title: String,
|
||||
slug: String,
|
||||
summary: Option<String>,
|
||||
content: String,
|
||||
tag_ids: Vec<Uuid>,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<repositories::article::ArticleWithTags, AppError> {
|
||||
repositories::article::create_article(
|
||||
&self.pool,
|
||||
tenant_id,
|
||||
column_id,
|
||||
title,
|
||||
slug,
|
||||
summary,
|
||||
content,
|
||||
tag_ids,
|
||||
created_by,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_article(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> Result<repositories::article::ArticleWithTags, AppError> {
|
||||
repositories::article::get_article(&self.pool, tenant_id, id).await
|
||||
}
|
||||
|
||||
pub async fn list_articles(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
q: repositories::article::ListArticlesQuery,
|
||||
) -> Result<repositories::column::Paged<Article>, AppError> {
|
||||
repositories::article::list_articles(&self.pool, tenant_id, q).await
|
||||
}
|
||||
|
||||
pub async fn update_article(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
column_id: Option<Option<Uuid>>,
|
||||
title: Option<String>,
|
||||
slug: Option<String>,
|
||||
summary: Option<Option<String>>,
|
||||
content: Option<String>,
|
||||
tag_ids: Option<Vec<Uuid>>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<repositories::article::ArticleWithTags, AppError> {
|
||||
repositories::article::update_article(
|
||||
&self.pool,
|
||||
tenant_id,
|
||||
id,
|
||||
column_id,
|
||||
title,
|
||||
slug,
|
||||
summary,
|
||||
content,
|
||||
tag_ids,
|
||||
updated_by,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn publish_article(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Article, AppError> {
|
||||
repositories::article::publish_article(&self.pool, tenant_id, id, user_id).await
|
||||
}
|
||||
|
||||
pub async fn rollback_article(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
to_version: i32,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Article, AppError> {
|
||||
repositories::article::rollback_article(&self.pool, tenant_id, id, to_version, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_versions(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
article_id: Uuid,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<repositories::column::Paged<ArticleVersion>, AppError> {
|
||||
repositories::article::list_versions(&self.pool, tenant_id, article_id, page, page_size)
|
||||
.await
|
||||
}
|
||||
}
|
||||
69
src/config/mod.rs
Normal file
69
src/config/mod.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub service_name: String,
|
||||
pub log_level: String,
|
||||
pub log_to_file: bool,
|
||||
pub log_dir: String,
|
||||
pub log_file_name: String,
|
||||
pub database_url: String,
|
||||
pub db_max_connections: u32,
|
||||
pub db_min_connections: u32,
|
||||
pub port: u16,
|
||||
pub iam_base_url: String,
|
||||
pub iam_jwks_url: Option<String>,
|
||||
pub jwt_public_key_pem: Option<String>,
|
||||
pub iam_timeout_ms: u64,
|
||||
pub iam_cache_ttl_seconds: u64,
|
||||
pub iam_stale_if_error_seconds: u64,
|
||||
pub iam_cache_max_entries: usize,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
service_name: env::var("SERVICE_NAME").unwrap_or_else(|_| "cms-service".into()),
|
||||
log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()),
|
||||
log_to_file: env::var("LOG_TO_FILE")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()),
|
||||
log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "cms.log".into()),
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL environment variable is required".to_string())?,
|
||||
db_max_connections: env::var("DB_MAX_CONNECTIONS")
|
||||
.unwrap_or("20".into())
|
||||
.parse()
|
||||
.map_err(|_| "DB_MAX_CONNECTIONS must be a number".to_string())?,
|
||||
db_min_connections: env::var("DB_MIN_CONNECTIONS")
|
||||
.unwrap_or("5".into())
|
||||
.parse()
|
||||
.map_err(|_| "DB_MIN_CONNECTIONS must be a number".to_string())?,
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "3100".to_string())
|
||||
.parse()
|
||||
.map_err(|_| "PORT must be a valid number".to_string())?,
|
||||
iam_base_url: env::var("IAM_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
iam_jwks_url: env::var("IAM_JWKS_URL").ok(),
|
||||
jwt_public_key_pem: env::var("JWT_PUBLIC_KEY_PEM").ok(),
|
||||
iam_timeout_ms: env::var("IAM_TIMEOUT_MS")
|
||||
.unwrap_or_else(|_| "2000".into())
|
||||
.parse()
|
||||
.map_err(|_| "IAM_TIMEOUT_MS must be a number".to_string())?,
|
||||
iam_cache_ttl_seconds: env::var("IAM_CACHE_TTL_SECONDS")
|
||||
.unwrap_or_else(|_| "10".into())
|
||||
.parse()
|
||||
.map_err(|_| "IAM_CACHE_TTL_SECONDS must be a number".to_string())?,
|
||||
iam_stale_if_error_seconds: env::var("IAM_STALE_IF_ERROR_SECONDS")
|
||||
.unwrap_or_else(|_| "60".into())
|
||||
.parse()
|
||||
.map_err(|_| "IAM_STALE_IF_ERROR_SECONDS must be a number".to_string())?,
|
||||
iam_cache_max_entries: env::var("IAM_CACHE_MAX_ENTRIES")
|
||||
.unwrap_or_else(|_| "50000".into())
|
||||
.parse()
|
||||
.map_err(|_| "IAM_CACHE_MAX_ENTRIES must be a number".to_string())?,
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/domain/mod.rs
Normal file
1
src/domain/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod models;
|
||||
73
src/domain/models.rs
Normal file
73
src/domain/models.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)]
|
||||
pub struct Column {
|
||||
pub tenant_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub sort_order: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)]
|
||||
pub struct Tag {
|
||||
pub tenant_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)]
|
||||
pub struct Media {
|
||||
pub tenant_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub url: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)]
|
||||
pub struct Article {
|
||||
pub tenant_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub column_id: Option<Uuid>,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
pub current_version: i32,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, FromRow)]
|
||||
pub struct ArticleVersion {
|
||||
pub tenant_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub article_id: Uuid,
|
||||
pub version: i32,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub created_by: Option<Uuid>,
|
||||
}
|
||||
16
src/infrastructure/db/mod.rs
Normal file
16
src/infrastructure/db/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::config::AppConfig;
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn init_pool(config: &AppConfig) -> Result<PgPool, sqlx::Error> {
|
||||
PgPoolOptions::new()
|
||||
.max_connections(config.db_max_connections)
|
||||
.min_connections(config.db_min_connections)
|
||||
.acquire_timeout(Duration::from_secs(3))
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
}
|
||||
224
src/infrastructure/iam_client/mod.rs
Normal file
224
src/infrastructure/iam_client/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use std::{
|
||||
hash::{Hash, Hasher},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use common_telemetry::AppError;
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IamClientConfig {
|
||||
pub base_url: String,
|
||||
pub timeout: Duration,
|
||||
pub cache_ttl: Duration,
|
||||
pub cache_stale_if_error: Duration,
|
||||
pub cache_max_entries: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IamClient {
|
||||
inner: Arc<IamClientInner>,
|
||||
}
|
||||
|
||||
struct IamClientInner {
|
||||
http: reqwest::Client,
|
||||
cfg: IamClientConfig,
|
||||
cache: DashMap<CacheKey, CacheEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CacheKey {
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permission: String,
|
||||
}
|
||||
|
||||
impl PartialEq for CacheKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.tenant_id == other.tenant_id
|
||||
&& self.user_id == other.user_id
|
||||
&& self.permission == other.permission
|
||||
}
|
||||
}
|
||||
impl Eq for CacheKey {}
|
||||
|
||||
impl Hash for CacheKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.tenant_id.hash(state);
|
||||
self.user_id.hash(state);
|
||||
self.permission.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CacheEntry {
|
||||
allowed: bool,
|
||||
expires_at: Instant,
|
||||
stale_until: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthorizationCheckRequest {
|
||||
permission: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthorizationCheckResponse {
|
||||
allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiSuccessResponse<T> {
|
||||
#[allow(dead_code)]
|
||||
code: u32,
|
||||
#[allow(dead_code)]
|
||||
message: String,
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
impl IamClient {
|
||||
pub fn new(cfg: IamClientConfig) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.timeout)
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
Self {
|
||||
inner: Arc::new(IamClientInner {
|
||||
http,
|
||||
cfg,
|
||||
cache: DashMap::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_permission(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
access_token: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let allowed = self
|
||||
.check_permission(tenant_id, user_id, permission, access_token)
|
||||
.await?;
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::PermissionDenied(permission.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_permission(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
access_token: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let key = CacheKey {
|
||||
tenant_id,
|
||||
user_id,
|
||||
permission: permission.to_string(),
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
if let Some(entry) = self.inner.cache.get(&key).map(|e| e.clone()) {
|
||||
if entry.expires_at > now {
|
||||
return Ok(entry.allowed);
|
||||
}
|
||||
}
|
||||
|
||||
let remote = self
|
||||
.check_permission_remote(tenant_id, permission, access_token)
|
||||
.await;
|
||||
|
||||
match remote {
|
||||
Ok(allowed) => {
|
||||
self.set_cache(key, allowed);
|
||||
Ok(allowed)
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(entry) = self.inner.cache.get(&key).map(|e| e.clone()) {
|
||||
if entry.stale_until > now {
|
||||
tracing::warn!(
|
||||
tenant_id = %tenant_id,
|
||||
user_id = %user_id,
|
||||
action = "iam_client.degraded_cache",
|
||||
latency_ms = 0_u64,
|
||||
error_code = "iam_degraded_cache"
|
||||
);
|
||||
return Ok(entry.allowed);
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cache(&self, key: CacheKey, allowed: bool) {
|
||||
if self.inner.cache.len() > self.inner.cfg.cache_max_entries {
|
||||
self.inner.cache.clear();
|
||||
}
|
||||
let now = Instant::now();
|
||||
let entry = CacheEntry {
|
||||
allowed,
|
||||
expires_at: now + self.inner.cfg.cache_ttl,
|
||||
stale_until: now + self.inner.cfg.cache_ttl + self.inner.cfg.cache_stale_if_error,
|
||||
};
|
||||
self.inner.cache.insert(key, entry);
|
||||
}
|
||||
|
||||
async fn check_permission_remote(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
permission: &str,
|
||||
access_token: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let url = format!(
|
||||
"{}/authorize/check",
|
||||
self.inner.cfg.base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.inner
|
||||
.http
|
||||
.post(url)
|
||||
.bearer_auth(access_token)
|
||||
.header("X-Tenant-ID", tenant_id.to_string())
|
||||
.json(&AuthorizationCheckRequest {
|
||||
permission: permission.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::ExternalReqError(format!("iam:request_failed:{}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(AppError::AuthError("iam:unauthorized".into()));
|
||||
}
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(AppError::PermissionDenied("iam:forbidden".into()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(AppError::ExternalReqError(format!(
|
||||
"iam:unexpected_status:{}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: ApiSuccessResponse<AuthorizationCheckResponse> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::ExternalReqError(format!("iam:decode_failed:{}", e)))?;
|
||||
|
||||
let allowed = body
|
||||
.data
|
||||
.map(|d| d.allowed)
|
||||
.ok_or_else(|| AppError::ExternalReqError("iam:missing_data".into()))?;
|
||||
|
||||
Ok(allowed)
|
||||
}
|
||||
}
|
||||
3
src/infrastructure/mod.rs
Normal file
3
src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod db;
|
||||
pub mod iam_client;
|
||||
pub mod repositories;
|
||||
480
src/infrastructure/repositories/article.rs
Normal file
480
src/infrastructure/repositories/article.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use crate::domain::models::{Article, ArticleVersion};
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct ArticleWithTags {
|
||||
pub article: Article,
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
async fn list_tag_ids_for_article(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
tenant_id: Uuid,
|
||||
article_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, AppError> {
|
||||
let tags = sqlx::query_scalar::<_, Uuid>(
|
||||
r#"
|
||||
SELECT tag_id
|
||||
FROM cms_article_tags
|
||||
WHERE tenant_id = $1 AND article_id = $2
|
||||
ORDER BY tag_id
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(article_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub async fn create_article(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
column_id: Option<Uuid>,
|
||||
title: String,
|
||||
slug: String,
|
||||
summary: Option<String>,
|
||||
content: String,
|
||||
tag_ids: Vec<Uuid>,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<ArticleWithTags, AppError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let article = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
INSERT INTO cms_articles (tenant_id, column_id, title, slug, summary, content, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
RETURNING
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(column_id)
|
||||
.bind(title)
|
||||
.bind(slug)
|
||||
.bind(summary)
|
||||
.bind(content)
|
||||
.bind(created_by)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for tag_id in &tag_ids {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO cms_article_tags (tenant_id, article_id, tag_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(article.id)
|
||||
.bind(tag_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, article.id).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ArticleWithTags { article, tag_ids })
|
||||
}
|
||||
|
||||
pub async fn get_article(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> Result<ArticleWithTags, AppError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let article = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
SELECT
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM cms_articles
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(ArticleWithTags { article, tag_ids })
|
||||
}
|
||||
|
||||
pub async fn update_article(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
column_id: Option<Option<Uuid>>,
|
||||
title: Option<String>,
|
||||
slug: Option<String>,
|
||||
summary: Option<Option<String>>,
|
||||
content: Option<String>,
|
||||
tag_ids: Option<Vec<Uuid>>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<ArticleWithTags, AppError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let article = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
UPDATE cms_articles
|
||||
SET
|
||||
column_id = COALESCE($3, column_id),
|
||||
title = COALESCE($4, title),
|
||||
slug = COALESCE($5, slug),
|
||||
summary = COALESCE($6, summary),
|
||||
content = COALESCE($7, content),
|
||||
updated_at = now(),
|
||||
updated_by = COALESCE($8, updated_by)
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(column_id)
|
||||
.bind(title)
|
||||
.bind(slug)
|
||||
.bind(summary)
|
||||
.bind(content)
|
||||
.bind(updated_by)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(tag_ids) = tag_ids {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM cms_article_tags
|
||||
WHERE tenant_id = $1 AND article_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for tag_id in &tag_ids {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO cms_article_tags (tenant_id, article_id, tag_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(tag_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let tag_ids = list_tag_ids_for_article(&mut tx, tenant_id, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(ArticleWithTags { article, tag_ids })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListArticlesQuery {
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub q: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub column_id: Option<Uuid>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn list_articles(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
q: ListArticlesQuery,
|
||||
) -> Result<super::column::Paged<Article>, AppError> {
|
||||
let page = q.page.max(1);
|
||||
let page_size = q.page_size.clamp(1, 200);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
let limit = page_size as i64;
|
||||
|
||||
let like = q.q.map(|s| format!("%{}%", s));
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT a.id)
|
||||
FROM cms_articles a
|
||||
LEFT JOIN cms_article_tags at ON at.tenant_id = a.tenant_id AND at.article_id = a.id
|
||||
WHERE a.tenant_id = $1
|
||||
AND ($2::cms_article_status IS NULL OR a.status = $2::cms_article_status)
|
||||
AND ($3::uuid IS NULL OR a.column_id = $3)
|
||||
AND ($4::uuid IS NULL OR at.tag_id = $4)
|
||||
AND ($5::text IS NULL OR a.title ILIKE $5 OR a.slug ILIKE $5 OR COALESCE(a.summary, '') ILIKE $5)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.status.as_deref())
|
||||
.bind(q.column_id)
|
||||
.bind(q.tag_id)
|
||||
.bind(like.as_deref())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let items = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
SELECT
|
||||
a.tenant_id, a.id, a.column_id, a.title, a.slug, a.summary, a.content,
|
||||
a.status::text as status, a.current_version, a.published_at,
|
||||
a.created_at, a.updated_at, a.created_by, a.updated_by
|
||||
FROM cms_articles a
|
||||
WHERE a.tenant_id = $1
|
||||
AND ($2::cms_article_status IS NULL OR a.status = $2::cms_article_status)
|
||||
AND ($3::uuid IS NULL OR a.column_id = $3)
|
||||
AND ($5::text IS NULL OR a.title ILIKE $5 OR a.slug ILIKE $5 OR COALESCE(a.summary, '') ILIKE $5)
|
||||
AND (
|
||||
$4::uuid IS NULL OR EXISTS (
|
||||
SELECT 1 FROM cms_article_tags at
|
||||
WHERE at.tenant_id = a.tenant_id AND at.article_id = a.id AND at.tag_id = $4
|
||||
)
|
||||
)
|
||||
ORDER BY a.updated_at DESC
|
||||
OFFSET $6
|
||||
LIMIT $7
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.status.as_deref())
|
||||
.bind(q.column_id)
|
||||
.bind(q.tag_id)
|
||||
.bind(like.as_deref())
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(super::column::Paged {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish_article(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Article, AppError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let article = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
SELECT
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM cms_articles
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let next_version = article.current_version + 1;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO cms_article_versions (tenant_id, article_id, version, title, summary, content, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::cms_article_status, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(next_version)
|
||||
.bind(&article.title)
|
||||
.bind(&article.summary)
|
||||
.bind(&article.content)
|
||||
.bind("published")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let updated = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
UPDATE cms_articles
|
||||
SET
|
||||
status = 'published',
|
||||
current_version = $3,
|
||||
published_at = COALESCE(published_at, now()),
|
||||
updated_at = now(),
|
||||
updated_by = COALESCE($4, updated_by)
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(next_version)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn rollback_article(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
to_version: i32,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<Article, AppError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let target = sqlx::query_as::<_, ArticleVersion>(
|
||||
r#"
|
||||
SELECT
|
||||
tenant_id, id, article_id, version,
|
||||
title, summary, content, status::text as status,
|
||||
created_at, created_by
|
||||
FROM cms_article_versions
|
||||
WHERE tenant_id = $1 AND article_id = $2 AND version = $3
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(to_version)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let current_version: i32 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT current_version
|
||||
FROM cms_articles
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let next_version = current_version + 1;
|
||||
|
||||
let updated = sqlx::query_as::<_, Article>(
|
||||
r#"
|
||||
UPDATE cms_articles
|
||||
SET
|
||||
title = $3,
|
||||
summary = $4,
|
||||
content = $5,
|
||||
status = $6::cms_article_status,
|
||||
current_version = $7,
|
||||
updated_at = now(),
|
||||
updated_by = COALESCE($8, updated_by)
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING
|
||||
tenant_id, id, column_id, title, slug, summary, content,
|
||||
status::text as status, current_version, published_at,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(&target.title)
|
||||
.bind(&target.summary)
|
||||
.bind(&target.content)
|
||||
.bind(&target.status)
|
||||
.bind(next_version)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO cms_article_versions (tenant_id, article_id, version, title, summary, content, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::cms_article_status, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(next_version)
|
||||
.bind(&updated.title)
|
||||
.bind(&updated.summary)
|
||||
.bind(&updated.content)
|
||||
.bind(&updated.status)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn list_versions(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
article_id: Uuid,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<super::column::Paged<ArticleVersion>, AppError> {
|
||||
let page = page.max(1);
|
||||
let page_size = page_size.clamp(1, 200);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
let limit = page_size as i64;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM cms_article_versions
|
||||
WHERE tenant_id = $1 AND article_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(article_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let items = sqlx::query_as::<_, ArticleVersion>(
|
||||
r#"
|
||||
SELECT
|
||||
tenant_id, id, article_id, version,
|
||||
title, summary, content, status::text as status,
|
||||
created_at, created_by
|
||||
FROM cms_article_versions
|
||||
WHERE tenant_id = $1 AND article_id = $2
|
||||
ORDER BY version DESC
|
||||
OFFSET $3
|
||||
LIMIT $4
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(article_id)
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(super::column::Paged {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
})
|
||||
}
|
||||
174
src/infrastructure/repositories/column.rs
Normal file
174
src/infrastructure/repositories/column.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use crate::domain::models::Column;
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn create_column(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
name: String,
|
||||
slug: String,
|
||||
description: Option<String>,
|
||||
parent_id: Option<Uuid>,
|
||||
sort_order: i32,
|
||||
) -> Result<Column, AppError> {
|
||||
let column = sqlx::query_as::<_, Column>(
|
||||
r#"
|
||||
INSERT INTO cms_columns (tenant_id, name, slug, description, parent_id, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(parent_id)
|
||||
.bind(sort_order)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(column)
|
||||
}
|
||||
|
||||
pub async fn get_column(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<Column, AppError> {
|
||||
let column = sqlx::query_as::<_, Column>(
|
||||
r#"
|
||||
SELECT tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at
|
||||
FROM cms_columns
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(column)
|
||||
}
|
||||
|
||||
pub async fn update_column(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
description: Option<Option<String>>,
|
||||
parent_id: Option<Option<Uuid>>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<Column, AppError> {
|
||||
let column = sqlx::query_as::<_, Column>(
|
||||
r#"
|
||||
UPDATE cms_columns
|
||||
SET
|
||||
name = COALESCE($3, name),
|
||||
slug = COALESCE($4, slug),
|
||||
description = COALESCE($5, description),
|
||||
parent_id = COALESCE($6, parent_id),
|
||||
sort_order = COALESCE($7, sort_order),
|
||||
updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(parent_id)
|
||||
.bind(sort_order)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(column)
|
||||
}
|
||||
|
||||
pub async fn delete_column(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM cms_columns
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("column:not_found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListColumnsQuery {
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub search: Option<String>,
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct Paged<T> {
|
||||
pub items: Vec<T>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
pub async fn list_columns(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
q: ListColumnsQuery,
|
||||
) -> Result<Paged<Column>, AppError> {
|
||||
let page = q.page.max(1);
|
||||
let page_size = q.page_size.clamp(1, 200);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
let limit = page_size as i64;
|
||||
|
||||
let like = q.search.map(|s| format!("%{}%", s));
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM cms_columns
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::uuid IS NULL OR parent_id = $2)
|
||||
AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.parent_id)
|
||||
.bind(like.as_deref())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let items = sqlx::query_as::<_, Column>(
|
||||
r#"
|
||||
SELECT tenant_id, id, name, slug, description, parent_id, sort_order, created_at, updated_at
|
||||
FROM cms_columns
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::uuid IS NULL OR parent_id = $2)
|
||||
AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3)
|
||||
ORDER BY sort_order ASC, updated_at DESC
|
||||
OFFSET $4
|
||||
LIMIT $5
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.parent_id)
|
||||
.bind(like.as_deref())
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(Paged {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
})
|
||||
}
|
||||
124
src/infrastructure/repositories/media.rs
Normal file
124
src/infrastructure/repositories/media.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::domain::models::Media;
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn create_media(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
url: String,
|
||||
mime_type: Option<String>,
|
||||
size_bytes: Option<i64>,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<Media, AppError> {
|
||||
let media = sqlx::query_as::<_, Media>(
|
||||
r#"
|
||||
INSERT INTO cms_media (tenant_id, url, mime_type, size_bytes, width, height, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(url)
|
||||
.bind(mime_type)
|
||||
.bind(size_bytes)
|
||||
.bind(width)
|
||||
.bind(height)
|
||||
.bind(created_by)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
pub async fn get_media(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<Media, AppError> {
|
||||
let media = sqlx::query_as::<_, Media>(
|
||||
r#"
|
||||
SELECT tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by
|
||||
FROM cms_media
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(media)
|
||||
}
|
||||
|
||||
pub async fn delete_media(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM cms_media
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("media:not_found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListMediaQuery {
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_media(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
q: ListMediaQuery,
|
||||
) -> Result<super::column::Paged<Media>, AppError> {
|
||||
let page = q.page.max(1);
|
||||
let page_size = q.page_size.clamp(1, 200);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
let limit = page_size as i64;
|
||||
|
||||
let like = q.search.map(|s| format!("%{}%", s));
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM cms_media
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::text IS NULL OR url ILIKE $2)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(like.as_deref())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let items = sqlx::query_as::<_, Media>(
|
||||
r#"
|
||||
SELECT tenant_id, id, url, mime_type, size_bytes, width, height, created_at, created_by
|
||||
FROM cms_media
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::text IS NULL OR url ILIKE $2)
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT $4
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(like.as_deref())
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(super::column::Paged {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
})
|
||||
}
|
||||
4
src/infrastructure/repositories/mod.rs
Normal file
4
src/infrastructure/repositories/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod article;
|
||||
pub mod column;
|
||||
pub mod media;
|
||||
pub mod tag;
|
||||
150
src/infrastructure/repositories/tag.rs
Normal file
150
src/infrastructure/repositories/tag.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use crate::domain::models::Tag;
|
||||
use common_telemetry::AppError;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn create_tag(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
kind: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
) -> Result<Tag, AppError> {
|
||||
let tag = sqlx::query_as::<_, Tag>(
|
||||
r#"
|
||||
INSERT INTO cms_tags (tenant_id, kind, name, slug)
|
||||
VALUES ($1, $2::cms_tag_kind, $3, $4)
|
||||
RETURNING tenant_id, id, kind::text as kind, name, slug, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
pub async fn get_tag(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<Tag, AppError> {
|
||||
let tag = sqlx::query_as::<_, Tag>(
|
||||
r#"
|
||||
SELECT tenant_id, id, kind::text as kind, name, slug, created_at, updated_at
|
||||
FROM cms_tags
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
) -> Result<Tag, AppError> {
|
||||
let tag = sqlx::query_as::<_, Tag>(
|
||||
r#"
|
||||
UPDATE cms_tags
|
||||
SET
|
||||
name = COALESCE($3, name),
|
||||
slug = COALESCE($4, slug),
|
||||
updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING tenant_id, id, kind::text as kind, name, slug, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
pub async fn delete_tag(pool: &PgPool, tenant_id: Uuid, id: Uuid) -> Result<(), AppError> {
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM cms_tags
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("tag:not_found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListTagsQuery {
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub search: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_tags(
|
||||
pool: &PgPool,
|
||||
tenant_id: Uuid,
|
||||
q: ListTagsQuery,
|
||||
) -> Result<super::column::Paged<Tag>, AppError> {
|
||||
let page = q.page.max(1);
|
||||
let page_size = q.page_size.clamp(1, 200);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
let limit = page_size as i64;
|
||||
|
||||
let like = q.search.map(|s| format!("%{}%", s));
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM cms_tags
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::cms_tag_kind IS NULL OR kind = $2::cms_tag_kind)
|
||||
AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3)
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.kind.as_deref())
|
||||
.bind(like.as_deref())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let items = sqlx::query_as::<_, Tag>(
|
||||
r#"
|
||||
SELECT tenant_id, id, kind::text as kind, name, slug, created_at, updated_at
|
||||
FROM cms_tags
|
||||
WHERE tenant_id = $1
|
||||
AND ($2::cms_tag_kind IS NULL OR kind = $2::cms_tag_kind)
|
||||
AND ($3::text IS NULL OR name ILIKE $3 OR slug ILIKE $3)
|
||||
ORDER BY updated_at DESC
|
||||
OFFSET $4
|
||||
LIMIT $5
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(q.kind.as_deref())
|
||||
.bind(like.as_deref())
|
||||
.bind(offset)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(super::column::Paged {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
})
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod application;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
85
src/main.rs
Normal file
85
src/main.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use axum::middleware::{from_fn, from_fn_with_state};
|
||||
use cms_service::{
|
||||
api::{self, AppState},
|
||||
application::services::CmsServices,
|
||||
config::AppConfig,
|
||||
infrastructure::{db, iam_client::{IamClient, IamClientConfig}},
|
||||
};
|
||||
use common_telemetry::telemetry::{self, TelemetryConfig};
|
||||
use auth_kit::middleware::{tenant::TenantMiddlewareConfig, auth::AuthMiddlewareConfig};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let config = AppConfig::from_env().expect("failed to load config");
|
||||
let _guard = telemetry::init(TelemetryConfig {
|
||||
service_name: config.service_name.clone(),
|
||||
log_level: config.log_level.clone(),
|
||||
log_to_file: config.log_to_file,
|
||||
log_dir: Some(config.log_dir.clone()),
|
||||
log_file: Some(config.log_file_name.clone()),
|
||||
});
|
||||
|
||||
let pool = db::init_pool(&config).await.expect("failed to init db pool");
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("failed to run migrations");
|
||||
|
||||
let state = AppState {
|
||||
services: CmsServices::new(pool),
|
||||
iam_client: IamClient::new(IamClientConfig {
|
||||
base_url: config.iam_base_url.clone(),
|
||||
timeout: Duration::from_millis(config.iam_timeout_ms),
|
||||
cache_ttl: Duration::from_secs(config.iam_cache_ttl_seconds),
|
||||
cache_stale_if_error: Duration::from_secs(config.iam_stale_if_error_seconds),
|
||||
cache_max_entries: config.iam_cache_max_entries,
|
||||
}),
|
||||
};
|
||||
|
||||
let auth_cfg = AuthMiddlewareConfig {
|
||||
skip_exact_paths: vec!["/healthz".to_string()],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
jwt: match &config.jwt_public_key_pem {
|
||||
Some(pem) => auth_kit::jwt::JwtVerifyConfig::rs256_from_pem("iam-service", pem)
|
||||
.expect("invalid JWT_PUBLIC_KEY_PEM"),
|
||||
None => {
|
||||
let jwks_url = config.iam_jwks_url.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}/.well-known/jwks.json",
|
||||
config.iam_base_url.trim_end_matches('/')
|
||||
)
|
||||
});
|
||||
auth_kit::jwt::JwtVerifyConfig::rs256_from_jwks("iam-service", &jwks_url)
|
||||
.expect("invalid IAM_JWKS_URL")
|
||||
}
|
||||
},
|
||||
};
|
||||
let tenant_cfg = TenantMiddlewareConfig {
|
||||
skip_exact_paths: vec!["/healthz".to_string()],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
};
|
||||
|
||||
let app = api::build_router(state)
|
||||
.layer(from_fn_with_state(
|
||||
tenant_cfg,
|
||||
auth_kit::middleware::tenant::resolve_tenant_with_config,
|
||||
))
|
||||
.layer(from_fn_with_state(
|
||||
auth_cfg,
|
||||
auth_kit::middleware::auth::authenticate_with_config,
|
||||
))
|
||||
.layer(from_fn(common_telemetry::axum_middleware::trace_http_request))
|
||||
.layer(from_fn(cms_service::api::middleware::ensure_request_id));
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
tracing::info!("Server started at http://{}", addr);
|
||||
tracing::info!("Docs available at http://{}/scalar", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user