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
}

1
src/application/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod services;

View 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
View 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
View File

@@ -0,0 +1 @@
pub mod models;

73
src/domain/models.rs Normal file
View 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>,
}

View 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
}

View 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)
}
}

View File

@@ -0,0 +1,3 @@
pub mod db;
pub mod iam_client;
pub mod repositories;

View 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,
})
}

View 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,
})
}

View 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,
})
}

View File

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

View 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
View 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
View 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();
}