refactor servers layer

This commit is contained in:
zhongqin
2026-05-15 17:51:59 +08:00
parent 6b5d632ccc
commit a412fe44e5
34 changed files with 3404 additions and 2939 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ description = "A high-performance Headless CMS and API engine built with Rust +
homepage = "https://github.com/snkzhong/raisfast"
[features]
default = ["db-sqlite", "search-tantivy", "plugin-js", "plugin-lua", "plugin-rhai", "openapi"]
default = ["db-sqlite", "search-tantivy", "plugin-js", "plugin-lua", "plugin-rhai", "openapi", "proxy"]
# ── Database backend (pick exactly one) ────────────────────────────
db-sqlite = ["sqlx/sqlite"]
@@ -202,3 +202,7 @@ required-features = ["export-types"]
name = "tauri-app"
path = "src/bin/tauri-app.rs"
required-features = ["tauri"]
[[test]]
name = "proxy_tests"
required-features = ["proxy"]
+4 -528
View File
@@ -15,11 +15,10 @@ use crate::eventbus::EventBus;
use crate::plugins::PluginManager;
use super::{
AccessCheckContext, AccessFilterContext, Advice, Aspect, ColumnDef, DataAfterCreateContext,
DataAfterDeleteContext, DataAfterReadContext, DataAfterUpdateContext, DataBeforeCreateContext,
DataBeforeDeleteContext, DataBeforeReadContext, DataBeforeUpdateContext, EventContext,
HttpAfterContext, HttpBeforeContext, JoinPointId, Layer, Operation, Pointcut, TargetMatcher,
When,
Advice, Aspect, ColumnDef, DataAfterCreateContext, DataAfterDeleteContext,
DataAfterReadContext, DataAfterUpdateContext, DataBeforeCreateContext, DataBeforeDeleteContext,
DataBeforeReadContext, DataBeforeUpdateContext, HttpAfterContext, HttpBeforeContext,
JoinPointId, Layer, Operation, Pointcut, TargetMatcher, When,
};
#[derive(Clone)]
@@ -119,7 +118,6 @@ impl AspectEngine {
/// Returns `(filtered_data, Dispatched)` — caller gets both the typed result and aspect-modified record.
pub async fn before_create<T: Clone + serde::Serialize + serde::de::DeserializeOwned + Send>(
&self,
_pool: &crate::db::Pool,
table: &str,
auth: &crate::middleware::auth::AuthUser,
data: T,
@@ -145,7 +143,6 @@ impl AspectEngine {
/// Returns `(filtered_data, Dispatched)`.
pub async fn before_update<T: Clone + serde::Serialize + serde::de::DeserializeOwned + Send>(
&self,
_pool: &crate::db::Pool,
table: &str,
auth: &crate::middleware::auth::AuthUser,
old_data: &impl serde::Serialize,
@@ -170,7 +167,6 @@ impl AspectEngine {
/// Before-delete: aspect data dispatch.
pub async fn before_delete(
&self,
_pool: &crate::db::Pool,
table: &str,
auth: &crate::middleware::auth::AuthUser,
data: &impl serde::Serialize,
@@ -490,52 +486,6 @@ impl AspectEngine {
Ok(())
}
/// Event Layer: pre-publish interception
///
/// Returns `Ok(true)` to continue publishing, `Ok(false)` if blocked by an aspect.
pub async fn dispatch_event_before_publish(
&self,
event_type: &str,
ctx: &mut EventContext,
) -> Result<bool, anyhow::Error> {
let jp_id = JoinPointId {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
};
let aspects = self.get_aspects(&jp_id, event_type);
for aspect in &aspects {
match aspect.on_event_before_publish(ctx).await {
Ok(Advice::Continue) => continue,
Ok(Advice::Skip) => return Ok(false),
Ok(Advice::Return(_)) => return Ok(false),
Err(e) => return Err(e),
}
}
Ok(true)
}
/// Event Layer: post-publish notification
pub async fn dispatch_event_after_publish(
&self,
event_type: &str,
ctx: &mut EventContext,
) -> Result<(), anyhow::Error> {
let jp_id = JoinPointId {
layer: Layer::Event,
operation: Operation::Publish,
when: When::After,
};
let aspects = self.get_aspects(&jp_id, event_type);
for aspect in &aspects {
match aspect.on_event_after_publish(ctx).await {
Ok(_) => continue,
Err(e) => return Err(e),
}
}
Ok(())
}
/// HTTP Layer: pre-request interception
///
/// Returns `Ok(None)` to continue processing, `Ok(Some(body))` to short-circuit.
@@ -581,52 +531,6 @@ impl AspectEngine {
}
Ok(())
}
/// Access Layer: permission check interception
///
/// Returns `Ok(true)` to allow, `Ok(false)` to deny.
pub async fn dispatch_access_check(
&self,
subject: &str,
ctx: &mut AccessCheckContext,
) -> Result<bool, anyhow::Error> {
let jp_id = JoinPointId {
layer: Layer::Access,
operation: Operation::Check,
when: When::Before,
};
let aspects = self.get_aspects(&jp_id, subject);
for aspect in &aspects {
match aspect.on_access_check(ctx).await {
Ok(Advice::Continue) => continue,
Ok(Advice::Skip) => return Ok(false),
Ok(Advice::Return(_)) => return Ok(false),
Err(e) => return Err(e),
}
}
Ok(true)
}
/// Access Layer: data filtering (query condition injection)
pub async fn dispatch_access_filter(
&self,
table: &str,
ctx: &mut AccessFilterContext,
) -> Result<(), anyhow::Error> {
let jp_id = JoinPointId {
layer: Layer::Access,
operation: Operation::Filter,
when: When::Before,
};
let aspects = self.get_aspects(&jp_id, table);
for aspect in &aspects {
match aspect.on_access_filter(ctx).await {
Ok(_) => continue,
Err(e) => return Err(e),
}
}
Ok(())
}
}
impl Default for AspectEngine {
@@ -1305,358 +1209,6 @@ mod tests {
assert!(!engine.enable("nonexistent"));
}
// ─── Event Layer tests ───
#[tokio::test]
async fn event_before_publish_returns_true_when_no_aspects() {
let engine = AspectEngine::new();
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "post_created".into(),
payload: serde_json::json!({"id": "1"}),
table: Some("posts".into()),
};
let result = engine
.dispatch_event_before_publish("post_created", &mut ctx)
.await;
assert!(result.unwrap());
}
#[tokio::test]
async fn event_before_publish_continue_allows_publish() {
struct LogEventAspect;
#[async_trait::async_trait]
impl Aspect for LogEventAspect {
fn name(&self) -> &str {
"log_event"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_event_before_publish(&self, ctx: &mut EventContext) -> AspectResult {
ctx.payload
.as_object_mut()
.unwrap()
.insert("logged".into(), serde_json::Value::Bool(true));
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(LogEventAspect);
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "post_created".into(),
payload: serde_json::json!({"id": "1"}),
table: Some("posts".into()),
};
let result = engine
.dispatch_event_before_publish("post_created", &mut ctx)
.await;
assert!(result.unwrap());
assert_eq!(ctx.payload["logged"], serde_json::Value::Bool(true));
}
#[tokio::test]
async fn event_before_publish_skip_blocks_publish() {
struct BlockAspect;
#[async_trait::async_trait]
impl Aspect for BlockAspect {
fn name(&self) -> &str {
"block_event"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_event_before_publish(&self, _ctx: &mut EventContext) -> AspectResult {
Ok(Advice::Skip)
}
}
let engine = AspectEngine::new();
engine.register(BlockAspect);
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "post_created".into(),
payload: serde_json::json!({}),
table: None,
};
let result = engine
.dispatch_event_before_publish("post_created", &mut ctx)
.await;
assert!(!result.unwrap());
}
#[tokio::test]
async fn event_after_publish_dispatches() {
struct AfterPublishAspect;
#[async_trait::async_trait]
impl Aspect for AfterPublishAspect {
fn name(&self) -> &str {
"after_publish"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::After,
target: TargetMatcher::All,
}]
}
async fn on_event_after_publish(&self, ctx: &mut EventContext) -> AspectResult {
ctx.payload
.as_object_mut()
.unwrap()
.insert("after_ran".into(), serde_json::Value::Bool(true));
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(AfterPublishAspect);
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "post_created".into(),
payload: serde_json::json!({"id": "1"}),
table: Some("posts".into()),
};
engine
.dispatch_event_after_publish("post_created", &mut ctx)
.await
.unwrap();
assert_eq!(ctx.payload["after_ran"], serde_json::Value::Bool(true));
}
#[tokio::test]
async fn event_target_filtering() {
struct PostsOnlyEventAspect;
#[async_trait::async_trait]
impl Aspect for PostsOnlyEventAspect {
fn name(&self) -> &str {
"posts_event"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
target: TargetMatcher::Tables(vec!["post_created".into()]),
}]
}
async fn on_event_before_publish(&self, ctx: &mut EventContext) -> AspectResult {
ctx.payload
.as_object_mut()
.unwrap()
.insert("posts_only".into(), serde_json::Value::Bool(true));
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(PostsOnlyEventAspect);
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "comment_created".into(),
payload: serde_json::json!({}),
table: Some("comments".into()),
};
engine
.dispatch_event_before_publish("comment_created", &mut ctx)
.await
.unwrap();
assert!(!ctx.payload.as_object().unwrap().contains_key("posts_only"));
}
// ─── Access Layer tests ───
#[tokio::test]
async fn access_check_allows_when_no_aspects() {
let engine = AspectEngine::new();
let mut ctx = AccessCheckContext {
base: BaseContext::new(Some("u1".into()), "default".into(), "now".into()),
route: "/api/v1/admin/posts".into(),
method: "PUT".into(),
table: Some("posts".into()),
action: "update".into(),
};
let result = engine
.dispatch_access_check("posts", &mut ctx)
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn access_check_skip_denies_access() {
struct DenyAspect;
#[async_trait::async_trait]
impl Aspect for DenyAspect {
fn name(&self) -> &str {
"deny_all"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Access,
operation: Operation::Check,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_access_check(&self, _ctx: &mut AccessCheckContext) -> AspectResult {
Ok(Advice::Skip)
}
}
let engine = AspectEngine::new();
engine.register(DenyAspect);
let mut ctx = AccessCheckContext {
base: BaseContext::new(Some("u1".into()), "default".into(), "now".into()),
route: "/api/v1/admin/posts/123".into(),
method: "DELETE".into(),
table: Some("posts".into()),
action: "delete".into(),
};
let result = engine
.dispatch_access_check("posts", &mut ctx)
.await
.unwrap();
assert!(!result);
}
#[tokio::test]
async fn access_check_continue_allows() {
struct AllowAspect;
#[async_trait::async_trait]
impl Aspect for AllowAspect {
fn name(&self) -> &str {
"allow"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Access,
operation: Operation::Check,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_access_check(&self, _ctx: &mut AccessCheckContext) -> AspectResult {
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(AllowAspect);
let mut ctx = AccessCheckContext {
base: BaseContext::new(Some("u1".into()), "default".into(), "now".into()),
route: "/api/v1/posts".into(),
method: "GET".into(),
table: Some("posts".into()),
action: "read".into(),
};
let result = engine
.dispatch_access_check("posts", &mut ctx)
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn access_filter_adds_conditions() {
struct TenantFilterAspect;
#[async_trait::async_trait]
impl Aspect for TenantFilterAspect {
fn name(&self) -> &str {
"tenant_filter"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Access,
operation: Operation::Filter,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_access_filter(&self, ctx: &mut AccessFilterContext) -> AspectResult {
ctx.conditions.push("tenant_id = ?".into());
ctx.params.push(ctx.base.tenant_id.clone());
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(TenantFilterAspect);
let mut ctx = AccessFilterContext {
base: BaseContext::new(None, "t1".into(), "now".into()),
table: "posts".into(),
conditions: vec![],
params: vec![],
};
engine
.dispatch_access_filter("posts", &mut ctx)
.await
.unwrap();
assert!(ctx.conditions.contains(&"tenant_id = ?".to_string()));
assert!(ctx.params.contains(&"t1".to_string()));
}
#[tokio::test]
async fn access_check_error_aborts() {
struct FailCheckAspect;
#[async_trait::async_trait]
impl Aspect for FailCheckAspect {
fn name(&self) -> &str {
"fail_check"
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Access,
operation: Operation::Check,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_access_check(&self, _ctx: &mut AccessCheckContext) -> AspectResult {
Err(anyhow::anyhow!("rbac lookup failed"))
}
}
let engine = AspectEngine::new();
engine.register(FailCheckAspect);
let mut ctx = AccessCheckContext {
base: BaseContext::new(None, "default".into(), "now".into()),
route: "/api/v1/posts".into(),
method: "GET".into(),
table: Some("posts".into()),
action: "read".into(),
};
let result = engine.dispatch_access_check("posts", &mut ctx).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("rbac lookup failed")
);
}
// ─── HTTP Layer tests ───
#[tokio::test]
@@ -1787,80 +1339,4 @@ mod tests {
.unwrap();
assert!(ctx.response_body.is_some());
}
#[tokio::test]
async fn event_priority_order() {
use std::sync::{Arc, Mutex};
let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
struct LateAspect {
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl Aspect for LateAspect {
fn name(&self) -> &str {
"late"
}
fn priority(&self) -> i32 {
100
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_event_before_publish(&self, _ctx: &mut EventContext) -> AspectResult {
self.log.lock().unwrap().push("late".into());
Ok(Advice::Continue)
}
}
struct EarlyAspect {
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl Aspect for EarlyAspect {
fn name(&self) -> &str {
"early"
}
fn priority(&self) -> i32 {
-100
}
fn pointcuts(&self) -> Vec<Pointcut> {
vec![Pointcut {
layer: Layer::Event,
operation: Operation::Publish,
when: When::Before,
target: TargetMatcher::All,
}]
}
async fn on_event_before_publish(&self, _ctx: &mut EventContext) -> AspectResult {
self.log.lock().unwrap().push("early".into());
Ok(Advice::Continue)
}
}
let engine = AspectEngine::new();
engine.register(LateAspect { log: log.clone() });
engine.register(EarlyAspect { log: log.clone() });
let mut ctx = EventContext {
base: BaseContext::new(None, "default".into(), "now".into()),
event_type: "test".into(),
payload: serde_json::json!({}),
table: None,
};
engine
.dispatch_event_before_publish("test", &mut ctx)
.await
.unwrap();
let entries = log.lock().unwrap();
assert_eq!(entries[0], "early");
assert_eq!(entries[1], "late");
}
}
+4
View File
@@ -1,7 +1,10 @@
//! Page-related commands
use serde::{Deserialize, Serialize};
use crate::models::page::PageStatus;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePageCmd {
pub title: String,
pub slug: String,
@@ -19,6 +22,7 @@ pub struct CreatePageCmd {
pub cover_image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatePageCmd {
pub id: i64,
pub title: Option<String>,
+3 -3
View File
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[cfg(feature = "export-types")]
use ts_rs::TS;
use utoipa::ToSchema;
@@ -7,7 +7,7 @@ use validator::Validate;
use super::validate_optional_uuid;
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct CreateCategoryRequest {
#[validate(length(min = 1, max = 100))]
pub name: String,
@@ -18,7 +18,7 @@ pub struct CreateCategoryRequest {
}
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct UpdateCategoryRequest {
#[validate(length(min = 1, max = 100))]
pub name: Option<String>,
+4 -4
View File
@@ -7,7 +7,7 @@ use validator::Validate;
use super::validate_optional_uuid;
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct CreateProductRequest {
#[validate(length(min = 1, max = 200))]
pub title: String,
@@ -39,7 +39,7 @@ pub struct CreateProductRequest {
}
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct UpdateProductRequest {
#[validate(length(min = 1, max = 200))]
pub title: Option<String>,
@@ -73,7 +73,7 @@ pub struct UpdateProductRequest {
}
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Serialize, Deserialize, Clone, Validate, ToSchema)]
pub struct CreateOrderRequest {
#[validate(length(min = 1))]
pub items: Vec<CreateOrderItemRequest>,
@@ -86,7 +86,7 @@ pub struct CreateOrderRequest {
}
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
#[derive(Debug, Serialize, Deserialize, Clone, Validate, ToSchema)]
pub struct CreateOrderItemRequest {
pub product_id: String,
#[validate(range(min = 1))]
+2 -2
View File
@@ -1,11 +1,11 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[cfg(feature = "export-types")]
use ts_rs::TS;
use utoipa::ToSchema;
use validator::Validate;
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct CreateTagRequest {
#[validate(length(min = 1, max = 50))]
pub name: String,
+71
View File
@@ -10,12 +10,19 @@
use serde::{Deserialize, Serialize};
use crate::dto::PostResponse;
use crate::models::category::Category;
use crate::models::comment::Comment;
use crate::models::email_verification::EmailVerificationToken;
use crate::models::media::Media;
use crate::models::order::Order;
use crate::models::page::Page;
use crate::models::password_reset::PasswordResetToken;
use crate::models::payment_order::PaymentOrder;
use crate::models::post::Post;
use crate::models::product::Product;
use crate::models::tag::Tag;
use crate::models::user::User;
use crate::models::wallet_transaction::WalletTransaction;
pub use raisfast_derive::EventMeta;
@@ -40,6 +47,68 @@ pub enum Event {
CommentCreating,
#[event(table = "comments")]
CommentCreated(Comment),
#[event(table = "comments")]
CommentUpdated(Comment),
#[event(table = "comments")]
CommentDeleted(Comment),
// ── Tag lifecycle ──
#[event(table = "tags")]
TagCreated(Tag),
#[event(table = "tags")]
TagUpdated(Tag),
#[event(table = "tags")]
TagDeleted(Tag),
// ── Category lifecycle ──
#[event(table = "categories")]
CategoryCreated(Category),
#[event(table = "categories")]
CategoryUpdated(Category),
#[event(table = "categories")]
CategoryDeleted(Category),
// ── Page lifecycle ──
#[event(table = "pages")]
PageCreated(Page),
#[event(table = "pages")]
PageUpdated(Page),
#[event(table = "pages")]
PageDeleted(Page),
// ── Product lifecycle ──
#[event(table = "products")]
ProductCreated(Product),
#[event(table = "products")]
ProductUpdated(Product),
#[event(table = "products")]
ProductDeleted(Product),
// ── Order lifecycle ──
#[event(table = "orders")]
OrderCreated(Order),
#[event(table = "orders")]
OrderPaid(Order),
#[event(table = "orders")]
OrderShipped(Order),
#[event(table = "orders")]
OrderCompleted(Order),
#[event(table = "orders")]
OrderCancelled(Order),
// ── Payment lifecycle ──
#[event(table = "payment_orders")]
PaymentOrderCreated(PaymentOrder),
#[event(table = "payment_orders")]
PaymentPaid(PaymentOrder),
#[event(table = "payment_orders")]
PaymentRefunded(PaymentOrder),
// ── Wallet lifecycle ──
#[event(table = "wallet_transactions")]
WalletCredited(WalletTransaction),
#[event(table = "wallet_transactions")]
WalletDebited(WalletTransaction),
// ── Generic CMS content lifecycle ──
ContentCreating,
@@ -65,10 +134,12 @@ pub enum Event {
MediaDeleted(Media),
// ── Auth ──
#[event(table = "users")]
PasswordResetRequested {
user: User,
token: PasswordResetToken,
},
#[event(table = "users")]
EmailVerificationRequested {
user_id: i64,
email: String,
+4 -5
View File
@@ -197,7 +197,7 @@ pub async fn register(
validation::validate(&req)?;
let user = auth::register(
state.user_repo.as_ref(),
&state.eventbus,
&state.aspect_engine,
req,
auth.tenant_id(),
state.config.require_email_verification,
@@ -221,8 +221,7 @@ pub async fn login(
let resp = auth::login(
state.user_repo.as_ref(),
state.refresh_token_repo.as_ref(),
&state.plugins,
&state.eventbus,
&state.aspect_engine,
&state.pool,
&req,
&state.config.jwt_secret,
@@ -254,7 +253,7 @@ pub async fn resend_verification(
email_verification::resend_verification(
&state.pool,
state.user_repo.as_ref(),
&state.eventbus,
&state.aspect_engine,
&req.email,
)
.await?;
@@ -312,7 +311,7 @@ pub async fn forgot_password(
password_reset::forgot_password(
&state.pool,
state.user_repo.as_ref(),
&state.eventbus,
&state.aspect_engine,
&req.email,
auth.tenant_id(),
)
+16 -26
View File
@@ -8,7 +8,6 @@ use crate::errors::app_error::AppResult;
use crate::errors::response::{ApiResponse, PaginatedData};
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::services::category;
use crate::utils::pagination::PaginationParams;
pub fn routes(
@@ -130,13 +129,10 @@ pub async fn list(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<crate::models::category::Category>>>
{
params.sanitize();
let (items, total) = category::list_categories_paginated(
state.category_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (items, total) = state
.category_service
.list_paginated(&auth, params.page, params.page_size)
.await?;
Ok(params.paginate(items, total))
}
@@ -150,7 +146,7 @@ pub async fn get(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<crate::models::category::Category>> {
let cat = category::get_category(state.category_repo.as_ref(), &id, &auth).await?;
let cat = state.category_service.get(&id, &auth).await?;
Ok(ApiResponse::success(cat))
}
@@ -167,7 +163,7 @@ pub async fn create(
) -> AppResult<ApiResponse<crate::models::category::Category>> {
auth.ensure_author()?;
validation::validate(&req)?;
let cat = category::create_category(state.category_repo.as_ref(), &auth, req).await?;
let cat = state.category_service.create(&auth, req).await?;
Ok(ApiResponse::success(cat))
}
@@ -186,7 +182,7 @@ pub async fn update(
) -> AppResult<ApiResponse<crate::models::category::Category>> {
auth.ensure_author()?;
validation::validate(&req)?;
let cat = category::update_category(state.category_repo.as_ref(), &auth, &id, req).await?;
let cat = state.category_service.update(&auth, &id, req).await?;
Ok(ApiResponse::success(cat))
}
@@ -202,7 +198,7 @@ pub async fn delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_author()?;
category::delete_category(state.category_repo.as_ref(), &id, &auth).await?;
state.category_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -215,13 +211,10 @@ pub async fn admin_list(
) -> AppResult<ApiResponse<PaginatedData<crate::models::category::Category>>> {
auth.ensure_admin()?;
params.sanitize();
let (items, total) = category::list_categories_paginated(
state.category_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (items, total) = state
.category_service
.list_paginated(&auth, params.page, params.page_size)
.await?;
Ok(params.paginate(items, total))
}
@@ -232,7 +225,7 @@ pub async fn admin_create(
) -> AppResult<ApiResponse<crate::models::category::Category>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let cat = category::create_category(state.category_repo.as_ref(), &auth, req).await?;
let cat = state.category_service.create(&auth, req).await?;
Ok(ApiResponse::success(cat))
}
@@ -244,7 +237,7 @@ pub async fn admin_update(
) -> AppResult<ApiResponse<crate::models::category::Category>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let cat = category::update_category(state.category_repo.as_ref(), &auth, &id, req).await?;
let cat = state.category_service.update(&auth, &id, req).await?;
Ok(ApiResponse::success(cat))
}
@@ -254,7 +247,7 @@ pub async fn admin_delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
category::delete_category(state.category_repo.as_ref(), &id, &auth).await?;
state.category_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -268,10 +261,7 @@ pub async fn admin_batch(
let mut affected = 0usize;
if req.action == "delete" {
for id in &req.ids {
if category::delete_category(state.category_repo.as_ref(), id, &auth)
.await
.is_ok()
{
if state.category_service.delete(id, &auth).await.is_ok() {
affected += 1;
}
}
+40 -52
View File
@@ -15,7 +15,6 @@ use crate::errors::response::{ApiResponse, PaginatedData};
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::models::comment::CommentStatus;
use crate::services::comment as comment_service;
use crate::utils::pagination::PaginationParams;
pub fn routes(
@@ -152,15 +151,10 @@ pub async fn list(
> {
let mut p = params;
p.sanitize();
let (comments, total) = comment_service::list_comments_paginated(
state.post_repo.as_ref(),
state.comment_repo.as_ref(),
&slug,
p.page,
p.page_size,
&auth,
)
.await?;
let (comments, total) = state
.comment_service
.list_paginated(&slug, p.page, p.page_size, &auth)
.await?;
Ok(p.paginate(comments, total))
}
@@ -199,19 +193,17 @@ pub async fn create(
) -> AppResult<ApiResponse<crate::models::comment::CommentResponse>> {
validation::validate(&req)?;
let comment = comment_service::create_comment(
state.post_repo.as_ref(),
state.comment_repo.as_ref(),
&state.plugins,
&state.eventbus,
&slug,
&auth,
&req.content,
req.parent_id.as_deref(),
None,
None,
)
.await?;
let comment = state
.comment_service
.create(
&slug,
&auth,
&req.content,
req.parent_id.as_deref(),
None,
None,
)
.await?;
Ok(ApiResponse::success(comment))
}
@@ -235,19 +227,17 @@ pub async fn create_guest(
.as_deref()
.ok_or_else(|| AppError::BadRequest("nickname_required".into()))?;
let comment = comment_service::create_comment(
state.post_repo.as_ref(),
state.comment_repo.as_ref(),
&state.plugins,
&state.eventbus,
&slug,
&auth,
&req.content,
req.parent_id.as_deref(),
Some(nickname),
req.email.as_deref(),
)
.await?;
let comment = state
.comment_service
.create(
&slug,
&auth,
&req.content,
req.parent_id.as_deref(),
Some(nickname),
req.email.as_deref(),
)
.await?;
Ok(ApiResponse::success(comment))
}
@@ -263,7 +253,7 @@ pub async fn delete(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
comment_service::delete_comment(state.comment_repo.as_ref(), &id, &auth).await?;
state.comment_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -282,7 +272,9 @@ pub async fn update_status(
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
validation::validate(&req)?;
comment_service::update_comment_status(state.comment_repo.as_ref(), &id, req.status, &auth)
state
.comment_service
.update_status(&id, req.status, &auth)
.await?;
Ok(ApiResponse::success(()))
}
@@ -321,7 +313,9 @@ pub async fn admin_update_status(
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
validation::validate(&req)?;
comment_service::update_comment_status(state.comment_repo.as_ref(), &id, req.status, &auth)
state
.comment_service
.update_status(&id, req.status, &auth)
.await?;
Ok(ApiResponse::success(()))
}
@@ -337,7 +331,7 @@ pub async fn admin_delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
comment_service::delete_comment(state.comment_repo.as_ref(), &id, &auth).await?;
state.comment_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -357,10 +351,7 @@ pub async fn admin_batch(
for id in &req.ids {
match req.action.as_str() {
"delete" => {
if comment_service::delete_comment(state.comment_repo.as_ref(), id, &auth)
.await
.is_ok()
{
if state.comment_service.delete(id, &auth).await.is_ok() {
affected += 1;
}
}
@@ -371,14 +362,11 @@ pub async fn admin_batch(
"spam" => CommentStatus::Spam,
_ => unreachable!(),
};
if comment_service::update_comment_status(
state.comment_repo.as_ref(),
id,
status,
&auth,
)
.await
.is_ok()
if state
.comment_service
.update_status(id, status, &auth)
.await
.is_ok()
{
affected += 1;
}
+1 -1
View File
@@ -145,7 +145,7 @@ pub async fn callback(
&state.config.jwt_secret,
state.config.jwt_access_expires,
state.config.jwt_refresh_expires,
&state.eventbus,
&state.aspect_engine,
)
.await?;
+25 -50
View File
@@ -9,7 +9,6 @@ use crate::errors::app_error::AppResult;
use crate::errors::response::ApiResponse;
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::services::order;
use crate::utils::pagination::PaginationParams;
pub fn routes(
@@ -197,15 +196,7 @@ pub async fn create_order(
.user_int_id()
.ok_or(crate::errors::app_error::AppError::Unauthorized)?;
validation::validate(&req)?;
let o = order::create_order(
&state.pool,
state.product_repo.as_ref(),
state.order_repo.as_ref(),
&auth,
user_int_id,
req,
)
.await?;
let o = state.order_service.create(&auth, user_int_id, req).await?;
let items = state
.order_repo
.find_items_by_order_id(o.id, auth.tenant_id())
@@ -227,14 +218,10 @@ pub async fn list_orders(
.user_int_id()
.ok_or(crate::errors::app_error::AppError::Unauthorized)?;
params.sanitize();
let (orders, total) = order::list_user_orders(
state.order_repo.as_ref(),
&auth,
user_int_id,
params.page,
params.page_size,
)
.await?;
let (orders, total) = state
.order_service
.list_user(&auth, user_int_id, params.page, params.page_size)
.await?;
let mut responses = Vec::new();
for o in orders {
let items = state
@@ -257,7 +244,7 @@ pub async fn get_order(
Path(id): Path<String>,
) -> AppResult<ApiResponse<OrderResponse>> {
auth.ensure_authenticated()?;
let (o, items) = order::get_order(state.order_repo.as_ref(), &auth, &id).await?;
let (o, items) = state.order_service.get(&auth, &id).await?;
Ok(ApiResponse::success(to_order_response(o, items)))
}
@@ -277,14 +264,7 @@ pub async fn cancel_order_handler(
let user_int_id = auth
.user_int_id()
.ok_or(crate::errors::app_error::AppError::Unauthorized)?;
order::cancel_order(
&state.pool,
state.order_repo.as_ref(),
&auth,
&id,
user_int_id,
)
.await?;
state.order_service.cancel(&auth, &id, user_int_id).await?;
Ok(ApiResponse::success(()))
}
@@ -302,14 +282,10 @@ pub async fn confirm_receipt(
let user_int_id = auth
.user_int_id()
.ok_or(crate::errors::app_error::AppError::Unauthorized)?;
order::confirm_receipt(
&state.pool,
state.order_repo.as_ref(),
&auth,
&id,
user_int_id,
)
.await?;
state
.order_service
.confirm_receipt(&auth, &id, user_int_id)
.await?;
Ok(ApiResponse::success(()))
}
@@ -324,14 +300,10 @@ pub async fn admin_list(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<OrderResponse>>> {
auth.ensure_admin()?;
params.sanitize();
let (orders, total) = order::list_admin_orders(
state.order_repo.as_ref(),
&auth,
params.page,
params.page_size,
None,
)
.await?;
let (orders, total) = state
.order_service
.list_admin(&auth, params.page, params.page_size, None)
.await?;
let mut responses = Vec::new();
for o in orders {
let items = state
@@ -354,7 +326,7 @@ pub async fn admin_get(
Path(id): Path<String>,
) -> AppResult<ApiResponse<OrderResponse>> {
auth.ensure_admin()?;
let (o, items) = order::get_order(state.order_repo.as_ref(), &auth, &id).await?;
let (o, items) = state.order_service.get(&auth, &id).await?;
Ok(ApiResponse::success(to_order_response(o, items)))
}
@@ -371,7 +343,7 @@ pub async fn admin_ship(
Json(req): Json<ShipOrderRequest>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
order::ship_order(&state.pool, state.order_repo.as_ref(), &auth, &id, &req).await?;
state.order_service.ship(&auth, &id, &req).await?;
Ok(ApiResponse::success(()))
}
@@ -386,7 +358,7 @@ pub async fn admin_cancel(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
order::admin_cancel(&state.pool, state.order_repo.as_ref(), &auth, &id).await?;
state.order_service.admin_cancel(&auth, &id).await?;
Ok(ApiResponse::success(()))
}
@@ -401,7 +373,7 @@ pub async fn admin_pay(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
order::mark_paid(&state.pool, state.order_repo.as_ref(), &auth, &id).await?;
state.order_service.mark_paid(&auth, &id).await?;
Ok(ApiResponse::success(()))
}
@@ -416,7 +388,7 @@ pub async fn admin_refund(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
order::refund_order(&state.pool, state.order_repo.as_ref(), &auth, &id).await?;
state.order_service.refund(&auth, &id).await?;
Ok(ApiResponse::success(()))
}
@@ -433,7 +405,10 @@ pub async fn admin_update_remark(
Json(req): Json<UpdateAdminRemarkRequest>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
order::update_admin_remark(state.order_repo.as_ref(), &auth, &id, &req.admin_remark).await?;
state
.order_service
.update_admin_remark(&auth, &id, &req.admin_remark)
.await?;
Ok(ApiResponse::success(()))
}
@@ -446,6 +421,6 @@ pub async fn admin_stats(
State(state): State<crate::AppState>,
) -> AppResult<ApiResponse<OrderStatsResponse>> {
auth.ensure_admin()?;
let stats = order::get_stats(&state.pool, &auth).await?;
let stats = state.order_service.get_stats(&auth).await?;
Ok(ApiResponse::success(stats))
}
+26 -25
View File
@@ -16,7 +16,7 @@ use crate::errors::response::{ApiResponse, PaginatedData};
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::models::page::PageStatus;
use crate::services::{page as page_service, post::resolve_doc_id_to_int};
use crate::services::post::resolve_doc_id_to_int;
use crate::utils::pagination::PaginationParams;
pub fn routes(
@@ -248,9 +248,10 @@ pub async fn list(
) -> AppResult<ApiResponse<PaginatedData<crate::models::page::Page>>> {
let pagination = PaginationParams::from_options(query.page, query.page_size);
let (items, total) =
page_service::list_published(&state.pool, pagination.page, pagination.page_size, &auth)
.await?;
let (items, total) = state
.page_service
.list_published(pagination.page, pagination.page_size, &auth)
.await?;
Ok(pagination.paginate(items, total))
}
@@ -264,7 +265,7 @@ pub async fn get_by_slug(
State(state): State<crate::AppState>,
Path(slug): Path<String>,
) -> AppResult<ApiResponse<crate::models::page::Page>> {
let page = page_service::get_by_slug(&state.pool, &slug, &auth).await?;
let page = state.page_service.get_by_slug(&slug, &auth).await?;
Ok(ApiResponse::success(page))
}
@@ -275,7 +276,9 @@ pub async fn sitemap(
auth: AuthUser,
State(state): State<crate::AppState>,
) -> AppResult<ApiResponse<Vec<SitemapEntry>>> {
let entries = page_service::sitemap(&state.pool, &auth)
let entries = state
.page_service
.sitemap(&auth)
.await?
.into_iter()
.map(|(slug, updated_at)| SitemapEntry { slug, updated_at })
@@ -297,14 +300,10 @@ pub async fn admin_list(
auth.ensure_author()?;
let pagination = PaginationParams::from_options(query.page, query.page_size);
let (items, total) = page_service::list_all(
&state.pool,
pagination.page,
pagination.page_size,
query.status,
&auth,
)
.await?;
let (items, total) = state
.page_service
.list_all(pagination.page, pagination.page_size, query.status, &auth)
.await?;
Ok(pagination.paginate(items, total))
}
@@ -320,7 +319,7 @@ pub async fn admin_get(
Path(id): Path<String>,
) -> AppResult<ApiResponse<crate::models::page::Page>> {
auth.ensure_author()?;
let page = page_service::get_by_id(&state.pool, &id, &auth).await?;
let page = state.page_service.get_by_id(&id, &auth).await?;
Ok(ApiResponse::success(page))
}
@@ -360,7 +359,7 @@ pub async fn create(
cover_image: req.cover_image,
};
let page = page_service::create_page(&state.pool, &auth, cmd).await?;
let page = state.page_service.create_page(&auth, cmd).await?;
Ok(ApiResponse::success(page))
}
@@ -396,7 +395,7 @@ pub async fn update(
updated_by: auth.user_int_id(),
};
let page = page_service::update_page(&state.pool, &auth, &id, cmd).await?;
let page = state.page_service.update_page(&auth, &id, cmd).await?;
Ok(ApiResponse::success(page))
}
@@ -411,7 +410,7 @@ pub async fn delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_author()?;
page_service::delete_page(&state.pool, &id, &auth).await?;
state.page_service.delete_page(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -427,7 +426,10 @@ pub async fn update_status(
Json(req): Json<UpdateStatusRequest>,
) -> AppResult<ApiResponse<crate::models::page::Page>> {
auth.ensure_author()?;
let page = page_service::update_status(&state.pool, &id, req.status, &auth).await?;
let page = state
.page_service
.update_status(&id, req.status, &auth)
.await?;
Ok(ApiResponse::success(page))
}
@@ -446,7 +448,7 @@ pub async fn reorder(
.into_iter()
.map(|i| (i.id, i.sort_order))
.collect();
page_service::reorder(&state.pool, items, &auth).await?;
state.page_service.reorder(items, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -466,10 +468,7 @@ pub async fn admin_batch(
for id in &req.ids {
match req.action.as_str() {
"delete" => {
if page_service::delete_page(&state.pool, id, &auth)
.await
.is_ok()
{
if state.page_service.delete_page(id, &auth).await.is_ok() {
affected += 1;
}
}
@@ -479,7 +478,9 @@ pub async fn admin_batch(
} else {
PageStatus::Draft
};
if page_service::update_status(&state.pool, id, status, &auth)
if state
.page_service
.update_status(id, status, &auth)
.await
.is_ok()
{
+69 -136
View File
@@ -3,13 +3,11 @@ use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use crate::audit::AuditService;
use crate::dto::payment::*;
use crate::errors::app_error::{AppError, AppResult};
use crate::errors::response::ApiResponse;
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::services::payment;
use crate::utils::pagination::PaginationParams;
pub fn routes(
@@ -265,21 +263,17 @@ pub async fn create_payment_order_handler(
let client_ip = extract_client_ip(&headers);
let client_language = extract_accept_language(&headers);
let client_user_agent = extract_user_agent(&headers);
let (order, provider_resp) = payment::create_payment_order(
&state.pool,
state.payment_channel_repo.as_ref(),
state.payment_order_repo.as_ref(),
state.product_repo.as_ref(),
state.order_repo.as_ref(),
&auth,
user_int_id,
req,
&state.config,
client_ip.as_deref(),
client_language.as_deref(),
client_user_agent.as_deref(),
)
.await?;
let (order, provider_resp) = state
.payment_service
.create_payment_order(
&auth,
user_int_id,
req,
client_ip.as_deref(),
client_language.as_deref(),
client_user_agent.as_deref(),
)
.await?;
Ok(ApiResponse::success(to_order_response_with_provider(
order,
provider_resp,
@@ -298,14 +292,10 @@ pub async fn list_user_orders(
let _user_id = auth.ensure_authenticated()?;
let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?;
params.sanitize();
let (orders, total) = payment::list_user_payment_orders(
state.payment_order_repo.as_ref(),
&auth,
user_int_id,
params.page,
params.page_size,
)
.await?;
let (orders, total) = state
.payment_service
.list_user_payment_orders(&auth, user_int_id, params.page, params.page_size)
.await?;
let responses: Vec<PaymentOrderResponse> = orders.into_iter().map(to_order_response).collect();
Ok(params.paginate(responses, total))
}
@@ -322,9 +312,10 @@ pub async fn get_payment_order_handler(
) -> AppResult<ApiResponse<PaymentOrderResponse>> {
let _user_id = auth.ensure_authenticated()?;
let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?;
let order =
payment::get_payment_order(state.payment_order_repo.as_ref(), &auth, user_int_id, &id)
.await?;
let order = state
.payment_service
.get_payment_order(&auth, user_int_id, &id)
.await?;
Ok(ApiResponse::success(to_order_response(order)))
}
@@ -340,18 +331,10 @@ pub async fn cancel_payment_order_handler(
) -> AppResult<ApiResponse<()>> {
let _user_id = auth.ensure_authenticated()?;
let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?;
let audit = AuditService::new(state.pool.clone());
payment::cancel_payment_order(
&state.pool,
state.payment_order_repo.as_ref(),
state.payment_channel_repo.as_ref(),
&auth,
&audit,
&state.config,
&id,
user_int_id,
)
.await?;
state
.payment_service
.cancel_payment_order(&auth, &id, user_int_id)
.await?;
Ok(ApiResponse::success(()))
}
@@ -367,12 +350,9 @@ pub async fn list_order_transactions(
) -> AppResult<ApiResponse<Vec<PaymentTransactionResponse>>> {
let _ = auth.ensure_authenticated()?;
let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?;
let order =
payment::get_payment_order(state.payment_order_repo.as_ref(), &auth, user_int_id, &id)
.await?;
let txs = state
.payment_tx_repo
.find_by_payment_order_id(order.id, auth.tenant_id())
.payment_service
.list_order_transactions(&auth, user_int_id, &id)
.await?;
let responses: Vec<PaymentTransactionResponse> = txs.into_iter().map(Into::into).collect();
Ok(ApiResponse::success(responses))
@@ -390,12 +370,9 @@ pub async fn list_order_refunds(
) -> AppResult<ApiResponse<Vec<PaymentRefundResponse>>> {
let _ = auth.ensure_authenticated()?;
let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?;
let order =
payment::get_payment_order(state.payment_order_repo.as_ref(), &auth, user_int_id, &id)
.await?;
let refunds = state
.payment_refund_repo
.find_by_payment_order_id(order.id, auth.tenant_id())
.payment_service
.list_order_refunds(&auth, user_int_id, &id)
.await?;
let responses: Vec<PaymentRefundResponse> = refunds.into_iter().map(Into::into).collect();
Ok(ApiResponse::success(responses))
@@ -412,20 +389,10 @@ pub async fn handle_callback(
headers: HeaderMap,
body: Bytes,
) -> AppResult<ApiResponse<()>> {
let audit = AuditService::new(state.pool.clone());
payment::handle_callback(
&state.pool,
state.payment_channel_repo.as_ref(),
state.payment_order_repo.as_ref(),
state.payment_tx_repo.as_ref(),
state.wallet_repo.as_ref(),
&audit,
&state.config,
&channel_doc_id,
&headers,
&body,
)
.await?;
state
.payment_service
.handle_callback(&channel_doc_id, &headers, &body)
.await?;
Ok(ApiResponse::success(()))
}
@@ -467,15 +434,15 @@ pub async fn list_available_channels_handler(
State(state): State<crate::AppState>,
Query(query): Query<AvailableChannelsQuery>,
) -> AppResult<ApiResponse<AvailableChannelsResponse>> {
let result = payment::list_available_channels(
state.payment_channel_repo.as_ref(),
state.order_repo.as_ref(),
&auth,
&query.order_id,
query.country.as_deref(),
query.language.as_deref(),
)
.await?;
let result = state
.payment_service
.list_available_channels(
&auth,
&query.order_id,
query.country.as_deref(),
query.language.as_deref(),
)
.await?;
Ok(ApiResponse::success(result))
}
@@ -491,8 +458,8 @@ pub async fn admin_list_channels(
auth.ensure_admin()?;
params.sanitize();
let (channels, total) = state
.payment_channel_repo
.find_all_admin_paginated(auth.tenant_id(), params.page, params.page_size, None)
.payment_service
.list_admin_channels(&auth, params.page, params.page_size)
.await?;
let responses: Vec<PaymentChannelResponse> = channels.into_iter().map(Into::into).collect();
Ok(params.paginate(responses, total))
@@ -509,15 +476,7 @@ pub async fn admin_create_channel(
Json(req): Json<CreatePaymentChannelRequest>,
) -> AppResult<ApiResponse<PaymentChannelResponse>> {
validation::validate(&req)?;
let audit = AuditService::new(state.pool.clone());
let channel = payment::create_channel(
state.payment_channel_repo.as_ref(),
&auth,
&state.config,
&audit,
req,
)
.await?;
let channel = state.payment_service.create_channel(&auth, req).await?;
Ok(ApiResponse::success(PaymentChannelResponse::from(channel)))
}
@@ -531,7 +490,7 @@ pub async fn admin_get_channel(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<PaymentChannelResponse>> {
let channel = payment::get_channel(state.payment_channel_repo.as_ref(), &auth, &id).await?;
let channel = state.payment_service.get_channel(&auth, &id).await?;
Ok(ApiResponse::success(PaymentChannelResponse::from(channel)))
}
@@ -548,16 +507,10 @@ pub async fn admin_update_channel(
Json(req): Json<UpdatePaymentChannelRequest>,
) -> AppResult<ApiResponse<PaymentChannelResponse>> {
validation::validate(&req)?;
let audit = AuditService::new(state.pool.clone());
let channel = payment::update_channel(
state.payment_channel_repo.as_ref(),
&auth,
&state.config,
&audit,
&id,
req,
)
.await?;
let channel = state
.payment_service
.update_channel(&auth, &id, req)
.await?;
Ok(ApiResponse::success(PaymentChannelResponse::from(channel)))
}
@@ -571,8 +524,7 @@ pub async fn admin_delete_channel(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
let audit = AuditService::new(state.pool.clone());
payment::delete_channel(state.payment_channel_repo.as_ref(), &auth, &audit, &id).await?;
state.payment_service.delete_channel(&auth, &id).await?;
Ok(ApiResponse::success(()))
}
@@ -587,14 +539,10 @@ pub async fn admin_list_orders(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<PaymentOrderResponse>>> {
auth.ensure_admin()?;
params.sanitize();
let (orders, total) = payment::list_admin_payment_orders(
state.payment_order_repo.as_ref(),
&auth,
params.page,
params.page_size,
None,
)
.await?;
let (orders, total) = state
.payment_service
.list_admin_payment_orders(&auth, params.page, params.page_size, None)
.await?;
let responses: Vec<PaymentOrderResponse> = orders.into_iter().map(to_order_response).collect();
Ok(params.paginate(responses, total))
}
@@ -610,8 +558,10 @@ pub async fn admin_get_order(
Path(id): Path<String>,
) -> AppResult<ApiResponse<PaymentOrderResponse>> {
auth.ensure_admin()?;
let order =
payment::get_payment_order(state.payment_order_repo.as_ref(), &auth, 0, &id).await?;
let order = state
.payment_service
.get_payment_order(&auth, 0, &id)
.await?;
Ok(ApiResponse::success(to_order_response(order)))
}
@@ -629,21 +579,10 @@ pub async fn admin_refund_order(
) -> AppResult<ApiResponse<PaymentRefundResponse>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let audit = AuditService::new(state.pool.clone());
let refund = payment::refund_payment_order(
&state.pool,
state.payment_order_repo.as_ref(),
state.payment_channel_repo.as_ref(),
state.payment_tx_repo.as_ref(),
state.payment_refund_repo.as_ref(),
state.wallet_repo.as_ref(),
&auth,
&audit,
&state.config,
&id,
req,
)
.await?;
let refund = state
.payment_service
.refund_payment_order(&auth, &id, req)
.await?;
Ok(ApiResponse::success(PaymentRefundResponse::from(refund)))
}
@@ -658,13 +597,10 @@ pub async fn admin_list_transactions(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<PaymentTransactionResponse>>> {
auth.ensure_admin()?;
params.sanitize();
let (txs, total) = payment::list_admin_transactions(
state.payment_tx_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (txs, total) = state
.payment_service
.list_admin_transactions(&auth, params.page, params.page_size)
.await?;
let responses: Vec<PaymentTransactionResponse> = txs.into_iter().map(Into::into).collect();
Ok(params.paginate(responses, total))
}
@@ -680,13 +616,10 @@ pub async fn admin_list_refunds(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<PaymentRefundResponse>>> {
auth.ensure_admin()?;
params.sanitize();
let (refunds, total) = payment::list_admin_refunds(
state.payment_refund_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (refunds, total) = state
.payment_service
.list_admin_refunds(&auth, params.page, params.page_size)
.await?;
let responses: Vec<PaymentRefundResponse> = refunds.into_iter().map(Into::into).collect();
Ok(params.paginate(responses, total))
}
+12 -20
View File
@@ -3,7 +3,6 @@ use crate::errors::app_error::AppResult;
use crate::errors::response::ApiResponse;
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::services::product;
use crate::utils::pagination::PaginationParams;
use axum::Json;
use axum::extract::{Path, Query, State};
@@ -86,13 +85,10 @@ pub async fn list_active(
Query(mut params): Query<PaginationParams>,
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<ProductResponse>>> {
params.sanitize();
let (items, total) = product::list_active_products(
state.product_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (items, total) = state
.product_service
.list_active(&auth, params.page, params.page_size)
.await?;
let resp: Vec<ProductResponse> = items.into_iter().map(Into::into).collect();
Ok(params.paginate(resp, total))
}
@@ -107,7 +103,7 @@ pub async fn get_product(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<ProductResponse>> {
let p = product::get_product(state.product_repo.as_ref(), &id, &auth).await?;
let p = state.product_service.get(&id, &auth).await?;
Ok(ApiResponse::success(ProductResponse::from(p)))
}
@@ -122,14 +118,10 @@ pub async fn admin_list(
) -> AppResult<ApiResponse<crate::errors::response::PaginatedData<ProductResponse>>> {
auth.ensure_admin()?;
params.sanitize();
let (items, total) = product::list_admin_products(
state.product_repo.as_ref(),
&auth,
params.page,
params.page_size,
None,
)
.await?;
let (items, total) = state
.product_service
.list_admin(&auth, params.page, params.page_size, None)
.await?;
let resp: Vec<ProductResponse> = items.into_iter().map(Into::into).collect();
Ok(params.paginate(resp, total))
}
@@ -146,7 +138,7 @@ pub async fn admin_create(
) -> AppResult<ApiResponse<ProductResponse>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let p = product::create_product(state.product_repo.as_ref(), &auth, req).await?;
let p = state.product_service.create(&auth, req).await?;
Ok(ApiResponse::success(ProductResponse::from(p)))
}
@@ -164,7 +156,7 @@ pub async fn admin_update(
) -> AppResult<ApiResponse<ProductResponse>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let p = product::update_product(state.product_repo.as_ref(), &auth, &id, req).await?;
let p = state.product_service.update(&auth, &id, req).await?;
Ok(ApiResponse::success(ProductResponse::from(p)))
}
@@ -179,6 +171,6 @@ pub async fn admin_delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
product::delete_product(state.product_repo.as_ref(), &id, &auth).await?;
state.product_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
+24 -41
View File
@@ -8,20 +8,8 @@ use crate::errors::app_error::AppResult;
use crate::errors::response::{ApiResponse, PaginatedData};
use crate::errors::validation;
use crate::middleware::auth::AuthUser;
use crate::services::tag;
use crate::utils::pagination::PaginationParams;
async fn dispatch_slug_for_tag(state: &crate::AppState, auth: &AuthUser, name: &str) -> String {
match state
.aspect_engine
.before_create(&state.pool, "tags", auth, serde_json::json!({"name": name}))
.await
{
Ok((_, d)) => d.str_or("slug", || tag::generate_slug(name)),
Err(_) => tag::generate_slug(name),
}
}
pub fn routes(
registry: &mut crate::server::RouteRegistry,
config: &crate::config::app::AppConfig,
@@ -140,13 +128,10 @@ pub async fn list(
Query(mut params): Query<PaginationParams>,
) -> AppResult<ApiResponse<PaginatedData<crate::models::tag::Tag>>> {
params.sanitize();
let (items, total) = tag::list_tags_paginated(
state.tag_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (items, total) = state
.tag_service
.list_paginated(&auth, params.page, params.page_size)
.await?;
Ok(params.paginate(items, total))
}
@@ -160,7 +145,7 @@ pub async fn get(
State(state): State<crate::AppState>,
Path(id): Path<String>,
) -> AppResult<ApiResponse<crate::models::tag::Tag>> {
let t = tag::get_tag(state.tag_repo.as_ref(), &id, &auth).await?;
let t = state.tag_service.get(&id, &auth).await?;
Ok(ApiResponse::success(t))
}
@@ -177,8 +162,7 @@ pub async fn create(
) -> AppResult<ApiResponse<crate::models::tag::Tag>> {
auth.ensure_author()?;
validation::validate(&req)?;
let slug = dispatch_slug_for_tag(&state, &auth, &req.name).await;
let t = tag::create_tag(state.tag_repo.as_ref(), &auth, req, slug).await?;
let t = state.tag_service.create(&auth, req).await?;
Ok(ApiResponse::success(t))
}
@@ -194,7 +178,7 @@ pub async fn delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_author()?;
tag::delete_tag(state.tag_repo.as_ref(), &id, &auth).await?;
state.tag_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -213,8 +197,11 @@ pub async fn update(
) -> AppResult<ApiResponse<crate::models::tag::Tag>> {
auth.ensure_author()?;
validation::validate(&req)?;
let slug = dispatch_slug_for_tag(&state, &auth, &req.name).await;
let t = tag::update_tag(state.tag_repo.as_ref(), &id, &auth, req.name.clone(), slug).await?;
let slug = crate::services::tag::generate_slug(&req.name);
let t = state
.tag_service
.update(&auth, &id, req.name.clone(), slug)
.await?;
Ok(ApiResponse::success(t))
}
@@ -227,13 +214,10 @@ pub async fn admin_list(
) -> AppResult<ApiResponse<PaginatedData<crate::models::tag::Tag>>> {
auth.ensure_admin()?;
params.sanitize();
let (items, total) = tag::list_tags_paginated(
state.tag_repo.as_ref(),
&auth,
params.page,
params.page_size,
)
.await?;
let (items, total) = state
.tag_service
.list_paginated(&auth, params.page, params.page_size)
.await?;
Ok(params.paginate(items, total))
}
@@ -244,8 +228,7 @@ pub async fn admin_create(
) -> AppResult<ApiResponse<crate::models::tag::Tag>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let slug = dispatch_slug_for_tag(&state, &auth, &req.name).await;
let t = tag::create_tag(state.tag_repo.as_ref(), &auth, req, slug).await?;
let t = state.tag_service.create(&auth, req).await?;
Ok(ApiResponse::success(t))
}
@@ -257,8 +240,11 @@ pub async fn admin_update(
) -> AppResult<ApiResponse<crate::models::tag::Tag>> {
auth.ensure_admin()?;
validation::validate(&req)?;
let slug = dispatch_slug_for_tag(&state, &auth, &req.name).await;
let t = tag::update_tag(state.tag_repo.as_ref(), &id, &auth, req.name.clone(), slug).await?;
let slug = crate::services::tag::generate_slug(&req.name);
let t = state
.tag_service
.update(&auth, &id, req.name.clone(), slug)
.await?;
Ok(ApiResponse::success(t))
}
@@ -268,7 +254,7 @@ pub async fn admin_delete(
Path(id): Path<String>,
) -> AppResult<ApiResponse<()>> {
auth.ensure_admin()?;
tag::delete_tag(state.tag_repo.as_ref(), &id, &auth).await?;
state.tag_service.delete(&id, &auth).await?;
Ok(ApiResponse::success(()))
}
@@ -282,10 +268,7 @@ pub async fn admin_batch(
let mut affected = 0usize;
if req.action == "delete" {
for id in &req.ids {
if tag::delete_tag(state.tag_repo.as_ref(), id, &auth)
.await
.is_ok()
{
if state.tag_service.delete(id, &auth).await.is_ok() {
affected += 1;
}
}
+38 -46
View File
@@ -203,8 +203,7 @@ pub async fn list_transactions(
.find_transactions_by_wallet(w.id, params.page, params.page_size)
.await?;
let items =
crate::services::wallet::tx_list_to_response(state.wallet_repo.as_ref(), rows).await?;
let items = state.wallet_service.tx_list_to_response(rows).await?;
Ok(params.paginate(items, total))
}
@@ -230,8 +229,7 @@ pub async fn list_all_transactions(
.find_transactions_by_user(user.id, params.page, params.page_size)
.await?;
let items =
crate::services::wallet::tx_list_to_response(state.wallet_repo.as_ref(), rows).await?;
let items = state.wallet_service.tx_list_to_response(rows).await?;
Ok(params.paginate(items, total))
}
@@ -275,8 +273,7 @@ pub async fn list_all_transactions_admin(
.wallet_repo
.find_all_transactions(params.page, params.page_size, auth.tenant_id())
.await?;
let items =
crate::services::wallet::tx_list_to_response(state.wallet_repo.as_ref(), rows).await?;
let items = state.wallet_service.tx_list_to_response(rows).await?;
Ok(params.paginate(items, total))
}
@@ -296,21 +293,21 @@ pub async fn admin_credit(
.await?
.ok_or_else(|| AppError::not_found("user"))?;
let tx = crate::services::wallet::credit_wallet(
state.wallet_repo.as_ref(),
&state.pool,
user.id,
&req.currency,
req.amount,
WalletTxType::Recharge,
&req.transaction_no,
req.reference_type.or(Some(WalletReferenceType::Admin)),
req.reference_id.as_deref(),
req.metadata.as_deref(),
)
.await?;
let tx = state
.wallet_service
.credit(
user.id,
&req.currency,
req.amount,
WalletTxType::Recharge,
&req.transaction_no,
req.reference_type.or(Some(WalletReferenceType::Admin)),
req.reference_id.as_deref(),
req.metadata.as_deref(),
)
.await?;
let resp = crate::services::wallet::tx_to_response(state.wallet_repo.as_ref(), tx).await?;
let resp = state.wallet_service.tx_to_response(tx).await?;
Ok(ApiResponse::success(resp))
}
@@ -330,21 +327,21 @@ pub async fn admin_debit(
.await?
.ok_or_else(|| AppError::not_found("user"))?;
let tx = crate::services::wallet::debit_wallet(
state.wallet_repo.as_ref(),
&state.pool,
user.id,
&req.currency,
req.amount,
WalletTxType::Payment,
&req.transaction_no,
req.reference_type.or(Some(WalletReferenceType::Admin)),
req.reference_id.as_deref(),
req.metadata.as_deref(),
)
.await?;
let tx = state
.wallet_service
.debit(
user.id,
&req.currency,
req.amount,
WalletTxType::Payment,
&req.transaction_no,
req.reference_type.or(Some(WalletReferenceType::Admin)),
req.reference_id.as_deref(),
req.metadata.as_deref(),
)
.await?;
let resp = crate::services::wallet::tx_to_response(state.wallet_repo.as_ref(), tx).await?;
let resp = state.wallet_service.tx_to_response(tx).await?;
Ok(ApiResponse::success(resp))
}
@@ -378,8 +375,7 @@ pub async fn list_user_transactions(
.find_transactions_by_wallet(w.id, params.page, params.page_size)
.await?;
let items =
crate::services::wallet::tx_list_to_response(state.wallet_repo.as_ref(), rows).await?;
let items = state.wallet_service.tx_list_to_response(rows).await?;
Ok(params.paginate(items, total))
}
@@ -407,8 +403,7 @@ pub async fn list_user_all_transactions(
.find_transactions_by_user(user.id, params.page, params.page_size)
.await?;
let items =
crate::services::wallet::tx_list_to_response(state.wallet_repo.as_ref(), rows).await?;
let items = state.wallet_service.tx_list_to_response(rows).await?;
Ok(params.paginate(items, total))
}
@@ -447,14 +442,11 @@ pub async fn admin_reversal(
}
}
let tx = crate::services::wallet::reverse_transaction(
state.wallet_repo.as_ref(),
&state.pool,
original.id,
&req.transaction_no,
)
.await?;
let tx = state
.wallet_service
.reverse_transaction(original.id, &req.transaction_no)
.await?;
let resp = crate::services::wallet::tx_to_response(state.wallet_repo.as_ref(), tx).await?;
let resp = state.wallet_service.tx_to_response(tx).await?;
Ok(ApiResponse::success(resp))
}
+68
View File
@@ -112,19 +112,27 @@ pub struct AppState {
pub eventbus: EventBus,
pub post_repo: Arc<dyn PostRepository>,
pub post_service: Arc<dyn crate::services::post::PostService>,
pub page_service: Arc<dyn crate::services::page::PageService>,
pub user_repo: Arc<dyn UserRepository>,
pub category_repo: Arc<dyn CategoryRepository>,
pub category_service: Arc<dyn crate::services::category::CategoryService>,
pub tag_repo: Arc<dyn TagRepository>,
pub tag_service: Arc<dyn crate::services::tag::TagService>,
pub comment_repo: Arc<dyn CommentRepository>,
pub comment_service: Arc<dyn crate::services::comment::CommentService>,
pub media_repo: Arc<dyn MediaRepository>,
pub refresh_token_repo: Arc<dyn RefreshTokenRepository>,
pub wallet_repo: Arc<dyn WalletRepository>,
pub wallet_service: Arc<dyn crate::services::wallet::WalletService>,
pub product_repo: Arc<dyn ProductRepository>,
pub product_service: Arc<dyn crate::services::product::ProductService>,
pub order_repo: Arc<dyn OrderRepository>,
pub order_service: Arc<dyn crate::services::order::OrderService>,
pub payment_channel_repo: Arc<dyn PaymentChannelRepository>,
pub payment_order_repo: Arc<dyn PaymentOrderRepository>,
pub payment_tx_repo: Arc<dyn PaymentTransactionRepository>,
pub payment_refund_repo: Arc<dyn PaymentRefundRepository>,
pub payment_service: Arc<dyn crate::services::payment::PaymentService>,
pub search: Arc<dyn SearchEngine>,
pub content_type_registry: Arc<ContentTypeRegistry>,
pub aspect_engine: Arc<crate::aspects::engine::AspectEngine>,
@@ -206,6 +214,35 @@ pub async fn build_app_state(
let aspect_engine = Arc::new(crate::aspects::engine::AspectEngine::new());
let order_service: Arc<dyn crate::services::order::OrderService> =
Arc::new(crate::services::order::OrderServiceImpl::new(
order_repo.clone(),
product_repo.clone(),
aspect_engine.clone(),
Arc::new(pool.clone()),
));
let wallet_service: Arc<dyn crate::services::wallet::WalletService> =
Arc::new(crate::services::wallet::WalletServiceImpl::new(
wallet_repo.clone(),
aspect_engine.clone(),
Arc::new(pool.clone()),
));
let payment_service: Arc<dyn crate::services::payment::PaymentService> =
Arc::new(crate::services::payment::PaymentServiceImpl::new(
payment_channel_repo.clone(),
payment_order_repo.clone(),
payment_tx_repo.clone(),
payment_refund_repo.clone(),
order_repo.clone(),
product_repo.clone(),
wallet_repo.clone(),
Arc::new(config.clone()),
aspect_engine.clone(),
Arc::new(pool.clone()),
));
let reserved = config.builtins.reserved_route_segments();
let protocol_names: Vec<&str> = protocol_registry.names();
let ct_registry = Arc::new(ContentTypeRegistry::load_from_dir(
@@ -250,6 +287,29 @@ pub async fn build_app_state(
search.clone(),
));
let tag_service: Arc<dyn crate::services::tag::TagService> = Arc::new(
crate::services::tag::TagServiceImpl::new(tag_repo.clone(), aspect_engine.clone()),
);
let category_service: Arc<dyn crate::services::category::CategoryService> =
Arc::new(crate::services::category::CategoryServiceImpl::new(
category_repo.clone(),
aspect_engine.clone(),
));
let page_service: Arc<dyn crate::services::page::PageService> = Arc::new(
crate::services::page::PageServiceImpl::new(Arc::new(pool.clone()), aspect_engine.clone()),
);
let comment_service: Arc<dyn crate::services::comment::CommentService> =
Arc::new(crate::services::comment::CommentServiceImpl::new(
post_repo.clone(),
comment_repo.clone(),
aspect_engine.clone(),
));
let product_service: Arc<dyn crate::services::product::ProductService> =
Arc::new(crate::services::product::ProductServiceImpl::new(
product_repo.clone(),
aspect_engine.clone(),
));
let options_repo: Arc<dyn crate::repositories::OptionsRepository> = Arc::new(
crate::repositories::SqlxOptionsRepository::new(pool.clone()),
);
@@ -297,19 +357,27 @@ pub async fn build_app_state(
eventbus: eventbus.clone(),
post_repo,
post_service,
page_service,
user_repo,
category_repo,
category_service,
tag_repo,
tag_service,
comment_repo,
comment_service,
media_repo,
refresh_token_repo,
wallet_repo,
wallet_service,
product_repo,
product_service,
order_repo,
order_service,
payment_channel_repo,
payment_order_repo,
payment_tx_repo,
payment_refund_repo,
payment_service,
search,
content_type_registry: ct_registry,
aspect_engine,
+20 -69
View File
@@ -617,57 +617,6 @@ async fn handle_plugin_route(
}
/// Spawn EventBus background subscriber to forward business events to the plugin system.
pub fn spawn_event_subscriber(
eventbus: crate::eventbus::EventBus,
plugins: Arc<crate::plugins::PluginManager>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
use crate::eventbus::Event;
let mut rx = eventbus.subscribe();
tokio::spawn(async move {
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok(event) => match event.as_ref() {
Event::PostCreated(_) => {
let json = serde_json::to_value(event.as_ref()).unwrap_or_default();
plugins.dispatch_action("on_post_created", &json).await;
}
Event::PostUpdated(_) => {
let json = serde_json::to_value(event.as_ref()).unwrap_or_default();
plugins.dispatch_action("on_post_updated", &json).await;
}
Event::PostDeleted(_) => {
let json = serde_json::to_value(event.as_ref()).unwrap_or_default();
plugins.dispatch_action("on_post_deleted", &json).await;
}
Event::CommentCreated(_) => {
let json = serde_json::to_value(event.as_ref()).unwrap_or_default();
plugins
.dispatch_action("on_comment_created", &json)
.await;
}
_ => {}
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("eventbus subscriber lagged, skipped {n} events");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
}
}
_ = shutdown_rx.changed() => {
tracing::info!("event subscriber shutting down");
break;
}
}
}
});
}
/// Spawn audit log subscriber to write all business events to the `audit_log` table.
pub fn spawn_audit_subscriber(
eventbus: crate::eventbus::EventBus,
@@ -800,14 +749,26 @@ pub fn spawn_audit_subscriber(
});
}
/// Derive webhook event type from event metadata.
///
/// Format: `{singular_table}.{action}` where action is extracted from `display_name()`
/// by stripping the PascalCase table prefix (e.g., `PostCreated` → `post.created`).
fn webhook_event_type(event: &crate::eventbus::Event) -> Option<String> {
let table = event.table()?;
let singular = table.strip_suffix('s').unwrap_or(table);
let display = event.display_name();
let prefix = singular[..1].to_uppercase() + &singular[1..];
let action = display.strip_prefix(&prefix)?;
let action = action[..1].to_ascii_lowercase() + &action[1..];
Some(format!("{}.{}", singular, action))
}
/// Spawn webhook event delivery subscriber
pub fn spawn_webhook_subscriber(
eventbus: crate::eventbus::EventBus,
webhook_service: Arc<crate::webhook::WebhookService>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
use crate::eventbus::Event;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
@@ -823,26 +784,15 @@ pub fn spawn_webhook_subscriber(
result = rx.recv() => {
match result {
Ok(event) => {
let event_type = match event.as_ref() {
Event::PostCreated(_) => "post.created",
Event::PostUpdated(_) => "post.updated",
Event::PostDeleted(_) => "post.deleted",
Event::CommentCreated(_) => "comment.created",
Event::UserRegistered(_) => "user.registered",
Event::UserLoggedIn { .. } => "user.loggedIn",
Event::MediaUploaded(_) => "media.uploaded",
Event::MediaDeleted(_) => "media.deleted",
Event::PasswordResetRequested { .. } => "user.passwordResetRequested",
Event::EmailVerificationRequested { .. } => {
"user.emailVerificationRequested"
}
_ => continue,
let event_type = match webhook_event_type(event.as_ref()) {
Some(t) => t,
None => continue,
};
let payload_value = serde_json::to_value(event.as_ref()).unwrap_or_default();
let timestamp = crate::utils::tz::now_utc();
let webhook_payload = crate::webhook::model::WebhookPayload {
event: event_type.to_string(),
event: event_type.clone(),
data: payload_value,
timestamp,
};
@@ -866,7 +816,7 @@ pub fn spawn_webhook_subscriber(
let events: Vec<String> =
serde_json::from_str(&sub.events).unwrap_or_default();
if !events.iter().any(|e| {
e == event_type || e == "*" || event_type.starts_with(&format!("{e}."))
e == &event_type || e == "*" || event_type.starts_with(&format!("{e}."))
}) {
continue;
}
@@ -877,6 +827,7 @@ pub fn spawn_webhook_subscriber(
);
let url = sub.url.clone();
let body_clone = body.clone();
let event_type = event_type.clone();
let client = client.clone();
tokio::spawn(async move {
let result = client
+11 -23
View File
@@ -13,14 +13,13 @@ use chrono::Utc;
use jsonwebtoken::{EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::aspects::engine::AspectEngine;
use crate::dto::{LoginResponse, RegisterRequest, UpdatePasswordRequest, UserResponse};
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::eventbus::EventBus;
use crate::middleware::auth::AuthUser;
use crate::models::user::{UserRole, UserStatus};
use crate::models::user_credential::AuthType;
use crate::plugins::PluginManager;
use crate::repositories::{RefreshTokenRepository, UserRepository};
/// JWT token claims (payload).
@@ -170,10 +169,10 @@ pub fn generate_access_token_for_test(user_id: &str, user_int_id: i64, role: Use
///
/// Checks if the email is already registered; if unique, hashes the password and creates
/// the user record and email credential within a transaction.
#[tracing::instrument(skip(_user_repo, eventbus), fields(username = tracing::field::Empty))]
#[tracing::instrument(skip(_user_repo, aspect_engine), fields(username = tracing::field::Empty))]
pub async fn register(
_user_repo: &dyn UserRepository,
eventbus: &EventBus,
aspect_engine: &AspectEngine,
req: RegisterRequest,
tenant_id: Option<&str>,
require_email_verification: bool,
@@ -286,11 +285,14 @@ pub async fn register(
Ok::<_, crate::errors::app_error::AppError>(user)
})?;
eventbus.emit(Event::UserRegistered(user.clone()));
aspect_engine.emit(Event::UserRegistered(user.clone()));
if require_email_verification {
let _ = crate::services::email_verification::trigger_email_verification(
pool, eventbus, user.id, &req.email,
pool,
aspect_engine,
user.id,
&req.email,
)
.await;
}
@@ -303,12 +305,11 @@ pub async fn register(
/// Looks up the user via email credential, verifies the password, and on success generates
/// an access token and a refresh token.
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(skip(_user_repo, refresh_token_repo, plugins, eventbus), fields(email = %req.email))]
#[tracing::instrument(skip(_user_repo, refresh_token_repo, aspect_engine), fields(email = %req.email))]
pub async fn login(
_user_repo: &dyn UserRepository,
refresh_token_repo: &dyn RefreshTokenRepository,
plugins: &PluginManager,
eventbus: &EventBus,
aspect_engine: &AspectEngine,
pool: &crate::db::Pool,
req: &crate::dto::LoginRequest,
jwt_secret: &str,
@@ -329,12 +330,6 @@ pub async fn login(
&req.password,
&crate::models::user_credential::extract_password_hash(&cred.credential_data)?,
)? {
plugins
.dispatch_action(
"on_login",
&serde_json::json!({"email": &req.email, "success": false}),
)
.await;
return Err(AppError::Unauthorized);
}
@@ -375,14 +370,7 @@ pub async fn login(
.create_token(user.id, &refresh_token_str, &expires_at.to_rfc3339())
.await?;
plugins
.dispatch_action(
"on_login",
&serde_json::json!({"email": &req.email, "success": true, "user_id": &user.document_id}),
)
.await;
eventbus.emit(Event::UserLoggedIn {
aspect_engine.emit(Event::UserLoggedIn {
user: user.clone(),
success: true,
});
+260 -177
View File
@@ -1,129 +1,214 @@
//! Category service.
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::aspects::slug_aspect;
use crate::commands::{CreateCategoryCmd, UpdateCategoryCmd};
use crate::dto::{CreateCategoryRequest, UpdateCategoryRequest};
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::middleware::auth::AuthUser;
use crate::models::category::Category;
use crate::repositories::CategoryRepository;
pub async fn create_category(
category_repo: &dyn CategoryRepository,
auth: &AuthUser,
req: CreateCategoryRequest,
) -> AppResult<crate::models::category::Category> {
let slug = slug_aspect::generate_slug(&req.name);
let parent_id = if let Some(ref doc_id) = req.parent_id {
if doc_id.parse::<i64>().is_ok() {
doc_id.parse::<i64>().ok()
} else {
category_repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
/// Category business logic trait.
#[async_trait]
pub trait CategoryService: Send + Sync {
async fn create(&self, auth: &AuthUser, req: CreateCategoryRequest) -> AppResult<Category>;
async fn update(
&self,
auth: &AuthUser,
id: &str,
req: UpdateCategoryRequest,
) -> AppResult<Category>;
async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()>;
async fn get(&self, id: &str, auth: &AuthUser) -> AppResult<Category>;
async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Category>>;
async fn list_paginated(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<Category>, i64)>;
}
pub struct CategoryServiceImpl {
repo: Arc<dyn CategoryRepository>,
aspect_engine: Arc<AspectEngine>,
}
impl CategoryServiceImpl {
pub fn new(repo: Arc<dyn CategoryRepository>, aspect_engine: Arc<AspectEngine>) -> Self {
Self {
repo,
aspect_engine,
}
} else {
None
};
category_repo
.create(
CreateCategoryCmd {
name: req.name,
slug,
description: req.description,
parent_id,
sort_order: req.sort_order.unwrap_or(0),
},
auth.tenant_id(),
auth.user_int_id(),
)
.await
}
async fn before_create(
&self,
auth: &AuthUser,
req: CreateCategoryRequest,
) -> AppResult<(CreateCategoryRequest, crate::aspects::Dispatched)> {
self.aspect_engine
.before_create("categories", auth, req)
.await
}
async fn before_update(
&self,
auth: &AuthUser,
existing: &Category,
req: UpdateCategoryRequest,
) -> AppResult<(UpdateCategoryRequest, crate::aspects::Dispatched)> {
self.aspect_engine
.before_update("categories", auth, existing, req)
.await
}
async fn before_delete(
&self,
auth: &AuthUser,
existing: &Category,
) -> AppResult<crate::aspects::Dispatched> {
self.aspect_engine
.before_delete("categories", auth, existing)
.await
}
fn after_created(&self, cat: &Category) {
self.aspect_engine.emit(Event::CategoryCreated(cat.clone()));
}
fn after_updated(&self, cat: &Category) {
self.aspect_engine.emit(Event::CategoryUpdated(cat.clone()));
}
fn after_deleted(&self, cat: &Category) {
self.aspect_engine.emit(Event::CategoryDeleted(cat.clone()));
}
}
pub async fn update_category(
category_repo: &dyn CategoryRepository,
auth: &AuthUser,
id: &str,
req: UpdateCategoryRequest,
) -> AppResult<crate::models::category::Category> {
let existing = category_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))?;
let new_slug = req
.name
.as_ref()
.map(|n| slug_aspect::generate_slug(n))
.unwrap_or(existing.slug);
let parent_id = if let Some(ref doc_id) = req.parent_id {
if doc_id.parse::<i64>().is_ok() {
doc_id.parse::<i64>().ok()
#[async_trait]
impl CategoryService for CategoryServiceImpl {
async fn create(&self, auth: &AuthUser, req: CreateCategoryRequest) -> AppResult<Category> {
let (req, _d) = self.before_create(auth, req).await?;
let slug = slug_aspect::generate_slug(&req.name);
let parent_id = if let Some(ref doc_id) = req.parent_id {
if doc_id.parse::<i64>().is_ok() {
doc_id.parse::<i64>().ok()
} else {
self.repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
}
} else {
category_repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
}
} else {
None
};
category_repo
.update(
UpdateCategoryCmd {
id: existing.id,
name: req.name,
slug: Some(new_slug),
description: req.description,
parent_id,
sort_order: req.sort_order,
},
auth.tenant_id(),
auth.user_int_id(),
)
.await
}
None
};
let cat = self
.repo
.create(
CreateCategoryCmd {
name: req.name,
slug,
description: req.description,
parent_id,
sort_order: req.sort_order.unwrap_or(0),
},
auth.tenant_id(),
auth.user_int_id(),
)
.await?;
self.after_created(&cat);
Ok(cat)
}
pub async fn delete_category(
category_repo: &dyn CategoryRepository,
id: &str,
auth: &AuthUser,
) -> AppResult<()> {
let existing = category_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))?;
category_repo.delete(existing.id, auth.tenant_id()).await?;
Ok(())
}
async fn update(
&self,
auth: &AuthUser,
id: &str,
req: UpdateCategoryRequest,
) -> AppResult<Category> {
let existing = self
.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))?;
let (req, _d) = self.before_update(auth, &existing, req).await?;
let new_slug = req
.name
.as_ref()
.map(|n| slug_aspect::generate_slug(n))
.unwrap_or(existing.slug);
pub async fn get_category(
category_repo: &dyn CategoryRepository,
id: &str,
auth: &AuthUser,
) -> AppResult<crate::models::category::Category> {
category_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))
}
let parent_id = if let Some(ref doc_id) = req.parent_id {
if doc_id.parse::<i64>().is_ok() {
doc_id.parse::<i64>().ok()
} else {
self.repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
}
} else {
None
};
let updated = self
.repo
.update(
UpdateCategoryCmd {
id: existing.id,
name: req.name,
slug: Some(new_slug),
description: req.description,
parent_id,
sort_order: req.sort_order,
},
auth.tenant_id(),
auth.user_int_id(),
)
.await?;
self.after_updated(&updated);
Ok(updated)
}
pub async fn list_categories(
category_repo: &dyn CategoryRepository,
auth: &AuthUser,
) -> AppResult<Vec<crate::models::category::Category>> {
category_repo.find_all(auth.tenant_id()).await
}
async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()> {
let existing = self
.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))?;
self.before_delete(auth, &existing).await?;
self.repo.delete(existing.id, auth.tenant_id()).await?;
self.after_deleted(&existing);
Ok(())
}
pub async fn list_categories_paginated(
category_repo: &dyn CategoryRepository,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<crate::models::category::Category>, i64)> {
category_repo
.find_paginated(auth.tenant_id(), page, page_size)
.await
async fn get(&self, id: &str, auth: &AuthUser) -> AppResult<Category> {
self.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("category"))
}
async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Category>> {
self.repo.find_all(auth.tenant_id()).await
}
async fn list_paginated(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<Category>, i64)> {
self.repo
.find_paginated(auth.tenant_id(), page, page_size)
.await
}
}
#[cfg(test)]
@@ -150,23 +235,30 @@ mod tests {
)
}
fn make_service(pool: crate::db::Pool) -> Arc<dyn CategoryService> {
Arc::new(CategoryServiceImpl::new(
Arc::new(SqlxCategoryRepository::new(pool.clone())),
Arc::new(AspectEngine::new()),
))
}
#[tokio::test]
async fn create_category_basic() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
let cat = super::create_category(
&repo,
&a,
CreateCategoryRequest {
name: "Tech".into(),
description: Some("Technology".into()),
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
let cat = svc
.create(
&a,
CreateCategoryRequest {
name: "Tech".into(),
description: Some("Technology".into()),
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
assert_eq!(cat.name, "Tech");
assert_eq!(cat.slug, "tech");
}
@@ -174,42 +266,42 @@ mod tests {
#[tokio::test]
async fn list_categories_empty() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
let cats = super::list_categories(&repo, &a).await.unwrap();
let cats = svc.list(&a).await.unwrap();
assert!(cats.is_empty());
}
#[tokio::test]
async fn update_category_renames() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
let cat = super::create_category(
&repo,
&a,
CreateCategoryRequest {
name: "Old".into(),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
let updated = super::update_category(
&repo,
&a,
&cat.document_id,
crate::dto::UpdateCategoryRequest {
name: Some("New".into()),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
let cat = svc
.create(
&a,
CreateCategoryRequest {
name: "Old".into(),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
let updated = svc
.update(
&a,
&cat.document_id,
crate::dto::UpdateCategoryRequest {
name: Some("New".into()),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
assert_eq!(updated.name, "New");
assert_eq!(updated.slug, "new");
}
@@ -217,47 +309,40 @@ mod tests {
#[tokio::test]
async fn delete_category() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
let cat = super::create_category(
&repo,
&a,
CreateCategoryRequest {
name: "Del".into(),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
super::delete_category(&repo, &cat.document_id, &a)
let cat = svc
.create(
&a,
CreateCategoryRequest {
name: "Del".into(),
description: None,
parent_id: None,
sort_order: None,
},
)
.await
.unwrap();
let cats = super::list_categories(&repo, &a).await.unwrap();
svc.delete(&cat.document_id, &a).await.unwrap();
let cats = svc.list(&a).await.unwrap();
assert!(cats.is_empty());
}
#[tokio::test]
async fn delete_category_not_found() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
assert!(
super::delete_category(&repo, "nonexistent", &a)
.await
.is_err()
);
assert!(svc.delete("nonexistent", &a).await.is_err());
}
#[tokio::test]
async fn list_categories_paginated() {
let pool = setup_pool().await;
let repo = SqlxCategoryRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth(None);
for i in 0..5 {
super::create_category(
&repo,
svc.create(
&a,
CreateCategoryRequest {
name: format!("Cat{i}"),
@@ -269,9 +354,7 @@ mod tests {
.await
.unwrap();
}
let (cats, total) = super::list_categories_paginated(&repo, &a, 1, 3)
.await
.unwrap();
let (cats, total) = svc.list_paginated(&a, 1, 3).await.unwrap();
assert_eq!(total, 5);
assert_eq!(cats.len(), 3);
}
+232 -137
View File
@@ -3,13 +3,16 @@
//! Handles comment-related business logic, including comment creation (with nesting depth validation),
//! comment listing (tree structure), comment deletion, and status management.
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::commands::CreateCommentCmd;
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::eventbus::EventBus;
use crate::middleware::auth::AuthUser;
use crate::models::comment::{self, CommentResponse, CommentStatus};
use crate::plugins::PluginManager;
use crate::policy::Policy;
use crate::repositories::{CommentRepository, PostRepository};
@@ -21,149 +24,218 @@ pub struct CommentInput {
pub parent_id: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub async fn create_comment(
post_repo: &dyn PostRepository,
comment_repo: &dyn CommentRepository,
plugins: &PluginManager,
eventbus: &EventBus,
post_slug: &str,
auth: &AuthUser,
content: &str,
parent_id: Option<&str>,
nickname: Option<&str>,
email: Option<&str>,
) -> AppResult<CommentResponse> {
let p = post_repo
.find_by_slug(post_slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("post"))?;
/// Comment business logic trait.
#[async_trait]
pub trait CommentService: Send + Sync {
async fn create(
&self,
post_slug: &str,
auth: &AuthUser,
content: &str,
parent_id: Option<&str>,
nickname: Option<&str>,
email: Option<&str>,
) -> AppResult<CommentResponse>;
async fn list_paginated(
&self,
post_slug: &str,
page: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<CommentResponse>, i64)>;
async fn delete(&self, comment_id: &str, auth: &AuthUser) -> AppResult<()>;
async fn update_status(
&self,
comment_id: &str,
status: CommentStatus,
auth: &AuthUser,
) -> AppResult<()>;
}
if let Some(pid_str) = parent_id {
let pid: i64 = pid_str
.parse()
.map_err(|_| AppError::BadRequest("invalid parent_id".into()))?;
let all_comments = comment_repo
.find_approved_by_post(p.id, auth.tenant_id())
.await?;
let parent = all_comments
.iter()
.find(|c| c.id == pid)
.ok_or_else(|| AppError::not_found("parent_comment"))?;
pub struct CommentServiceImpl {
post_repo: Arc<dyn PostRepository>,
comment_repo: Arc<dyn CommentRepository>,
aspect_engine: Arc<AspectEngine>,
}
if parent.post_id != p.id {
return Err(AppError::BadRequest("parent_comment_mismatch".into()));
impl CommentServiceImpl {
pub fn new(
post_repo: Arc<dyn PostRepository>,
comment_repo: Arc<dyn CommentRepository>,
aspect_engine: Arc<AspectEngine>,
) -> Self {
Self {
post_repo,
comment_repo,
aspect_engine,
}
comment::validate_depth(&all_comments, pid)?;
}
let comment_input = CommentInput {
content: content.to_string(),
nickname: nickname.map(std::string::ToString::to_string),
email: email.map(std::string::ToString::to_string),
parent_id: parent_id.map(std::string::ToString::to_string),
};
async fn before_delete(
&self,
auth: &AuthUser,
existing: &crate::models::comment::Comment,
) -> AppResult<crate::aspects::Dispatched> {
crate::policy::CommentPolicy::can_delete(auth, existing)?;
self.aspect_engine
.before_delete("comments", auth, existing)
.await
}
let filtered = plugins
.dispatch_filter(&Event::CommentCreating, comment_input)
.await?;
fn after_updated(&self, c: &crate::models::comment::Comment) {
self.aspect_engine.emit(Event::CommentUpdated(c.clone()));
}
let parent_id = if let Some(ref doc_id) = filtered.parent_id {
if doc_id.is_empty() {
None
} else if let Ok(int_id) = doc_id.parse::<i64>() {
Some(int_id)
} else {
comment_repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
fn after_deleted(&self, c: &crate::models::comment::Comment) {
self.aspect_engine.emit(Event::CommentDeleted(c.clone()));
}
}
#[async_trait]
impl CommentService for CommentServiceImpl {
async fn create(
&self,
post_slug: &str,
auth: &AuthUser,
content: &str,
parent_id: Option<&str>,
nickname: Option<&str>,
email: Option<&str>,
) -> AppResult<CommentResponse> {
let p = self
.post_repo
.find_by_slug(post_slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("post"))?;
if let Some(pid_str) = parent_id {
let pid: i64 = pid_str
.parse()
.map_err(|_| AppError::BadRequest("invalid parent_id".into()))?;
let all_comments = self
.comment_repo
.find_approved_by_post(p.id, auth.tenant_id())
.await?;
let parent = all_comments
.iter()
.find(|c| c.id == pid)
.ok_or_else(|| AppError::not_found("parent_comment"))?;
if parent.post_id != p.id {
return Err(AppError::BadRequest("parent_comment_mismatch".into()));
}
comment::validate_depth(&all_comments, pid)?;
}
} else {
None
};
let c = comment_repo
.create(
CreateCommentCmd {
post_id: p.id,
created_by: auth.user_int_id(),
nickname: filtered.nickname,
email: filtered.email,
content: filtered.content,
parent_id,
},
auth.tenant_id(),
)
.await?;
let comment_input = CommentInput {
content: content.to_string(),
nickname: nickname.map(std::string::ToString::to_string),
email: email.map(std::string::ToString::to_string),
parent_id: parent_id.map(std::string::ToString::to_string),
};
eventbus.emit(Event::CommentCreated(c.clone()));
let (filtered, _d) = self
.aspect_engine
.before_create("comments", auth, comment_input)
.await?;
Ok(CommentResponse {
id: c.id,
document_id: c.document_id.clone(),
post_id: c.post_id,
created_by: c.created_by,
nickname: c.nickname,
content: c.content,
parent_id: c.parent_id,
depth: 0,
replies: vec![],
created_at: c.created_at,
})
}
let parent_id = if let Some(ref doc_id) = filtered.parent_id {
if doc_id.is_empty() {
None
} else if let Ok(int_id) = doc_id.parse::<i64>() {
Some(int_id)
} else {
self.comment_repo
.find_by_document_id(doc_id, auth.tenant_id())
.await?
.map(|c| c.id)
}
} else {
None
};
pub async fn list_comments_paginated(
post_repo: &dyn PostRepository,
comment_repo: &dyn CommentRepository,
post_slug: &str,
page: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<CommentResponse>, i64)> {
let p = post_repo
.find_by_slug(post_slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("post"))?;
let c = self
.comment_repo
.create(
CreateCommentCmd {
post_id: p.id,
created_by: auth.user_int_id(),
nickname: filtered.nickname,
email: filtered.email,
content: filtered.content,
parent_id,
},
auth.tenant_id(),
)
.await?;
let (comments, total) = comment_repo
.find_approved_by_post_paginated(p.id, page, page_size, auth.tenant_id())
.await?;
Ok((comment::build_tree(&comments), total))
}
self.aspect_engine.emit(Event::CommentCreated(c.clone()));
pub async fn delete_comment(
comment_repo: &dyn CommentRepository,
comment_id: &str,
auth: &AuthUser,
) -> AppResult<()> {
let c = comment_repo
.find_by_document_id(comment_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("comment"))?;
Ok(CommentResponse {
id: c.id,
document_id: c.document_id.clone(),
post_id: c.post_id,
created_by: c.created_by,
nickname: c.nickname,
content: c.content,
parent_id: c.parent_id,
depth: 0,
replies: vec![],
created_at: c.created_at,
})
}
crate::policy::CommentPolicy::can_delete(auth, &c)?;
async fn list_paginated(
&self,
post_slug: &str,
page: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<CommentResponse>, i64)> {
let p = self
.post_repo
.find_by_slug(post_slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("post"))?;
comment_repo.delete(c.id, auth.tenant_id()).await?;
Ok(())
}
let (comments, total) = self
.comment_repo
.find_approved_by_post_paginated(p.id, page, page_size, auth.tenant_id())
.await?;
Ok((comment::build_tree(&comments), total))
}
pub async fn update_comment_status(
comment_repo: &dyn CommentRepository,
comment_id: &str,
status: CommentStatus,
auth: &AuthUser,
) -> AppResult<()> {
let c = comment_repo
.find_by_document_id(comment_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("comment"))?;
comment_repo
.update_status(c.id, status, auth.tenant_id())
.await?;
async fn delete(&self, comment_id: &str, auth: &AuthUser) -> AppResult<()> {
let c = self
.comment_repo
.find_by_document_id(comment_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("comment"))?;
Ok(())
self.before_delete(auth, &c).await?;
self.comment_repo.delete(c.id, auth.tenant_id()).await?;
self.after_deleted(&c);
Ok(())
}
async fn update_status(
&self,
comment_id: &str,
status: CommentStatus,
auth: &AuthUser,
) -> AppResult<()> {
let c = self
.comment_repo
.find_by_document_id(comment_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("comment"))?;
self.comment_repo
.update_status(c.id, status, auth.tenant_id())
.await?;
self.after_updated(&c);
Ok(())
}
}
#[cfg(test)]
@@ -260,10 +332,15 @@ mod tests {
let user = insert_user(&pool).await;
let post_id = insert_post(&pool, user.id).await;
let c = insert_comment(&pool, post_id, user.id).await;
let repo = SqlxCommentRepository::new(pool.clone());
super::update_comment_status(&repo, &c.document_id, CommentStatus::Approved, &auth(&user))
let svc = CommentServiceImpl::new(
Arc::new(crate::repositories::SqlxPostRepository::new(pool.clone())),
Arc::new(SqlxCommentRepository::new(pool.clone())),
Arc::new(AspectEngine::new()),
);
svc.update_status(&c.document_id, CommentStatus::Approved, &auth(&user))
.await
.unwrap();
let repo = SqlxCommentRepository::new(pool.clone());
let updated = repo.find_by_id(c.id, None).await.unwrap().unwrap();
assert_eq!(
updated.status,
@@ -275,9 +352,14 @@ mod tests {
async fn update_comment_status_not_found() {
let pool = setup_pool().await;
let repo = SqlxCommentRepository::new(pool.clone());
let svc = CommentServiceImpl::new(
Arc::new(crate::repositories::SqlxPostRepository::new(pool.clone())),
Arc::new(repo),
Arc::new(AspectEngine::new()),
);
let a = AuthUser::new_test("any", crate::models::user::UserRole::Admin, "");
assert!(
super::update_comment_status(&repo, "missing", CommentStatus::Approved, &a)
svc.update_status("missing", CommentStatus::Approved, &a)
.await
.is_err()
);
@@ -289,16 +371,19 @@ mod tests {
let user = insert_user(&pool).await;
let post_id = insert_post(&pool, user.id).await;
let c = insert_comment(&pool, post_id, user.id).await;
let repo = SqlxCommentRepository::new(pool.clone());
let svc = CommentServiceImpl::new(
Arc::new(crate::repositories::SqlxPostRepository::new(pool.clone())),
Arc::new(SqlxCommentRepository::new(pool.clone())),
Arc::new(AspectEngine::new()),
);
let a = AuthUser::from_parts(
Some(user.document_id.clone()),
Some(user.id),
crate::models::user::UserRole::Admin,
None,
);
super::delete_comment(&repo, &c.document_id, &a)
.await
.unwrap();
svc.delete(&c.document_id, &a).await.unwrap();
let repo = SqlxCommentRepository::new(pool.clone());
assert!(repo.find_by_id(c.id, None).await.unwrap().is_none());
}
@@ -306,8 +391,13 @@ mod tests {
async fn delete_comment_not_found() {
let pool = setup_pool().await;
let repo = SqlxCommentRepository::new(pool.clone());
let svc = CommentServiceImpl::new(
Arc::new(crate::repositories::SqlxPostRepository::new(pool.clone())),
Arc::new(repo),
Arc::new(AspectEngine::new()),
);
let a = AuthUser::new_test("any", crate::models::user::UserRole::Admin, "");
assert!(super::delete_comment(&repo, "missing", &a).await.is_err());
assert!(svc.delete("missing", &a).await.is_err());
}
#[tokio::test]
@@ -316,10 +406,15 @@ mod tests {
let user = insert_user(&pool).await;
let post_id = insert_post(&pool, user.id).await;
let c = insert_comment(&pool, post_id, user.id).await;
let repo = SqlxCommentRepository::new(pool.clone());
super::update_comment_status(&repo, &c.document_id, CommentStatus::Spam, &auth(&user))
let svc = CommentServiceImpl::new(
Arc::new(crate::repositories::SqlxPostRepository::new(pool.clone())),
Arc::new(SqlxCommentRepository::new(pool.clone())),
Arc::new(AspectEngine::new()),
);
svc.update_status(&c.document_id, CommentStatus::Spam, &auth(&user))
.await
.unwrap();
let repo = SqlxCommentRepository::new(pool.clone());
let updated = repo.find_by_id(c.id, None).await.unwrap().unwrap();
assert_eq!(updated.status, crate::models::comment::CommentStatus::Spam);
}
+21 -24
View File
@@ -2,17 +2,14 @@
use chrono::Utc;
use crate::aspects::engine::AspectEngine;
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::eventbus::EventBus;
use crate::repositories::UserRepository;
/// Trigger email verification after registration (if enabled in config).
///
/// Deletes old tokens, creates a new token, and sends a verification email via EventBus.
pub async fn trigger_email_verification(
pool: &crate::db::Pool,
eventbus: &EventBus,
aspect_engine: &AspectEngine,
user_id: i64,
email: &str,
) -> AppResult<()> {
@@ -21,7 +18,7 @@ pub async fn trigger_email_verification(
let verification =
crate::models::email_verification::create(pool, user_id, email, 86400).await?;
eventbus.emit(Event::EmailVerificationRequested {
aspect_engine.emit(Event::EmailVerificationRequested {
user_id,
email: email.to_string(),
token: verification,
@@ -78,7 +75,7 @@ pub async fn verify_email(pool: &crate::db::Pool, token: &str) -> AppResult<()>
pub async fn resend_verification(
pool: &crate::db::Pool,
_user_repo: &dyn UserRepository,
eventbus: &EventBus,
aspect_engine: &AspectEngine,
email: &str,
) -> AppResult<()> {
let cred = crate::models::user_credential::find_by_auth_type_and_identifier(
@@ -93,7 +90,7 @@ pub async fn resend_verification(
return Err(AppError::BadRequest("email_already_verified".into()));
}
trigger_email_verification(pool, eventbus, cred.user_id, &cred.identifier).await
trigger_email_verification(pool, aspect_engine, cred.user_id, &cred.identifier).await
}
#[cfg(test)]
@@ -111,8 +108,8 @@ mod tests {
pool
}
fn eventbus() -> crate::eventbus::EventBus {
crate::eventbus::EventBus::new(16)
fn aspect_engine() -> crate::aspects::engine::AspectEngine {
crate::aspects::engine::AspectEngine::new()
}
async fn insert_user(pool: &crate::db::Pool, email: &str) -> crate::models::user::User {
@@ -144,8 +141,8 @@ mod tests {
async fn trigger_email_verification_creates_token() {
let pool = setup_pool().await;
let user = insert_user(&pool, "verify@test.com").await;
let eb = eventbus();
super::trigger_email_verification(&pool, &eb, user.id, "verify@test.com")
let ae = aspect_engine();
super::trigger_email_verification(&pool, &ae, user.id, "verify@test.com")
.await
.unwrap();
let row =
@@ -162,8 +159,8 @@ mod tests {
async fn trigger_email_verification_replaces_old() {
let pool = setup_pool().await;
let user = insert_user(&pool, "replace@test.com").await;
let eb = eventbus();
super::trigger_email_verification(&pool, &eb, user.id, "replace@test.com")
let ae = aspect_engine();
super::trigger_email_verification(&pool, &ae, user.id, "replace@test.com")
.await
.unwrap();
let sql = format!(
@@ -176,7 +173,7 @@ mod tests {
.await
.unwrap();
assert_eq!(count_before, 1);
super::trigger_email_verification(&pool, &eb, user.id, "replace@test.com")
super::trigger_email_verification(&pool, &ae, user.id, "replace@test.com")
.await
.unwrap();
let (count_after,): (i64,) = sqlx::query_as(&sql)
@@ -191,8 +188,8 @@ mod tests {
async fn verify_email_valid_token() {
let pool = setup_pool().await;
let user = insert_user(&pool, "v@test.com").await;
let eb = eventbus();
super::trigger_email_verification(&pool, &eb, user.id, "v@test.com")
let ae = aspect_engine();
super::trigger_email_verification(&pool, &ae, user.id, "v@test.com")
.await
.unwrap();
let sql = format!(
@@ -230,9 +227,9 @@ mod tests {
async fn resend_verification_success() {
let pool = setup_pool().await;
let user = insert_user(&pool, "resend@test.com").await;
let eb = eventbus();
let ae = aspect_engine();
let repo = SqlxUserRepository::new(pool.clone());
super::resend_verification(&pool, &repo, &eb, "resend@test.com")
super::resend_verification(&pool, &repo, &ae, "resend@test.com")
.await
.unwrap();
let sql = format!(
@@ -251,8 +248,8 @@ mod tests {
async fn resend_verification_already_verified() {
let pool = setup_pool().await;
let user = insert_user(&pool, "verified@test.com").await;
let eb = eventbus();
super::trigger_email_verification(&pool, &eb, user.id, "verified@test.com")
let ae = aspect_engine();
super::trigger_email_verification(&pool, &ae, user.id, "verified@test.com")
.await
.unwrap();
let sql = format!(
@@ -266,7 +263,7 @@ mod tests {
.unwrap();
super::verify_email(&pool, &token_str).await.unwrap();
let repo = SqlxUserRepository::new(pool.clone());
let err = super::resend_verification(&pool, &repo, &eb, "verified@test.com")
let err = super::resend_verification(&pool, &repo, &ae, "verified@test.com")
.await
.unwrap_err();
let msg = err.to_string();
@@ -276,10 +273,10 @@ mod tests {
#[tokio::test]
async fn resend_verification_user_not_found() {
let pool = setup_pool().await;
let eb = eventbus();
let ae = aspect_engine();
let repo = SqlxUserRepository::new(pool.clone());
assert!(
super::resend_verification(&pool, &repo, &eb, "nope@no.com")
super::resend_verification(&pool, &repo, &ae, "nope@no.com")
.await
.is_err()
);
+8 -5
View File
@@ -9,9 +9,11 @@ use chrono::Utc;
#[cfg(feature = "export-types")]
use ts_rs::TS;
use crate::aspects::engine::AspectEngine;
use crate::commands::CreateUserCmd;
use crate::dto::LoginResponse;
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::middleware::auth::AuthUser;
use crate::models::oauth;
use crate::oauth::{OAuthProviderRegistry, OAuthUserInfo};
@@ -89,7 +91,7 @@ pub async fn handle_callback(
jwt_secret: &str,
jwt_access_expires: u64,
jwt_refresh_expires: u64,
eventbus: &crate::eventbus::EventBus,
aspect_engine: &AspectEngine,
) -> AppResult<OAuthCallbackResult> {
let provider = registry.get(provider_name).ok_or_else(|| {
AppError::BadRequest(format!("unsupported OAuth provider: {provider_name}"))
@@ -173,7 +175,7 @@ pub async fn handle_callback(
)
.await?;
eventbus.emit(crate::eventbus::Event::UserLoggedIn {
aspect_engine.emit(Event::UserLoggedIn {
user: user.clone(),
success: true,
});
@@ -182,7 +184,8 @@ pub async fn handle_callback(
}
}
let user = auto_register_user(pool, user_repo, provider_name, &user_info, eventbus).await?;
let user =
auto_register_user(pool, user_repo, provider_name, &user_info, aspect_engine).await?;
do_bind_oauth(pool, user.id, provider_name, &token_resp, &user_info).await?;
@@ -306,7 +309,7 @@ async fn auto_register_user(
user_repo: &dyn UserRepository,
provider_name: &str,
user_info: &OAuthUserInfo,
eventbus: &crate::eventbus::EventBus,
aspect_engine: &AspectEngine,
) -> AppResult<crate::models::user::User> {
let base_username = user_info.display_name.clone().unwrap_or_else(|| {
format!(
@@ -360,7 +363,7 @@ async fn auto_register_user(
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("failed to fetch created user")))?;
eventbus.emit(crate::eventbus::Event::UserRegistered(user.clone()));
aspect_engine.emit(Event::UserRegistered(user.clone()));
Ok(user)
}
+671 -729
View File
File diff suppressed because it is too large Load Diff
+193 -98
View File
@@ -2,120 +2,215 @@
//!
//! Provides full CRUD business logic for pages, including status management and block validation.
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::commands::{CreatePageCmd, UpdatePageCmd};
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::middleware::auth::AuthUser;
use crate::models::page::{self, PageStatus};
use crate::models::page::{self, Page, PageStatus};
fn validate_blocks_json(blocks: &str) -> AppResult<Vec<page::PageBlock>> {
serde_json::from_str(blocks)
.map_err(|e| AppError::BadRequest(format!("invalid blocks JSON: {e}")))
/// Page business logic trait.
#[async_trait]
pub trait PageService: Send + Sync {
async fn list_published(
&self,
page_num: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<Page>, i64)>;
async fn get_by_slug(&self, slug: &str, auth: &AuthUser) -> AppResult<Page>;
async fn get_by_id(&self, id: &str, auth: &AuthUser) -> AppResult<Page>;
async fn list_all(
&self,
page_num: i64,
page_size: i64,
status: Option<PageStatus>,
auth: &AuthUser,
) -> AppResult<(Vec<Page>, i64)>;
async fn create_page(&self, auth: &AuthUser, cmd: CreatePageCmd) -> AppResult<Page>;
async fn update_page(
&self,
auth: &AuthUser,
document_id: &str,
cmd: UpdatePageCmd,
) -> AppResult<Page>;
async fn delete_page(&self, id: &str, auth: &AuthUser) -> AppResult<()>;
async fn update_status(&self, id: &str, status: PageStatus, auth: &AuthUser)
-> AppResult<Page>;
async fn reorder(&self, items: Vec<(String, i64)>, auth: &AuthUser) -> AppResult<()>;
async fn sitemap(&self, auth: &AuthUser) -> AppResult<Vec<(String, Option<String>)>>;
}
pub async fn list_published(
pool: &crate::db::Pool,
page_num: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<page::Page>, i64)> {
page::list_published(pool, page_num, page_size, auth.tenant_id()).await
pub struct PageServiceImpl {
pool: Arc<crate::db::Pool>,
aspect_engine: Arc<AspectEngine>,
}
pub async fn get_by_slug(
pool: &crate::db::Pool,
slug: &str,
auth: &AuthUser,
) -> AppResult<page::Page> {
page::find_by_slug(pool, slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))
}
pub async fn get_by_id(pool: &crate::db::Pool, id: &str, auth: &AuthUser) -> AppResult<page::Page> {
page::find_by_document_id(pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))
}
pub async fn list_all(
pool: &crate::db::Pool,
page_num: i64,
page_size: i64,
status: Option<PageStatus>,
auth: &AuthUser,
) -> AppResult<(Vec<page::Page>, i64)> {
page::list_all(pool, page_num, page_size, status, auth.tenant_id()).await
}
pub async fn create_page(
pool: &crate::db::Pool,
auth: &AuthUser,
cmd: CreatePageCmd,
) -> AppResult<page::Page> {
if let Some(ref blocks) = cmd.blocks {
validate_blocks_json(blocks)?;
impl PageServiceImpl {
pub fn new(pool: Arc<crate::db::Pool>, aspect_engine: Arc<AspectEngine>) -> Self {
Self {
pool,
aspect_engine,
}
}
page::create(pool, &cmd, auth.tenant_id()).await
}
pub async fn update_page(
pool: &crate::db::Pool,
auth: &AuthUser,
document_id: &str,
mut cmd: UpdatePageCmd,
) -> AppResult<page::Page> {
if let Some(ref blocks) = cmd.blocks {
validate_blocks_json(blocks)?;
fn validate_blocks_json(blocks: &str) -> AppResult<Vec<page::PageBlock>> {
serde_json::from_str(blocks)
.map_err(|e| AppError::BadRequest(format!("invalid blocks JSON: {e}")))
}
let existing = page::find_by_document_id(pool, document_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
async fn before_create(
&self,
auth: &AuthUser,
cmd: CreatePageCmd,
) -> AppResult<(CreatePageCmd, crate::aspects::Dispatched)> {
self.aspect_engine.before_create("pages", auth, cmd).await
}
cmd.id = existing.id;
page::update(pool, &cmd, auth.tenant_id()).await
async fn before_update(
&self,
auth: &AuthUser,
existing: &Page,
cmd: UpdatePageCmd,
) -> AppResult<(UpdatePageCmd, crate::aspects::Dispatched)> {
self.aspect_engine
.before_update("pages", auth, existing, cmd)
.await
}
async fn before_delete(
&self,
auth: &AuthUser,
existing: &Page,
) -> AppResult<crate::aspects::Dispatched> {
self.aspect_engine
.before_delete("pages", auth, existing)
.await
}
fn after_created(&self, p: &Page) {
self.aspect_engine.emit(Event::PageCreated(p.clone()));
}
fn after_updated(&self, p: &Page) {
self.aspect_engine.emit(Event::PageUpdated(p.clone()));
}
fn after_deleted(&self, p: &Page) {
self.aspect_engine.emit(Event::PageDeleted(p.clone()));
}
}
pub async fn delete_page(pool: &crate::db::Pool, id: &str, auth: &AuthUser) -> AppResult<()> {
let p = page::find_by_document_id(pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
page::delete(pool, p.id, auth.tenant_id()).await
}
#[async_trait]
impl PageService for PageServiceImpl {
async fn list_published(
&self,
page_num: i64,
page_size: i64,
auth: &AuthUser,
) -> AppResult<(Vec<Page>, i64)> {
page::list_published(&self.pool, page_num, page_size, auth.tenant_id()).await
}
pub async fn update_status(
pool: &crate::db::Pool,
id: &str,
status: PageStatus,
auth: &AuthUser,
) -> AppResult<page::Page> {
let p = page::find_by_document_id(pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
page::update_status(pool, p.id, status, auth.user_int_id(), auth.tenant_id()).await
}
async fn get_by_slug(&self, slug: &str, auth: &AuthUser) -> AppResult<Page> {
page::find_by_slug(&self.pool, slug, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))
}
pub async fn reorder(
pool: &crate::db::Pool,
items: Vec<(String, i64)>,
auth: &AuthUser,
) -> AppResult<()> {
let mut resolved = Vec::new();
for (doc_id, sort_order) in items {
let p = page::find_by_document_id(pool, &doc_id, auth.tenant_id())
async fn get_by_id(&self, id: &str, auth: &AuthUser) -> AppResult<Page> {
page::find_by_document_id(&self.pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))
}
async fn list_all(
&self,
page_num: i64,
page_size: i64,
status: Option<PageStatus>,
auth: &AuthUser,
) -> AppResult<(Vec<Page>, i64)> {
page::list_all(&self.pool, page_num, page_size, status, auth.tenant_id()).await
}
async fn create_page(&self, auth: &AuthUser, cmd: CreatePageCmd) -> AppResult<Page> {
let (cmd, _d) = self.before_create(auth, cmd).await?;
if let Some(ref blocks) = cmd.blocks {
Self::validate_blocks_json(blocks)?;
}
let p = page::create(&self.pool, &cmd, auth.tenant_id()).await?;
self.after_created(&p);
Ok(p)
}
async fn update_page(
&self,
auth: &AuthUser,
document_id: &str,
mut cmd: UpdatePageCmd,
) -> AppResult<Page> {
let existing = page::find_by_document_id(&self.pool, document_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
resolved.push((p.id, sort_order));
}
page::reorder(pool, &resolved, auth.tenant_id()).await
}
pub async fn sitemap(
pool: &crate::db::Pool,
auth: &AuthUser,
) -> AppResult<Vec<(String, Option<String>)>> {
page::list_sitemap(pool, auth.tenant_id()).await
cmd.id = existing.id;
let (cmd, _d) = self.before_update(auth, &existing, cmd).await?;
if let Some(ref blocks) = cmd.blocks {
Self::validate_blocks_json(blocks)?;
}
let updated = page::update(&self.pool, &cmd, auth.tenant_id()).await?;
self.after_updated(&updated);
Ok(updated)
}
async fn delete_page(&self, id: &str, auth: &AuthUser) -> AppResult<()> {
let p = page::find_by_document_id(&self.pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
self.before_delete(auth, &p).await?;
page::delete(&self.pool, p.id, auth.tenant_id()).await?;
self.after_deleted(&p);
Ok(())
}
async fn update_status(
&self,
id: &str,
status: PageStatus,
auth: &AuthUser,
) -> AppResult<Page> {
let p = page::find_by_document_id(&self.pool, id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
page::update_status(
&self.pool,
p.id,
status,
auth.user_int_id(),
auth.tenant_id(),
)
.await
}
async fn reorder(&self, items: Vec<(String, i64)>, auth: &AuthUser) -> AppResult<()> {
let mut resolved = Vec::new();
for (doc_id, sort_order) in items {
let p = page::find_by_document_id(&self.pool, &doc_id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("page"))?;
resolved.push((p.id, sort_order));
}
page::reorder(&self.pool, &resolved, auth.tenant_id()).await
}
async fn sitemap(&self, auth: &AuthUser) -> AppResult<Vec<(String, Option<String>)>> {
page::list_sitemap(&self.pool, auth.tenant_id()).await
}
}
#[cfg(test)]
@@ -124,27 +219,27 @@ mod tests {
#[test]
fn validate_blocks_json_valid_empty() {
let blocks = validate_blocks_json("[]").unwrap();
let blocks = PageServiceImpl::validate_blocks_json("[]").unwrap();
assert!(blocks.is_empty());
}
#[test]
fn validate_blocks_json_valid_richtext() {
let json = r#"[{"type":"richtext","content":"hello"}]"#;
let blocks = validate_blocks_json(json).unwrap();
let blocks = PageServiceImpl::validate_blocks_json(json).unwrap();
assert_eq!(blocks.len(), 1);
assert!(matches!(blocks[0], page::PageBlock::Richtext { .. }));
}
#[test]
fn validate_blocks_json_invalid() {
let result = validate_blocks_json("not json");
let result = PageServiceImpl::validate_blocks_json("not json");
assert!(result.is_err());
}
#[test]
fn validate_blocks_json_invalid_structure() {
let result = validate_blocks_json(r#"[{"wrong":"field"}]"#);
let result = PageServiceImpl::validate_blocks_json(r#"[{"wrong":"field"}]"#);
assert!(result.is_err());
}
}
+12 -14
View File
@@ -2,18 +2,16 @@
use chrono::Utc;
use crate::aspects::engine::AspectEngine;
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::middleware::auth::AuthUser;
use crate::repositories::UserRepository;
/// Request a password reset.
///
/// Finds the user, deletes old tokens, creates a new token, and triggers email sending via EventBus.
/// Always returns success regardless of whether the user exists (to prevent email enumeration).
pub async fn forgot_password(
pool: &crate::db::Pool,
_user_repo: &dyn UserRepository,
eventbus: &crate::eventbus::EventBus,
aspect_engine: &AspectEngine,
email: &str,
_tenant_id: Option<&str>,
) -> AppResult<()> {
@@ -37,7 +35,7 @@ pub async fn forgot_password(
let reset_token = crate::models::password_reset::create(pool, user.id, 3600).await?;
eventbus.emit(crate::eventbus::Event::PasswordResetRequested {
aspect_engine.emit(Event::PasswordResetRequested {
user: user.clone(),
token: reset_token,
});
@@ -193,8 +191,8 @@ mod tests {
pool
}
fn eventbus() -> crate::eventbus::EventBus {
crate::eventbus::EventBus::new(16)
fn aspect_engine() -> crate::aspects::engine::AspectEngine {
crate::aspects::engine::AspectEngine::new()
}
async fn insert_user(pool: &crate::db::Pool, email: &str) -> crate::models::user::User {
@@ -229,8 +227,8 @@ mod tests {
let pool = setup_pool().await;
let user = insert_user(&pool, "reset@test.com").await;
let repo = SqlxUserRepository::new(pool.clone());
let eb = eventbus();
super::forgot_password(&pool, &repo, &eb, "reset@test.com", None)
let ae = aspect_engine();
super::forgot_password(&pool, &repo, &ae, "reset@test.com", None)
.await
.unwrap();
let sql = format!(
@@ -249,8 +247,8 @@ mod tests {
async fn forgot_password_nonexistent_user_ok() {
let pool = setup_pool().await;
let repo = SqlxUserRepository::new(pool.clone());
let eb = eventbus();
super::forgot_password(&pool, &repo, &eb, "noone@test.com", None)
let ae = aspect_engine();
super::forgot_password(&pool, &repo, &ae, "noone@test.com", None)
.await
.unwrap();
}
@@ -270,9 +268,9 @@ mod tests {
async fn reset_password_weak_password() {
let pool = setup_pool().await;
let user = insert_user(&pool, "weak@test.com").await;
let eb = eventbus();
let ae = aspect_engine();
let repo = SqlxUserRepository::new(pool.clone());
super::forgot_password(&pool, &repo, &eb, "weak@test.com", None)
super::forgot_password(&pool, &repo, &ae, "weak@test.com", None)
.await
.unwrap();
let sql = format!(
+408 -8
View File
@@ -1,3 +1,8 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::audit::AuditService;
use crate::commands::{
CreatePaymentChannelCmd, CreatePaymentOrderCmd, CreatePaymentRefundCmd,
@@ -6,6 +11,7 @@ use crate::commands::{
use crate::config::app::AppConfig;
use crate::dto::payment::*;
use crate::errors::app_error::{AppError, AppResult};
use crate::eventbus::Event;
use crate::middleware::auth::AuthUser;
use crate::models::payment_channel::PaymentChannel;
use crate::models::payment_order::{PaymentOrder, PaymentStatus};
@@ -15,11 +21,406 @@ use crate::models::wallet_transaction::{WalletReferenceType, WalletTxType};
use crate::payment::ProviderResponse;
use crate::payment::routing::{RoutingContext, select_best_channel, select_channels};
use crate::repositories::{
PaymentChannelRepository, PaymentOrderRepository, PaymentRefundRepository,
PaymentTransactionRepository, WalletRepository,
OrderRepository, PaymentChannelRepository, PaymentOrderRepository, PaymentRefundRepository,
PaymentTransactionRepository, ProductRepository, WalletRepository,
};
use base64::Engine;
#[async_trait]
pub trait PaymentService: Send + Sync {
async fn create_channel(
&self,
auth: &AuthUser,
req: CreatePaymentChannelRequest,
) -> AppResult<PaymentChannel>;
async fn update_channel(
&self,
auth: &AuthUser,
id: &str,
req: UpdatePaymentChannelRequest,
) -> AppResult<PaymentChannel>;
async fn delete_channel(&self, auth: &AuthUser, id: &str) -> AppResult<()>;
async fn get_channel(&self, auth: &AuthUser, id: &str) -> AppResult<PaymentChannel>;
async fn list_channels(&self, auth: &AuthUser) -> AppResult<Vec<PaymentChannel>>;
async fn list_available_channels(
&self,
auth: &AuthUser,
order_id: &str,
country: Option<&str>,
language: Option<&str>,
) -> AppResult<AvailableChannelsResponse>;
async fn create_payment_order(
&self,
auth: &AuthUser,
user_id: i64,
req: CreatePaymentOrderRequest,
client_ip: Option<&str>,
client_language: Option<&str>,
client_user_agent: Option<&str>,
) -> AppResult<(PaymentOrder, Option<ProviderResponse>)>;
async fn cancel_payment_order(&self, auth: &AuthUser, id: &str, user_id: i64) -> AppResult<()>;
async fn get_payment_order(
&self,
auth: &AuthUser,
user_id: i64,
id: &str,
) -> AppResult<PaymentOrder>;
async fn list_user_payment_orders(
&self,
auth: &AuthUser,
user_id: i64,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentOrder>, i64)>;
async fn handle_callback(
&self,
channel_doc_id: &str,
headers: &axum::http::HeaderMap,
body: &[u8],
) -> AppResult<PaymentOrder>;
async fn refund_payment_order(
&self,
auth: &AuthUser,
id: &str,
req: CreateRefundRequest,
) -> AppResult<PaymentRefund>;
async fn list_admin_payment_orders(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
status: Option<&str>,
) -> AppResult<(Vec<PaymentOrder>, i64)>;
async fn list_admin_transactions(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentTransaction>, i64)>;
async fn list_admin_refunds(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentRefund>, i64)>;
async fn list_admin_channels(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentChannel>, i64)>;
async fn list_order_transactions(
&self,
auth: &AuthUser,
user_id: i64,
order_id: &str,
) -> AppResult<Vec<PaymentTransaction>>;
async fn list_order_refunds(
&self,
auth: &AuthUser,
user_id: i64,
order_id: &str,
) -> AppResult<Vec<PaymentRefund>>;
}
pub struct PaymentServiceImpl {
channel_repo: Arc<dyn PaymentChannelRepository>,
order_repo: Arc<dyn PaymentOrderRepository>,
tx_repo: Arc<dyn PaymentTransactionRepository>,
refund_repo: Arc<dyn PaymentRefundRepository>,
shop_order_repo: Arc<dyn OrderRepository>,
product_repo: Arc<dyn ProductRepository>,
wallet_repo: Arc<dyn WalletRepository>,
config: Arc<AppConfig>,
aspect_engine: Arc<AspectEngine>,
pool: Arc<crate::db::Pool>,
}
impl PaymentServiceImpl {
#[allow(clippy::too_many_arguments)]
pub fn new(
channel_repo: Arc<dyn PaymentChannelRepository>,
order_repo: Arc<dyn PaymentOrderRepository>,
tx_repo: Arc<dyn PaymentTransactionRepository>,
refund_repo: Arc<dyn PaymentRefundRepository>,
shop_order_repo: Arc<dyn OrderRepository>,
product_repo: Arc<dyn ProductRepository>,
wallet_repo: Arc<dyn WalletRepository>,
config: Arc<AppConfig>,
aspect_engine: Arc<AspectEngine>,
pool: Arc<crate::db::Pool>,
) -> Self {
Self {
channel_repo,
order_repo,
tx_repo,
refund_repo,
shop_order_repo,
product_repo,
wallet_repo,
config,
aspect_engine,
pool,
}
}
fn after_payment_order_created(&self, order: &PaymentOrder) {
self.aspect_engine
.emit(Event::PaymentOrderCreated(order.clone()));
}
fn after_payment_paid(&self, order: &PaymentOrder) {
self.aspect_engine.emit(Event::PaymentPaid(order.clone()));
}
fn after_payment_refunded(&self, order: &PaymentOrder) {
self.aspect_engine
.emit(Event::PaymentRefunded(order.clone()));
}
}
#[async_trait]
impl PaymentService for PaymentServiceImpl {
async fn create_channel(
&self,
auth: &AuthUser,
req: CreatePaymentChannelRequest,
) -> AppResult<PaymentChannel> {
let audit = AuditService::new((*self.pool).clone());
create_channel(self.channel_repo.as_ref(), auth, &self.config, &audit, req).await
}
async fn update_channel(
&self,
auth: &AuthUser,
id: &str,
req: UpdatePaymentChannelRequest,
) -> AppResult<PaymentChannel> {
let audit = AuditService::new((*self.pool).clone());
update_channel(
self.channel_repo.as_ref(),
auth,
&self.config,
&audit,
id,
req,
)
.await
}
async fn delete_channel(&self, auth: &AuthUser, id: &str) -> AppResult<()> {
let audit = AuditService::new((*self.pool).clone());
delete_channel(self.channel_repo.as_ref(), auth, &audit, id).await
}
async fn get_channel(&self, auth: &AuthUser, id: &str) -> AppResult<PaymentChannel> {
get_channel(self.channel_repo.as_ref(), auth, id).await
}
async fn list_channels(&self, auth: &AuthUser) -> AppResult<Vec<PaymentChannel>> {
list_channels(self.channel_repo.as_ref(), auth).await
}
async fn list_available_channels(
&self,
auth: &AuthUser,
order_id: &str,
country: Option<&str>,
language: Option<&str>,
) -> AppResult<AvailableChannelsResponse> {
list_available_channels(
self.channel_repo.as_ref(),
self.shop_order_repo.as_ref(),
auth,
order_id,
country,
language,
)
.await
}
async fn create_payment_order(
&self,
auth: &AuthUser,
user_id: i64,
req: CreatePaymentOrderRequest,
client_ip: Option<&str>,
client_language: Option<&str>,
client_user_agent: Option<&str>,
) -> AppResult<(PaymentOrder, Option<ProviderResponse>)> {
let (order, resp) = create_payment_order(
&self.pool,
self.channel_repo.as_ref(),
self.order_repo.as_ref(),
self.product_repo.as_ref(),
self.shop_order_repo.as_ref(),
auth,
user_id,
req,
&self.config,
client_ip,
client_language,
client_user_agent,
)
.await?;
self.after_payment_order_created(&order);
Ok((order, resp))
}
async fn cancel_payment_order(&self, auth: &AuthUser, id: &str, user_id: i64) -> AppResult<()> {
let audit = AuditService::new((*self.pool).clone());
cancel_payment_order(
&self.pool,
self.order_repo.as_ref(),
self.channel_repo.as_ref(),
auth,
&audit,
&self.config,
id,
user_id,
)
.await
}
async fn get_payment_order(
&self,
auth: &AuthUser,
user_id: i64,
id: &str,
) -> AppResult<PaymentOrder> {
get_payment_order(self.order_repo.as_ref(), auth, user_id, id).await
}
async fn list_user_payment_orders(
&self,
auth: &AuthUser,
user_id: i64,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentOrder>, i64)> {
list_user_payment_orders(self.order_repo.as_ref(), auth, user_id, page, page_size).await
}
async fn handle_callback(
&self,
channel_doc_id: &str,
headers: &axum::http::HeaderMap,
body: &[u8],
) -> AppResult<PaymentOrder> {
let audit = AuditService::new((*self.pool).clone());
let order = handle_callback(
&self.pool,
self.channel_repo.as_ref(),
self.order_repo.as_ref(),
self.tx_repo.as_ref(),
self.wallet_repo.as_ref(),
&audit,
&self.config,
channel_doc_id,
headers,
body,
)
.await?;
self.after_payment_paid(&order);
Ok(order)
}
async fn refund_payment_order(
&self,
auth: &AuthUser,
id: &str,
req: CreateRefundRequest,
) -> AppResult<PaymentRefund> {
let audit = AuditService::new((*self.pool).clone());
let refund = refund_payment_order(
&self.pool,
self.order_repo.as_ref(),
self.channel_repo.as_ref(),
self.tx_repo.as_ref(),
self.refund_repo.as_ref(),
self.wallet_repo.as_ref(),
auth,
&audit,
&self.config,
id,
req,
)
.await?;
if let Ok(Some(order)) = self
.order_repo
.find_by_document_id(id, auth.tenant_id())
.await
{
self.after_payment_refunded(&order);
}
Ok(refund)
}
async fn list_admin_payment_orders(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
status: Option<&str>,
) -> AppResult<(Vec<PaymentOrder>, i64)> {
list_admin_payment_orders(self.order_repo.as_ref(), auth, page, page_size, status).await
}
async fn list_admin_transactions(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentTransaction>, i64)> {
list_admin_transactions(self.tx_repo.as_ref(), auth, page, page_size).await
}
async fn list_admin_refunds(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentRefund>, i64)> {
list_admin_refunds(self.refund_repo.as_ref(), auth, page, page_size).await
}
async fn list_admin_channels(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<PaymentChannel>, i64)> {
auth.ensure_admin()?;
self.channel_repo
.find_all_admin_paginated(auth.tenant_id(), page, page_size, None)
.await
}
async fn list_order_transactions(
&self,
auth: &AuthUser,
user_id: i64,
order_id: &str,
) -> AppResult<Vec<PaymentTransaction>> {
let order = self.get_payment_order(auth, user_id, order_id).await?;
self.tx_repo
.find_by_payment_order_id(order.id, auth.tenant_id())
.await
}
async fn list_order_refunds(
&self,
auth: &AuthUser,
user_id: i64,
order_id: &str,
) -> AppResult<Vec<PaymentRefund>> {
let order = self.get_payment_order(auth, user_id, order_id).await?;
self.refund_repo
.find_by_payment_order_id(order.id, auth.tenant_id())
.await
}
}
fn is_unique_violation(err: &AppError) -> bool {
match err {
AppError::Internal(e) => {
@@ -514,7 +915,7 @@ pub async fn handle_callback(
channel_doc_id: &str,
headers: &axum::http::HeaderMap,
body: &[u8],
) -> AppResult<()> {
) -> AppResult<PaymentOrder> {
let channel = channel_repo
.find_by_document_id(channel_doc_id, None)
.await?
@@ -555,7 +956,7 @@ pub async fn handle_callback(
}
if payment_order.status == PaymentStatus::Paid {
return Ok(());
return Ok(payment_order);
}
if payment_order.status != PaymentStatus::Pending {
@@ -567,7 +968,7 @@ pub async fn handle_callback(
}
if callback.status != PaymentStatus::Paid {
return Ok(());
return Ok(payment_order);
}
if let Some(ref provider_tx_id) = callback.provider_tx_id
@@ -576,7 +977,7 @@ pub async fn handle_callback(
.await?
.is_some()
{
return Ok(());
return Ok(payment_order);
}
crate::in_transaction!(pool, tx, {
@@ -594,7 +995,6 @@ pub async fn handle_callback(
"callback for order {} skipped: CAS failed (already processed)",
payment_order.document_id
);
return Ok(());
}
if let Some(ref provider_tx_id) = callback.provider_tx_id {
@@ -667,7 +1067,7 @@ pub async fn handle_callback(
None
);
Ok(())
Ok(payment_order)
}
#[allow(clippy::too_many_arguments)]
+3 -5
View File
@@ -123,9 +123,7 @@ impl PostServiceImpl {
auth: &AuthUser,
req: CreatePostRequest,
) -> AppResult<(CreatePostRequest, crate::aspects::Dispatched)> {
self.aspect_engine
.before_create(self.repo.pool(), "posts", auth, req)
.await
self.aspect_engine.before_create("posts", auth, req).await
}
async fn before_update(
@@ -136,7 +134,7 @@ impl PostServiceImpl {
) -> AppResult<(UpdatePostRequest, crate::aspects::Dispatched)> {
crate::policy::PostPolicy::can_update(auth, existing)?;
self.aspect_engine
.before_update(self.repo.pool(), "posts", auth, existing, req)
.before_update("posts", auth, existing, req)
.await
}
@@ -147,7 +145,7 @@ impl PostServiceImpl {
) -> AppResult<crate::aspects::Dispatched> {
crate::policy::PostPolicy::can_delete(auth, existing)?;
self.aspect_engine
.before_delete(self.repo.pool(), "posts", auth, existing)
.before_delete("posts", auth, existing)
.await
}
+669 -576
View File
File diff suppressed because it is too large Load Diff
+177 -106
View File
@@ -1,77 +1,159 @@
//! Tag service.
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::dto::CreateTagRequest;
use crate::errors::app_error::{AppError, AppResult};
use crate::event::Event;
use crate::middleware::auth::AuthUser;
use crate::models::tag::Tag;
use crate::repositories::TagRepository;
pub fn generate_slug(name: &str) -> String {
crate::aspects::slug_aspect::generate_slug(name)
}
pub async fn create_tag(
tag_repo: &dyn TagRepository,
auth: &AuthUser,
req: CreateTagRequest,
slug: String,
) -> AppResult<crate::models::tag::Tag> {
tag_repo
.create(&req.name, &slug, auth.tenant_id(), auth.user_int_id())
.await
#[async_trait]
pub trait TagService: Send + Sync {
async fn create(&self, auth: &AuthUser, req: CreateTagRequest) -> AppResult<Tag>;
async fn update(&self, auth: &AuthUser, id: &str, name: String, slug: String)
-> AppResult<Tag>;
async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()>;
async fn get(&self, id: &str, auth: &AuthUser) -> AppResult<Tag>;
async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Tag>>;
async fn list_paginated(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<Tag>, i64)>;
}
pub async fn delete_tag(tag_repo: &dyn TagRepository, id: &str, auth: &AuthUser) -> AppResult<()> {
let tag = tag_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))?;
tag_repo.delete(tag.id, auth.tenant_id()).await?;
Ok(())
pub struct TagServiceImpl {
repo: Arc<dyn TagRepository>,
aspect_engine: Arc<AspectEngine>,
}
pub async fn get_tag(
tag_repo: &dyn TagRepository,
id: &str,
auth: &AuthUser,
) -> AppResult<crate::models::tag::Tag> {
tag_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))
impl TagServiceImpl {
pub fn new(repo: Arc<dyn TagRepository>, aspect_engine: Arc<AspectEngine>) -> Self {
Self {
repo,
aspect_engine,
}
}
async fn before_create(
&self,
auth: &AuthUser,
req: CreateTagRequest,
) -> AppResult<(CreateTagRequest, crate::aspects::Dispatched)> {
self.aspect_engine.before_create("tags", auth, req).await
}
async fn before_update(
&self,
auth: &AuthUser,
existing: &Tag,
req: (String, String),
) -> AppResult<((String, String), crate::aspects::Dispatched)> {
self.aspect_engine
.before_update("tags", auth, existing, req)
.await
}
async fn before_delete(
&self,
auth: &AuthUser,
existing: &Tag,
) -> AppResult<crate::aspects::Dispatched> {
self.aspect_engine
.before_delete("tags", auth, existing)
.await
}
fn after_created(&self, tag: &Tag) {
self.aspect_engine.emit(Event::TagCreated(tag.clone()));
}
fn after_updated(&self, tag: &Tag) {
self.aspect_engine.emit(Event::TagUpdated(tag.clone()));
}
fn after_deleted(&self, tag: &Tag) {
self.aspect_engine.emit(Event::TagDeleted(tag.clone()));
}
}
pub async fn update_tag(
tag_repo: &dyn TagRepository,
id: &str,
auth: &AuthUser,
name: String,
slug: String,
) -> AppResult<crate::models::tag::Tag> {
let tag = tag_repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))?;
tag_repo
.update(tag.id, &name, &slug, auth.tenant_id())
.await
}
#[async_trait]
impl TagService for TagServiceImpl {
async fn create(&self, auth: &AuthUser, req: CreateTagRequest) -> AppResult<Tag> {
let (req, _d) = self.before_create(auth, req).await?;
let slug = generate_slug(&req.name);
let tag = self
.repo
.create(&req.name, &slug, auth.tenant_id(), auth.user_int_id())
.await?;
self.after_created(&tag);
Ok(tag)
}
pub async fn list_tags(
tag_repo: &dyn TagRepository,
auth: &AuthUser,
) -> AppResult<Vec<crate::models::tag::Tag>> {
tag_repo.find_all(auth.tenant_id()).await
}
async fn update(
&self,
auth: &AuthUser,
id: &str,
name: String,
slug: String,
) -> AppResult<Tag> {
let tag = self
.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))?;
let ((name, slug), _d) = self.before_update(auth, &tag, (name, slug)).await?;
let updated = self
.repo
.update(tag.id, &name, &slug, auth.tenant_id())
.await?;
self.after_updated(&updated);
Ok(updated)
}
pub async fn list_tags_paginated(
tag_repo: &dyn TagRepository,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<crate::models::tag::Tag>, i64)> {
tag_repo
.find_paginated(auth.tenant_id(), page, page_size)
.await
async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()> {
let tag = self
.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))?;
self.before_delete(auth, &tag).await?;
self.repo.delete(tag.id, auth.tenant_id()).await?;
self.after_deleted(&tag);
Ok(())
}
async fn get(&self, id: &str, auth: &AuthUser) -> AppResult<Tag> {
self.repo
.find_by_document_id(id, auth.tenant_id())
.await?
.ok_or_else(|| AppError::not_found("tag"))
}
async fn list(&self, auth: &AuthUser) -> AppResult<Vec<Tag>> {
self.repo.find_all(auth.tenant_id()).await
}
async fn list_paginated(
&self,
auth: &AuthUser,
page: i64,
page_size: i64,
) -> AppResult<(Vec<Tag>, i64)> {
self.repo
.find_paginated(auth.tenant_id(), page, page_size)
.await
}
}
#[cfg(test)]
@@ -98,21 +180,27 @@ mod tests {
)
}
fn make_service(pool: crate::db::Pool) -> Arc<dyn TagService> {
Arc::new(TagServiceImpl::new(
Arc::new(SqlxTagRepository::new(pool.clone())),
Arc::new(AspectEngine::new()),
))
}
#[tokio::test]
async fn create_tag_basic() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
let tag = super::create_tag(
&repo,
&a,
CreateTagRequest {
name: "Rust".into(),
},
super::generate_slug("Rust"),
)
.await
.unwrap();
let tag = svc
.create(
&a,
CreateTagRequest {
name: "Rust".into(),
},
)
.await
.unwrap();
assert_eq!(tag.name, "Rust");
assert_eq!(tag.slug, "rust");
}
@@ -120,34 +208,25 @@ mod tests {
#[tokio::test]
async fn list_tags_empty() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
let tags = super::list_tags(&repo, &a).await.unwrap();
let tags = svc.list(&a).await.unwrap();
assert!(tags.is_empty());
}
#[tokio::test]
async fn update_tag() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
let tag = super::create_tag(
&repo,
&a,
CreateTagRequest { name: "Old".into() },
super::generate_slug("Old"),
)
.await
.unwrap();
let updated = super::update_tag(
&repo,
&tag.document_id,
&a,
"New".into(),
super::generate_slug("New"),
)
.await
.unwrap();
let tag = svc
.create(&a, CreateTagRequest { name: "Old".into() })
.await
.unwrap();
let updated = svc
.update(&a, &tag.document_id, "New".into(), generate_slug("New"))
.await
.unwrap();
assert_eq!(updated.name, "New");
assert_eq!(updated.slug, "new");
}
@@ -155,38 +234,32 @@ mod tests {
#[tokio::test]
async fn delete_tag() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
let tag = super::create_tag(
&repo,
&a,
CreateTagRequest { name: "Del".into() },
super::generate_slug("Del"),
)
.await
.unwrap();
super::delete_tag(&repo, &tag.document_id, &a)
let tag = svc
.create(&a, CreateTagRequest { name: "Del".into() })
.await
.unwrap();
let tags = super::list_tags(&repo, &a).await.unwrap();
svc.delete(&tag.document_id, &a).await.unwrap();
let tags = svc.list(&a).await.unwrap();
assert!(tags.is_empty());
}
#[tokio::test]
async fn delete_tag_not_found() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
assert!(super::delete_tag(&repo, "no-such-tag", &a).await.is_err());
assert!(svc.delete("no-such-tag", &a).await.is_err());
}
#[tokio::test]
async fn update_tag_not_found() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
assert!(
super::update_tag(&repo, "missing", &a, "X".into(), super::generate_slug("X"))
svc.update(&a, "missing", "X".into(), generate_slug("X"))
.await
.is_err()
);
@@ -195,21 +268,19 @@ mod tests {
#[tokio::test]
async fn list_tags_paginated() {
let pool = setup_pool().await;
let repo = SqlxTagRepository::new(pool.clone());
let svc = make_service(pool.clone());
let a = auth();
for i in 0..5 {
super::create_tag(
&repo,
svc.create(
&a,
CreateTagRequest {
name: format!("Tag{i}"),
},
super::generate_slug(&format!("Tag{i}")),
)
.await
.unwrap();
}
let (tags, total) = super::list_tags_paginated(&repo, &a, 1, 3).await.unwrap();
let (tags, total) = svc.list_paginated(&a, 1, 3).await.unwrap();
assert_eq!(total, 5);
assert_eq!(tags.len(), 3);
}
+217
View File
@@ -1,6 +1,12 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::aspects::engine::AspectEngine;
use crate::db::dialect::ph;
use crate::db::pool::DbConnection;
use crate::errors::app_error::{AppError, AppResult};
use crate::eventbus::Event;
use crate::models::currencies;
use crate::models::wallet;
use crate::models::wallet::WalletStatus;
@@ -8,6 +14,92 @@ use crate::models::wallet_transaction::WalletTransaction;
use crate::models::wallet_transaction::{WalletEntryType, WalletReferenceType, WalletTxType};
use crate::repositories::WalletRepository;
#[async_trait]
pub trait WalletService: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn credit(
&self,
user_id: i64,
currency: &str,
amount: i64,
tx_type: WalletTxType,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<WalletTransaction>;
#[allow(clippy::too_many_arguments)]
async fn debit(
&self,
user_id: i64,
currency: &str,
amount: i64,
tx_type: WalletTxType,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<WalletTransaction>;
#[allow(clippy::too_many_arguments)]
async fn transfer(
&self,
from_user_id: i64,
to_user_id: i64,
currency: &str,
amount: i64,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<(WalletTransaction, WalletTransaction)>;
async fn reverse_transaction(
&self,
original_tx_id: i64,
transaction_no: &str,
) -> AppResult<WalletTransaction>;
async fn tx_to_response(
&self,
tx: WalletTransaction,
) -> AppResult<crate::dto::WalletTransactionResponse>;
async fn tx_list_to_response(
&self,
rows: Vec<WalletTransaction>,
) -> AppResult<Vec<crate::dto::WalletTransactionResponse>>;
}
pub struct WalletServiceImpl {
repo: Arc<dyn WalletRepository>,
aspect_engine: Arc<AspectEngine>,
pool: Arc<crate::db::Pool>,
}
impl WalletServiceImpl {
pub fn new(
repo: Arc<dyn WalletRepository>,
aspect_engine: Arc<AspectEngine>,
pool: Arc<crate::db::Pool>,
) -> Self {
Self {
repo,
aspect_engine,
pool,
}
}
fn after_credited(&self, tx: &WalletTransaction) {
self.aspect_engine.emit(Event::WalletCredited(tx.clone()));
}
fn after_debited(&self, tx: &WalletTransaction) {
self.aspect_engine.emit(Event::WalletDebited(tx.clone()));
}
}
async fn ensure_currency_active(tx: &mut DbConnection, currency: &str) -> AppResult<()> {
currencies::find_by_code_tx(tx, currency)
.await?
@@ -652,6 +744,131 @@ pub async fn tx_list_to_response(
Ok(responses)
}
#[async_trait]
impl WalletService for WalletServiceImpl {
async fn credit(
&self,
user_id: i64,
currency: &str,
amount: i64,
tx_type: WalletTxType,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<WalletTransaction> {
let tx = credit_wallet(
self.repo.as_ref(),
&self.pool,
user_id,
currency,
amount,
tx_type,
transaction_no,
reference_type,
reference_id,
metadata,
)
.await?;
self.after_credited(&tx);
Ok(tx)
}
async fn debit(
&self,
user_id: i64,
currency: &str,
amount: i64,
tx_type: WalletTxType,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<WalletTransaction> {
let tx = debit_wallet(
self.repo.as_ref(),
&self.pool,
user_id,
currency,
amount,
tx_type,
transaction_no,
reference_type,
reference_id,
metadata,
)
.await?;
self.after_debited(&tx);
Ok(tx)
}
async fn transfer(
&self,
from_user_id: i64,
to_user_id: i64,
currency: &str,
amount: i64,
transaction_no: &str,
reference_type: Option<WalletReferenceType>,
reference_id: Option<&str>,
metadata: Option<&str>,
) -> AppResult<(WalletTransaction, WalletTransaction)> {
let (out_tx, in_tx) = transfer(
self.repo.as_ref(),
&self.pool,
from_user_id,
to_user_id,
currency,
amount,
transaction_no,
reference_type,
reference_id,
metadata,
)
.await?;
self.after_debited(&out_tx);
self.after_credited(&in_tx);
Ok((out_tx, in_tx))
}
async fn reverse_transaction(
&self,
original_tx_id: i64,
transaction_no: &str,
) -> AppResult<WalletTransaction> {
let tx = reverse_transaction(
self.repo.as_ref(),
&self.pool,
original_tx_id,
transaction_no,
)
.await?;
match tx.entry_type {
crate::models::wallet_transaction::WalletEntryType::Credit => {
self.after_credited(&tx);
}
crate::models::wallet_transaction::WalletEntryType::Debit => {
self.after_debited(&tx);
}
}
Ok(tx)
}
async fn tx_to_response(
&self,
tx: WalletTransaction,
) -> AppResult<crate::dto::WalletTransactionResponse> {
tx_to_response(self.repo.as_ref(), tx).await
}
async fn tx_list_to_response(
&self,
rows: Vec<WalletTransaction>,
) -> AppResult<Vec<crate::dto::WalletTransactionResponse>> {
tx_list_to_response(self.repo.as_ref(), rows).await
}
}
#[cfg(test)]
mod tests {
use super::*;
+68
View File
@@ -121,21 +121,65 @@ async fn build_test_app(pool: raisfast::db::Pool) -> (axum::Router, AppState) {
Arc::new(NoopSearchEngine),
))
},
page_service: Arc::new(raisfast::services::page::PageServiceImpl::new(
Arc::new(pool.clone()),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
)),
user_repo: Arc::new(SqlxUserRepository::new(pool.clone())),
category_repo: Arc::new(SqlxCategoryRepository::new(pool.clone())),
category_service: Arc::new(raisfast::services::category::CategoryServiceImpl::new(
Arc::new(SqlxCategoryRepository::new(pool.clone())),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
)),
tag_repo: Arc::new(SqlxTagRepository::new(pool.clone())),
tag_service: Arc::new(raisfast::services::tag::TagServiceImpl::new(
Arc::new(SqlxTagRepository::new(pool.clone())),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
)),
comment_repo: Arc::new(SqlxCommentRepository::new(pool.clone())),
comment_service: Arc::new(raisfast::services::comment::CommentServiceImpl::new(
Arc::new(CachedPostRepository::new(
SqlxPostRepository::new(pool.clone()),
Arc::new(MemoryCache::new()),
None,
)),
Arc::new(SqlxCommentRepository::new(pool.clone())),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
)),
media_repo: Arc::new(SqlxMediaRepository::new(pool.clone())),
refresh_token_repo: Arc::new(SqlxRefreshTokenRepository::new(pool.clone())),
wallet_repo: Arc::new(raisfast::repositories::SqlxWalletRepository::new(
pool.clone(),
)),
wallet_service: Arc::new(raisfast::services::wallet::WalletServiceImpl::new(
Arc::new(raisfast::repositories::SqlxWalletRepository::new(
pool.clone(),
)),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
Arc::new(pool.clone()),
)),
product_repo: Arc::new(raisfast::repositories::SqlxProductRepository::new(
pool.clone(),
)),
product_service: Arc::new(raisfast::services::product::ProductServiceImpl::new(
Arc::new(raisfast::repositories::SqlxProductRepository::new(
pool.clone(),
)),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
)),
order_repo: Arc::new(raisfast::repositories::SqlxOrderRepository::new(
pool.clone(),
)),
order_service: Arc::new(raisfast::services::order::OrderServiceImpl::new(
Arc::new(raisfast::repositories::SqlxOrderRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxProductRepository::new(
pool.clone(),
)),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
Arc::new(pool.clone()),
)),
payment_channel_repo: Arc::new(raisfast::repositories::SqlxPaymentChannelRepository::new(
pool.clone(),
)),
@@ -148,6 +192,30 @@ async fn build_test_app(pool: raisfast::db::Pool) -> (axum::Router, AppState) {
payment_refund_repo: Arc::new(raisfast::repositories::SqlxPaymentRefundRepository::new(
pool.clone(),
)),
payment_service: Arc::new(raisfast::services::payment::PaymentServiceImpl::new(
Arc::new(raisfast::repositories::SqlxPaymentChannelRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxPaymentOrderRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxPaymentTransactionRepository::new(pool.clone())),
Arc::new(raisfast::repositories::SqlxPaymentRefundRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxOrderRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxProductRepository::new(
pool.clone(),
)),
Arc::new(raisfast::repositories::SqlxWalletRepository::new(
pool.clone(),
)),
config.clone(),
Arc::new(raisfast::aspects::engine::AspectEngine::new()),
Arc::new(pool.clone()),
)),
search: Arc::new(NoopSearchEngine),
content_type_registry: Arc::new(raisfast::content_type::ContentTypeRegistry::new()),
aspect_engine: {
+18 -28
View File
@@ -68,14 +68,14 @@ async fn setup_pool() -> sqlx::SqlitePool {
pool
}
async fn create_test_user(pool: &sqlx::SqlitePool, label: &str) -> (i64, String) {
let eventbus = raisfast::eventbus::EventBus::new(16);
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let user_repo = SqlxUserRepository::new(pool.clone());
let req = raisfast::dto::RegisterRequest {
username: format!("user_{label}"),
email: format!("{label}@test.com"),
password: "Password123".into(),
};
let user = auth::register(&user_repo, &eventbus, req, None, false, pool)
let user = auth::register(&user_repo, &aspect_engine, req, None, false, pool)
.await
.unwrap();
let row: (i64,) = sqlx::query_as("SELECT id FROM users WHERE document_id = ?")
@@ -125,7 +125,7 @@ label = "优先级"
async fn tauri_auth_register_service() {
let pool = setup_pool().await;
let user_repo = SqlxUserRepository::new(pool.clone());
let eventbus = raisfast::eventbus::EventBus::new(16);
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let req = raisfast::dto::RegisterRequest {
username: "testuser".into(),
@@ -133,7 +133,7 @@ async fn tauri_auth_register_service() {
password: "Password123".into(),
};
let result = auth::register(&user_repo, &eventbus, req, None, false, &pool).await;
let result = auth::register(&user_repo, &aspect_engine, req, None, false, &pool).await;
assert!(
result.is_ok(),
@@ -148,14 +148,14 @@ async fn tauri_auth_register_service() {
async fn tauri_auth_register_duplicate_email() {
let pool = setup_pool().await;
let user_repo = SqlxUserRepository::new(pool.clone());
let eventbus = raisfast::eventbus::EventBus::new(16);
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let req = raisfast::dto::RegisterRequest {
username: "user1".into(),
email: "dup@example.com".into(),
password: "Password123".into(),
};
auth::register(&user_repo, &eventbus, req, None, false, &pool)
auth::register(&user_repo, &aspect_engine, req, None, false, &pool)
.await
.unwrap();
@@ -164,8 +164,8 @@ async fn tauri_auth_register_duplicate_email() {
email: "dup@example.com".into(),
password: "Password456".into(),
};
let eventbus2 = raisfast::eventbus::EventBus::new(16);
let result = auth::register(&user_repo, &eventbus2, req2, None, false, &pool).await;
let aspect_engine2 = raisfast::aspects::engine::AspectEngine::new();
let result = auth::register(&user_repo, &aspect_engine2, req2, None, false, &pool).await;
assert!(result.is_err(), "duplicate email should fail");
}
@@ -175,15 +175,14 @@ async fn tauri_auth_login_service() {
let config = test_config();
let user_repo = SqlxUserRepository::new(pool.clone());
let refresh_repo = SqlxRefreshTokenRepository::new(pool.clone());
let eventbus = raisfast::eventbus::EventBus::new(16);
let plugin_mgr = raisfast::plugins::PluginManager::new(Arc::new(config.clone())).await;
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let reg_req = raisfast::dto::RegisterRequest {
username: "loginuser".into(),
email: "login@example.com".into(),
password: "Password123".into(),
};
auth::register(&user_repo, &eventbus, reg_req, None, false, &pool)
auth::register(&user_repo, &aspect_engine, reg_req, None, false, &pool)
.await
.unwrap();
@@ -191,12 +190,11 @@ async fn tauri_auth_login_service() {
email: "login@example.com".into(),
password: "Password123".into(),
};
let eventbus2 = raisfast::eventbus::EventBus::new(16);
let aspect_engine2 = raisfast::aspects::engine::AspectEngine::new();
let result = auth::login(
&user_repo,
&refresh_repo,
&plugin_mgr,
&eventbus2,
&aspect_engine2,
&pool,
&login_req,
&config.jwt_secret,
@@ -219,15 +217,14 @@ async fn tauri_auth_login_wrong_password() {
let config = test_config();
let user_repo = SqlxUserRepository::new(pool.clone());
let refresh_repo = SqlxRefreshTokenRepository::new(pool.clone());
let eventbus = raisfast::eventbus::EventBus::new(16);
let plugin_mgr = raisfast::plugins::PluginManager::new(Arc::new(config.clone())).await;
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let reg_req = raisfast::dto::RegisterRequest {
username: "wrongpw".into(),
email: "wrong@example.com".into(),
password: "Password123".into(),
};
auth::register(&user_repo, &eventbus, reg_req, None, false, &pool)
auth::register(&user_repo, &aspect_engine, reg_req, None, false, &pool)
.await
.unwrap();
@@ -235,12 +232,11 @@ async fn tauri_auth_login_wrong_password() {
email: "wrong@example.com".into(),
password: "WrongPassword".into(),
};
let eventbus2 = raisfast::eventbus::EventBus::new(16);
let aspect_engine2 = raisfast::aspects::engine::AspectEngine::new();
let result = auth::login(
&user_repo,
&refresh_repo,
&plugin_mgr,
&eventbus2,
&aspect_engine2,
&pool,
&login_req,
&config.jwt_secret,
@@ -258,14 +254,14 @@ async fn tauri_auth_login_wrong_password() {
async fn tauri_auth_get_me_service() {
let pool = setup_pool().await;
let user_repo = SqlxUserRepository::new(pool.clone());
let eventbus = raisfast::eventbus::EventBus::new(16);
let aspect_engine = raisfast::aspects::engine::AspectEngine::new();
let reg_req = raisfast::dto::RegisterRequest {
username: "getme".into(),
email: "getme@example.com".into(),
password: "Password123".into(),
};
let user = auth::register(&user_repo, &eventbus, reg_req, None, false, &pool)
let user = auth::register(&user_repo, &aspect_engine, reg_req, None, false, &pool)
.await
.unwrap();
@@ -295,9 +291,6 @@ async fn tauri_post_create_and_list() {
Arc::new(raisfast::cache::MemoryCache::new()),
None,
));
let eventbus = raisfast::eventbus::EventBus::new(16);
let config = test_config();
let plugin_mgr = raisfast::plugins::PluginManager::new(Arc::new(config.clone())).await;
let svc = build_post_service(post_repo.clone());
let req = raisfast::dto::CreatePostRequest {
@@ -338,9 +331,6 @@ async fn tauri_post_get_by_slug() {
Arc::new(raisfast::cache::MemoryCache::new()),
None,
));
let eventbus = raisfast::eventbus::EventBus::new(16);
let config = test_config();
let plugin_mgr = raisfast::plugins::PluginManager::new(Arc::new(config.clone())).await;
let svc = build_post_service(post_repo.clone());
let req = raisfast::dto::CreatePostRequest {