From a412fe44e5ee85ef99ccb60aa449e809a414243d Mon Sep 17 00:00:00 2001 From: zhongqin Date: Fri, 15 May 2026 17:51:59 +0800 Subject: [PATCH] refactor servers layer --- Cargo.toml | 6 +- src/aspects/engine.rs | 532 +---------- src/commands/page.rs | 4 + src/dto/category.rs | 6 +- src/dto/order.rs | 8 +- src/dto/tag.rs | 4 +- src/event/mod.rs | 71 ++ src/handlers/auth.rs | 9 +- src/handlers/category.rs | 42 +- src/handlers/comment.rs | 92 +- src/handlers/oauth.rs | 2 +- src/handlers/order.rs | 75 +- src/handlers/page.rs | 51 +- src/handlers/payment.rs | 205 ++-- src/handlers/product.rs | 32 +- src/handlers/tag.rs | 65 +- src/handlers/wallet.rs | 84 +- src/lib.rs | 68 ++ src/server.rs | 89 +- src/services/auth.rs | 34 +- src/services/category.rs | 437 +++++---- src/services/comment.rs | 369 +++++--- src/services/email_verification.rs | 45 +- src/services/oauth.rs | 13 +- src/services/order.rs | 1400 +++++++++++++--------------- src/services/page.rs | 291 ++++-- src/services/password_reset.rs | 26 +- src/services/payment.rs | 416 ++++++++- src/services/post.rs | 8 +- src/services/product.rs | 1245 +++++++++++++------------ src/services/tag.rs | 283 +++--- src/services/wallet.rs | 217 +++++ tests/api.rs | 68 ++ tests/tauri_tests.rs | 46 +- 34 files changed, 3404 insertions(+), 2939 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ddc69609..f1106108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/src/aspects/engine.rs b/src/aspects/engine.rs index 915c5baa..805a9576 100644 --- a/src/aspects/engine.rs +++ b/src/aspects/engine.rs @@ -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( &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( &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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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>> = Arc::new(Mutex::new(Vec::new())); - - struct LateAspect { - log: Arc>>, - } - #[async_trait::async_trait] - impl Aspect for LateAspect { - fn name(&self) -> &str { - "late" - } - fn priority(&self) -> i32 { - 100 - } - fn pointcuts(&self) -> Vec { - 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>>, - } - #[async_trait::async_trait] - impl Aspect for EarlyAspect { - fn name(&self) -> &str { - "early" - } - fn priority(&self) -> i32 { - -100 - } - fn pointcuts(&self) -> Vec { - 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"); - } } diff --git a/src/commands/page.rs b/src/commands/page.rs index 20ce1fe9..6088e163 100644 --- a/src/commands/page.rs +++ b/src/commands/page.rs @@ -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, } +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdatePageCmd { pub id: i64, pub title: Option, diff --git a/src/dto/category.rs b/src/dto/category.rs index f42b9bf3..58deda46 100644 --- a/src/dto/category.rs +++ b/src/dto/category.rs @@ -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, diff --git a/src/dto/order.rs b/src/dto/order.rs index a5a1c229..4d98ca3b 100644 --- a/src/dto/order.rs +++ b/src/dto/order.rs @@ -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, @@ -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, @@ -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))] diff --git a/src/dto/tag.rs b/src/dto/tag.rs index 3438e5d0..c5ef25b4 100644 --- a/src/dto/tag.rs +++ b/src/dto/tag.rs @@ -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, diff --git a/src/event/mod.rs b/src/event/mod.rs index 1e681679..dfd6a89d 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -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, diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index edbbc8c8..b2b6f61b 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -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(), ) diff --git a/src/handlers/category.rs b/src/handlers/category.rs index 1e1ea298..c2e218f5 100644 --- a/src/handlers/category.rs +++ b/src/handlers/category.rs @@ -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>> { 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, Path(id): Path, ) -> AppResult> { - 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> { 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> { 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, ) -> AppResult> { 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>> { 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> { 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> { 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, ) -> AppResult> { 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; } } diff --git a/src/handlers/comment.rs b/src/handlers/comment.rs index 572e42c3..32d6dee1 100644 --- a/src/handlers/comment.rs +++ b/src/handlers/comment.rs @@ -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> { 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, Path(id): Path, ) -> AppResult> { - 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> { 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> { 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, ) -> AppResult> { 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; } diff --git a/src/handlers/oauth.rs b/src/handlers/oauth.rs index 756cd175..f1444738 100644 --- a/src/handlers/oauth.rs +++ b/src/handlers/oauth.rs @@ -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?; diff --git a/src/handlers/order.rs b/src/handlers/order.rs index 57b9dc26..33d532f8 100644 --- a/src/handlers/order.rs +++ b/src/handlers/order.rs @@ -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, ) -> AppResult> { 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>> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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)) } diff --git a/src/handlers/page.rs b/src/handlers/page.rs index 54bcfd11..3d97f811 100644 --- a/src/handlers/page.rs +++ b/src/handlers/page.rs @@ -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>> { 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, Path(slug): Path, ) -> AppResult> { - 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, ) -> AppResult>> { - 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, ) -> AppResult> { 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, ) -> AppResult> { 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, ) -> AppResult> { 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() { diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 56d590a7..732ed1f8 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -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 = 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> { 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> { 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>> { 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 = txs.into_iter().map(Into::into).collect(); Ok(ApiResponse::success(responses)) @@ -390,12 +370,9 @@ pub async fn list_order_refunds( ) -> AppResult>> { 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 = 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> { - 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, Query(query): Query, ) -> AppResult> { - 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 = 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, ) -> AppResult> { 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, Path(id): Path, ) -> AppResult> { - 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, ) -> AppResult> { 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, Path(id): Path, ) -> AppResult> { - 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>> { 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 = 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, ) -> AppResult> { 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> { 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>> { 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 = txs.into_iter().map(Into::into).collect(); Ok(params.paginate(responses, total)) } @@ -680,13 +616,10 @@ pub async fn admin_list_refunds( ) -> AppResult>> { 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 = refunds.into_iter().map(Into::into).collect(); Ok(params.paginate(responses, total)) } diff --git a/src/handlers/product.rs b/src/handlers/product.rs index a463d09f..12a9f494 100644 --- a/src/handlers/product.rs +++ b/src/handlers/product.rs @@ -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, ) -> AppResult>> { 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 = items.into_iter().map(Into::into).collect(); Ok(params.paginate(resp, total)) } @@ -107,7 +103,7 @@ pub async fn get_product( State(state): State, Path(id): Path, ) -> AppResult> { - 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>> { 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 = items.into_iter().map(Into::into).collect(); Ok(params.paginate(resp, total)) } @@ -146,7 +138,7 @@ pub async fn admin_create( ) -> AppResult> { 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> { 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, ) -> AppResult> { auth.ensure_admin()?; - product::delete_product(state.product_repo.as_ref(), &id, &auth).await?; + state.product_service.delete(&id, &auth).await?; Ok(ApiResponse::success(())) } diff --git a/src/handlers/tag.rs b/src/handlers/tag.rs index 09ac6d46..c354c88d 100644 --- a/src/handlers/tag.rs +++ b/src/handlers/tag.rs @@ -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, ) -> AppResult>> { 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, Path(id): Path, ) -> AppResult> { - 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> { 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, ) -> AppResult> { 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> { 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>> { 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> { 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> { 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, ) -> AppResult> { 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; } } diff --git a/src/handlers/wallet.rs b/src/handlers/wallet.rs index 713e6735..bbf1502a 100644 --- a/src/handlers/wallet.rs +++ b/src/handlers/wallet.rs @@ -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)) } diff --git a/src/lib.rs b/src/lib.rs index a1d79105..c6df96e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,19 +112,27 @@ pub struct AppState { pub eventbus: EventBus, pub post_repo: Arc, pub post_service: Arc, + pub page_service: Arc, pub user_repo: Arc, pub category_repo: Arc, + pub category_service: Arc, pub tag_repo: Arc, + pub tag_service: Arc, pub comment_repo: Arc, + pub comment_service: Arc, pub media_repo: Arc, pub refresh_token_repo: Arc, pub wallet_repo: Arc, + pub wallet_service: Arc, pub product_repo: Arc, + pub product_service: Arc, pub order_repo: Arc, + pub order_service: Arc, pub payment_channel_repo: Arc, pub payment_order_repo: Arc, pub payment_tx_repo: Arc, pub payment_refund_repo: Arc, + pub payment_service: Arc, pub search: Arc, pub content_type_registry: Arc, pub aspect_engine: Arc, @@ -206,6 +214,35 @@ pub async fn build_app_state( let aspect_engine = Arc::new(crate::aspects::engine::AspectEngine::new()); + let order_service: Arc = + Arc::new(crate::services::order::OrderServiceImpl::new( + order_repo.clone(), + product_repo.clone(), + aspect_engine.clone(), + Arc::new(pool.clone()), + )); + + let wallet_service: Arc = + Arc::new(crate::services::wallet::WalletServiceImpl::new( + wallet_repo.clone(), + aspect_engine.clone(), + Arc::new(pool.clone()), + )); + + let payment_service: Arc = + 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 = Arc::new( + crate::services::tag::TagServiceImpl::new(tag_repo.clone(), aspect_engine.clone()), + ); + let category_service: Arc = + Arc::new(crate::services::category::CategoryServiceImpl::new( + category_repo.clone(), + aspect_engine.clone(), + )); + let page_service: Arc = Arc::new( + crate::services::page::PageServiceImpl::new(Arc::new(pool.clone()), aspect_engine.clone()), + ); + let comment_service: Arc = + Arc::new(crate::services::comment::CommentServiceImpl::new( + post_repo.clone(), + comment_repo.clone(), + aspect_engine.clone(), + )); + let product_service: Arc = + Arc::new(crate::services::product::ProductServiceImpl::new( + product_repo.clone(), + aspect_engine.clone(), + )); + let options_repo: Arc = 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, diff --git a/src/server.rs b/src/server.rs index 222aaf78..729fa40b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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, - mut shutdown_rx: tokio::sync::watch::Receiver, -) { - 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 { + 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, mut shutdown_rx: tokio::sync::watch::Receiver, ) { - 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 = 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 diff --git a/src/services/auth.rs b/src/services/auth.rs index e398a5fe..b9f80519 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -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, }); diff --git a/src/services/category.rs b/src/services/category.rs index ca9bdd0a..ccecd448 100644 --- a/src/services/category.rs +++ b/src/services/category.rs @@ -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 { - let slug = slug_aspect::generate_slug(&req.name); - let parent_id = if let Some(ref doc_id) = req.parent_id { - if doc_id.parse::().is_ok() { - doc_id.parse::().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; + async fn update( + &self, + auth: &AuthUser, + id: &str, + req: UpdateCategoryRequest, + ) -> AppResult; + async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()>; + async fn get(&self, id: &str, auth: &AuthUser) -> AppResult; + async fn list(&self, auth: &AuthUser) -> AppResult>; + async fn list_paginated( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; +} + +pub struct CategoryServiceImpl { + repo: Arc, + aspect_engine: Arc, +} + +impl CategoryServiceImpl { + pub fn new(repo: Arc, aspect_engine: Arc) -> 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 { + 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 { - 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::().is_ok() { - doc_id.parse::().ok() +#[async_trait] +impl CategoryService for CategoryServiceImpl { + async fn create(&self, auth: &AuthUser, req: CreateCategoryRequest) -> AppResult { + 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::().is_ok() { + doc_id.parse::().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 { + 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 { - 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::().is_ok() { + doc_id.parse::().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> { - 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, i64)> { - category_repo - .find_paginated(auth.tenant_id(), page, page_size) - .await + async fn get(&self, id: &str, auth: &AuthUser) -> AppResult { + 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> { + self.repo.find_all(auth.tenant_id()).await + } + + async fn list_paginated( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, 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 { + 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); } diff --git a/src/services/comment.rs b/src/services/comment.rs index 413c08bb..dc0f68bc 100644 --- a/src/services/comment.rs +++ b/src/services/comment.rs @@ -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, } -#[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 { - 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; + async fn list_paginated( + &self, + post_slug: &str, + page: i64, + page_size: i64, + auth: &AuthUser, + ) -> AppResult<(Vec, 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, + comment_repo: Arc, + aspect_engine: Arc, +} - if parent.post_id != p.id { - return Err(AppError::BadRequest("parent_comment_mismatch".into())); +impl CommentServiceImpl { + pub fn new( + post_repo: Arc, + comment_repo: Arc, + aspect_engine: Arc, + ) -> 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::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::() { - 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 { + 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::() { + 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, 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, 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); } diff --git a/src/services/email_verification.rs b/src/services/email_verification.rs index eb8fc0bb..6a5d20c3 100644 --- a/src/services/email_verification.rs +++ b/src/services/email_verification.rs @@ -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() ); diff --git a/src/services/oauth.rs b/src/services/oauth.rs index 30bffa8b..bc10e341 100644 --- a/src/services/oauth.rs +++ b/src/services/oauth.rs @@ -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 { 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 { 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) } diff --git a/src/services/order.rs b/src/services/order.rs index ba7879e3..51cec4e7 100644 --- a/src/services/order.rs +++ b/src/services/order.rs @@ -1,6 +1,12 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::aspects::engine::AspectEngine; use crate::commands::CreateOrderCmd; use crate::dto::{CreateOrderRequest, ShipOrderRequest}; use crate::errors::app_error::{AppError, AppResult}; +use crate::eventbus::Event; use crate::middleware::auth::AuthUser; use crate::models::order::{Order, OrderStatus}; use crate::models::order_item::OrderItem; @@ -10,396 +16,483 @@ use crate::repositories::{OrderRepository, ProductRepository}; const MAX_ITEMS_PER_ORDER: usize = 100; const MAX_QUANTITY: i64 = 10000; -pub async fn create_order( - pool: &crate::db::Pool, - product_repo: &dyn ProductRepository, - _order_repo: &dyn OrderRepository, - auth: &AuthUser, - user_id: i64, - req: CreateOrderRequest, -) -> AppResult { - if req.items.is_empty() { - return Err(AppError::BadRequest("items_empty".into())); - } - if req.items.len() > MAX_ITEMS_PER_ORDER { - return Err(AppError::BadRequest("too_many_items".into())); +#[async_trait] +pub trait OrderService: Send + Sync { + async fn create( + &self, + auth: &AuthUser, + user_id: i64, + req: CreateOrderRequest, + ) -> AppResult; + async fn cancel(&self, auth: &AuthUser, order_id: &str, user_id: i64) -> AppResult<()>; + async fn mark_paid(&self, auth: &AuthUser, order_id: &str) -> AppResult; + async fn ship(&self, auth: &AuthUser, order_id: &str, req: &ShipOrderRequest) -> AppResult<()>; + async fn confirm_receipt(&self, auth: &AuthUser, order_id: &str, user_id: i64) + -> AppResult<()>; + async fn refund(&self, auth: &AuthUser, order_id: &str) -> AppResult<()>; + async fn admin_cancel(&self, auth: &AuthUser, order_id: &str) -> AppResult<()>; + async fn get(&self, auth: &AuthUser, order_id: &str) -> AppResult<(Order, Vec)>; + async fn list_user( + &self, + auth: &AuthUser, + user_id: i64, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn list_admin( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + status: Option<&str>, + ) -> AppResult<(Vec, i64)>; + async fn update_admin_remark( + &self, + auth: &AuthUser, + order_id: &str, + admin_remark: &str, + ) -> AppResult<()>; + async fn get_stats(&self, auth: &AuthUser) -> AppResult; +} + +pub struct OrderServiceImpl { + repo: Arc, + product_repo: Arc, + aspect_engine: Arc, + pool: Arc, +} + +impl OrderServiceImpl { + pub fn new( + repo: Arc, + product_repo: Arc, + aspect_engine: Arc, + pool: Arc, + ) -> Self { + Self { + repo, + product_repo, + aspect_engine, + pool, + } } - let mut order_items_data: Vec<(i64, i64, crate::models::product::Product)> = Vec::new(); - let mut subtotal: i64 = 0; - - for item in &req.items { - if item.quantity > MAX_QUANTITY { - return Err(AppError::BadRequest("quantity_exceeds_limit".into())); - } - let product = product_repo - .find_by_document_id(&item.product_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("product"))?; - - if product.status != ProductStatus::Active { - return Err(AppError::BadRequest("product_not_active".into())); - } - - let line_total = product - .price - .checked_mul(item.quantity) - .ok_or_else(|| AppError::BadRequest("line_total_overflow".into()))?; - subtotal = subtotal - .checked_add(line_total) - .ok_or_else(|| AppError::BadRequest("subtotal_overflow".into()))?; - order_items_data.push((item.quantity, line_total, product)); + async fn before_create( + &self, + auth: &AuthUser, + req: CreateOrderRequest, + ) -> AppResult<(CreateOrderRequest, crate::aspects::Dispatched)> { + self.aspect_engine.before_create("orders", auth, req).await } - let order_no = format!("ORD-{}", uuid::Uuid::now_v7().to_string().replace('-', "")); + fn after_created(&self, order: &Order) { + self.aspect_engine.emit(Event::OrderCreated(order.clone())); + } - let currency = req.currency.as_deref().unwrap_or("CNY"); - let total_amount = subtotal; + fn after_paid(&self, order: &Order) { + self.aspect_engine.emit(Event::OrderPaid(order.clone())); + } - let order = crate::in_transaction!(pool, tx, { - let order = crate::models::order::tx_insert( - &mut tx, - &CreateOrderCmd { - user_id, - order_no, - subtotal, - discount_amount: 0, - shipping_amount: 0, - total_amount, - currency: currency.into(), - buyer_name: req.buyer_name.clone(), - buyer_phone: req.buyer_phone.clone(), - buyer_email: req.buyer_email.clone(), - shipping_address: req.shipping_address.clone(), - remark: req.remark.clone(), - }, - auth.tenant_id(), - ) - .await?; + fn after_shipped(&self, order: &Order) { + self.aspect_engine.emit(Event::OrderShipped(order.clone())); + } - let mut items = Vec::new(); - for (quantity, line_total, product) in &order_items_data { - items.push(crate::commands::CreateOrderItemCmd { - order_id: order.id, - product_id: Some(product.id), - title: product.title.clone(), - description: product.description.clone(), - unit_price: product.price, - quantity: *quantity, - subtotal: *line_total, - cover_url: product.cover_url.clone(), - attributes: product.attributes.clone(), - }); + fn after_completed(&self, order: &Order) { + self.aspect_engine + .emit(Event::OrderCompleted(order.clone())); + } + + fn after_cancelled(&self, order: &Order) { + self.aspect_engine + .emit(Event::OrderCancelled(order.clone())); + } +} + +#[async_trait] +impl OrderService for OrderServiceImpl { + async fn create( + &self, + auth: &AuthUser, + user_id: i64, + req: CreateOrderRequest, + ) -> AppResult { + let (req, _d) = self.before_create(auth, req).await?; + + if req.items.is_empty() { + return Err(AppError::BadRequest("items_empty".into())); + } + if req.items.len() > MAX_ITEMS_PER_ORDER { + return Err(AppError::BadRequest("too_many_items".into())); } - crate::models::order_item::tx_insert_batch(&mut tx, items, auth.tenant_id()).await?; + let mut order_items_data: Vec<(i64, i64, crate::models::product::Product)> = Vec::new(); + let mut subtotal: i64 = 0; + + for item in &req.items { + if item.quantity > MAX_QUANTITY { + return Err(AppError::BadRequest("quantity_exceeds_limit".into())); + } + let product = self + .product_repo + .find_by_document_id(&item.product_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("product"))?; + + if product.status != ProductStatus::Active { + return Err(AppError::BadRequest("product_not_active".into())); + } + + let line_total = product + .price + .checked_mul(item.quantity) + .ok_or_else(|| AppError::BadRequest("line_total_overflow".into()))?; + subtotal = subtotal + .checked_add(line_total) + .ok_or_else(|| AppError::BadRequest("subtotal_overflow".into()))?; + order_items_data.push((item.quantity, line_total, product)); + } + + let order_no = format!("ORD-{}", uuid::Uuid::now_v7().to_string().replace('-', "")); + + let currency = req.currency.as_deref().unwrap_or("CNY"); + let total_amount = subtotal; + + let order = crate::in_transaction!(&self.pool, tx, { + let order = crate::models::order::tx_insert( + &mut tx, + &CreateOrderCmd { + user_id, + order_no, + subtotal, + discount_amount: 0, + shipping_amount: 0, + total_amount, + currency: currency.into(), + buyer_name: req.buyer_name.clone(), + buyer_phone: req.buyer_phone.clone(), + buyer_email: req.buyer_email.clone(), + shipping_address: req.shipping_address.clone(), + remark: req.remark.clone(), + }, + auth.tenant_id(), + ) + .await?; + + let mut items = Vec::new(); + for (quantity, line_total, product) in &order_items_data { + items.push(crate::commands::CreateOrderItemCmd { + order_id: order.id, + product_id: Some(product.id), + title: product.title.clone(), + description: product.description.clone(), + unit_price: product.price, + quantity: *quantity, + subtotal: *line_total, + cover_url: product.cover_url.clone(), + attributes: product.attributes.clone(), + }); + } + crate::models::order_item::tx_insert_batch(&mut tx, items, auth.tenant_id()).await?; + + Ok(order) + })?; + + self.after_created(&order); Ok(order) - })?; - - Ok(order) -} - -pub async fn cancel_order( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, - user_id: i64, -) -> AppResult<()> { - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.user_id != user_id { - return Err(AppError::Forbidden); - } - if order.status != OrderStatus::Pending { - return Err(AppError::BadRequest("only_pending_can_cancel".into())); } - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let rows = crate::models::order::tx_update_status_cas( - &mut tx, - order.id, - OrderStatus::Cancelled, - Some("cancelled_at"), - OrderStatus::Pending, - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result -} + async fn cancel(&self, auth: &AuthUser, order_id: &str, user_id: i64) -> AppResult<()> { + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; -pub async fn mark_paid( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, -) -> AppResult { - auth.ensure_admin()?; - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.status != OrderStatus::Pending { - return Err(AppError::BadRequest("only_pending_can_pay".into())); - } - - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let rows = crate::models::order::tx_update_status_cas( - &mut tx, - order.id, - OrderStatus::Paid, - Some("paid_at"), - OrderStatus::Pending, - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result?; - - order_repo - .find_by_id(order.id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order")) -} - -pub async fn ship_order( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, - req: &ShipOrderRequest, -) -> AppResult<()> { - auth.ensure_admin()?; - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.status != OrderStatus::Paid { - return Err(AppError::BadRequest("only_paid_can_ship".into())); - } - - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let rows = crate::models::order::tx_update_shipped( - &mut tx, - order.id, - req.tracking_no.as_deref(), - req.carrier.as_deref(), - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result -} - -pub async fn confirm_receipt( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, - user_id: i64, -) -> AppResult<()> { - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.user_id != user_id { - return Err(AppError::Forbidden); - } - if order.status != OrderStatus::Shipped { - return Err(AppError::BadRequest("only_shipped_can_confirm".into())); - } - - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let rows = crate::models::order::tx_update_status_cas( - &mut tx, - order.id, - OrderStatus::Completed, - Some("completed_at"), - OrderStatus::Shipped, - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result -} - -pub async fn refund_order( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, -) -> AppResult<()> { - auth.ensure_admin()?; - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.status != OrderStatus::Paid && order.status != OrderStatus::Shipped { - return Err(AppError::BadRequest( - "only_paid_or_shipped_can_refund".into(), - )); - } - - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let expected = order.status; - let rows = crate::models::order::tx_update_status_cas( - &mut tx, - order.id, - OrderStatus::Refunding, - Some("refunding_at"), - expected, - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result -} - -pub async fn admin_cancel( - pool: &crate::db::Pool, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, -) -> AppResult<()> { - auth.ensure_admin()?; - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - - if order.status != OrderStatus::Pending && order.status != OrderStatus::Paid { - return Err(AppError::BadRequest( - "only_pending_or_paid_can_admin_cancel".into(), - )); - } - - let result: Result<(), AppError> = async { - crate::in_transaction!(pool, tx, { - let expected = order.status; - let rows = crate::models::order::tx_update_status_cas( - &mut tx, - order.id, - OrderStatus::Cancelled, - Some("cancelled_at"), - expected, - ) - .await?; - if rows == 0 { - return Err(AppError::BadRequest("concurrent_status_change".into())); - } - Ok(()) - }) - } - .await; - result -} - -pub async fn get_order( - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, -) -> AppResult<(Order, Vec)> { - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - if auth.role() != "admin" { - let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?; - if order.user_id != user_int_id { + if order.user_id != user_id { return Err(AppError::Forbidden); } + if order.status != OrderStatus::Pending { + return Err(AppError::BadRequest("only_pending_can_cancel".into())); + } + + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_status_cas( + &mut tx, + order.id, + OrderStatus::Cancelled, + Some("cancelled_at"), + OrderStatus::Pending, + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result?; + + self.after_cancelled(&order); + Ok(()) } - let items = order_repo - .find_items_by_order_id(order.id, auth.tenant_id()) - .await?; - Ok((order, items)) -} -pub async fn list_user_orders( - order_repo: &dyn OrderRepository, - auth: &AuthUser, - user_id: i64, - page: i64, - page_size: i64, -) -> AppResult<(Vec, i64)> { - order_repo - .find_by_user_paginated(user_id, auth.tenant_id(), page, page_size) - .await -} + async fn mark_paid(&self, auth: &AuthUser, order_id: &str) -> AppResult { + auth.ensure_admin()?; + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; -pub async fn list_admin_orders( - order_repo: &dyn OrderRepository, - auth: &AuthUser, - page: i64, - page_size: i64, - status: Option<&str>, -) -> AppResult<(Vec, i64)> { - auth.ensure_admin()?; - order_repo - .find_all_admin_paginated(auth.tenant_id(), page, page_size, status) - .await -} + if order.status != OrderStatus::Pending { + return Err(AppError::BadRequest("only_pending_can_pay".into())); + } -pub async fn update_admin_remark( - order_repo: &dyn OrderRepository, - auth: &AuthUser, - order_id: &str, - admin_remark: &str, -) -> AppResult<()> { - auth.ensure_admin()?; - let order = order_repo - .find_by_document_id(order_id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("order"))?; - order_repo - .update_admin_remark(order.id, admin_remark, auth.tenant_id()) - .await -} + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_status_cas( + &mut tx, + order.id, + OrderStatus::Paid, + Some("paid_at"), + OrderStatus::Pending, + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result?; -pub async fn get_stats( - pool: &crate::db::Pool, - auth: &AuthUser, -) -> AppResult { - auth.ensure_admin()?; - crate::models::order::get_stats_query(pool, auth.tenant_id()).await + let paid = self + .repo + .find_by_id(order.id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + + self.after_paid(&paid); + Ok(paid) + } + + async fn ship(&self, auth: &AuthUser, order_id: &str, req: &ShipOrderRequest) -> AppResult<()> { + auth.ensure_admin()?; + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + + if order.status != OrderStatus::Paid { + return Err(AppError::BadRequest("only_paid_can_ship".into())); + } + + let order_id = order.id; + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_shipped( + &mut tx, + order_id, + req.tracking_no.as_deref(), + req.carrier.as_deref(), + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result?; + + self.after_shipped(&order); + Ok(()) + } + + async fn confirm_receipt( + &self, + auth: &AuthUser, + order_id: &str, + user_id: i64, + ) -> AppResult<()> { + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + + if order.user_id != user_id { + return Err(AppError::Forbidden); + } + if order.status != OrderStatus::Shipped { + return Err(AppError::BadRequest("only_shipped_can_confirm".into())); + } + + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_status_cas( + &mut tx, + order.id, + OrderStatus::Completed, + Some("completed_at"), + OrderStatus::Shipped, + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result?; + + self.after_completed(&order); + Ok(()) + } + + async fn refund(&self, auth: &AuthUser, order_id: &str) -> AppResult<()> { + auth.ensure_admin()?; + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + + if order.status != OrderStatus::Paid && order.status != OrderStatus::Shipped { + return Err(AppError::BadRequest( + "only_paid_or_shipped_can_refund".into(), + )); + } + + let expected = order.status; + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_status_cas( + &mut tx, + order.id, + OrderStatus::Refunding, + Some("refunding_at"), + expected, + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result + } + + async fn admin_cancel(&self, auth: &AuthUser, order_id: &str) -> AppResult<()> { + auth.ensure_admin()?; + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + + if order.status != OrderStatus::Pending && order.status != OrderStatus::Paid { + return Err(AppError::BadRequest( + "only_pending_or_paid_can_admin_cancel".into(), + )); + } + + let expected = order.status; + let result: Result<(), AppError> = async { + crate::in_transaction!(&self.pool, tx, { + let rows = crate::models::order::tx_update_status_cas( + &mut tx, + order.id, + OrderStatus::Cancelled, + Some("cancelled_at"), + expected, + ) + .await?; + if rows == 0 { + return Err(AppError::BadRequest("concurrent_status_change".into())); + } + Ok(()) + }) + } + .await; + result?; + self.after_cancelled(&order); + Ok(()) + } + + async fn get(&self, auth: &AuthUser, order_id: &str) -> AppResult<(Order, Vec)> { + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + if auth.role() != "admin" { + let user_int_id = auth.user_int_id().ok_or(AppError::Unauthorized)?; + if order.user_id != user_int_id { + return Err(AppError::Forbidden); + } + } + let items = self + .repo + .find_items_by_order_id(order.id, auth.tenant_id()) + .await?; + Ok((order, items)) + } + + async fn list_user( + &self, + auth: &AuthUser, + user_id: i64, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)> { + self.repo + .find_by_user_paginated(user_id, auth.tenant_id(), page, page_size) + .await + } + + async fn list_admin( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + status: Option<&str>, + ) -> AppResult<(Vec, i64)> { + auth.ensure_admin()?; + self.repo + .find_all_admin_paginated(auth.tenant_id(), page, page_size, status) + .await + } + + async fn update_admin_remark( + &self, + auth: &AuthUser, + order_id: &str, + admin_remark: &str, + ) -> AppResult<()> { + auth.ensure_admin()?; + let order = self + .repo + .find_by_document_id(order_id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("order"))?; + self.repo + .update_admin_remark(order.id, admin_remark, auth.tenant_id()) + .await + } + + async fn get_stats(&self, auth: &AuthUser) -> AppResult { + auth.ensure_admin()?; + crate::models::order::get_stats_query(&self.pool, auth.tenant_id()).await + } } #[cfg(test)] @@ -417,6 +510,15 @@ mod tests { pool } + fn make_service(pool: crate::db::Pool) -> Arc { + Arc::new(OrderServiceImpl::new( + Arc::new(SqlxOrderRepository::new(pool.clone())), + Arc::new(SqlxProductRepository::new(pool.clone())), + Arc::new(AspectEngine::new()), + Arc::new(pool), + )) + } + fn auth(tid: Option<&str>) -> AuthUser { AuthUser::from_parts( Some("u1".to_string()), @@ -499,36 +601,47 @@ mod tests { .unwrap() } + fn make_create_req(prod_doc_id: &str, quantity: i64) -> CreateOrderRequest { + CreateOrderRequest { + items: vec![CreateOrderItemRequest { + product_id: prod_doc_id.to_string(), + quantity, + }], + currency: None, + buyer_name: None, + buyer_phone: None, + buyer_email: None, + shipping_address: None, + remark: None, + } + } + + async fn seed_order( + svc: &dyn OrderService, + pool: &crate::db::Pool, + auth: &AuthUser, + ) -> (i64, Order) { + let uid = seed_user(pool).await; + let prod = seed_active_product(pool, "Widget", 1000).await; + let order = svc + .create(auth, uid, make_create_req(&prod.document_id, 1)) + .await + .unwrap(); + (uid, order) + } + #[tokio::test] async fn create_order_basic() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; let prod = seed_active_product(&pool, "Widget", 1000).await; - let order = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 2, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); + let order = svc + .create(&a, uid, make_create_req(&prod.document_id, 2)) + .await + .unwrap(); assert_eq!(order.user_id, uid); assert_eq!(order.subtotal, 2000); @@ -536,6 +649,7 @@ mod tests { assert_eq!(order.status, OrderStatus::Pending); assert!(order.order_no.starts_with("ORD-")); + let order_repo = SqlxOrderRepository::new(pool); let items = order_repo .find_items_by_order_id(order.id, None) .await @@ -550,45 +664,43 @@ mod tests { #[tokio::test] async fn create_order_multiple_items() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; let p1 = seed_active_product(&pool, "Item1", 100).await; let p2 = seed_active_product(&pool, "Item2", 200).await; - let order = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![ - CreateOrderItemRequest { - product_id: p1.document_id.clone(), - quantity: 3, - }, - CreateOrderItemRequest { - product_id: p2.document_id.clone(), - quantity: 1, - }, - ], - currency: Some("USD".into()), - buyer_name: Some("John".into()), - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); + let order = svc + .create( + &a, + uid, + CreateOrderRequest { + items: vec![ + CreateOrderItemRequest { + product_id: p1.document_id.clone(), + quantity: 3, + }, + CreateOrderItemRequest { + product_id: p2.document_id.clone(), + quantity: 1, + }, + ], + currency: Some("USD".into()), + buyer_name: Some("John".into()), + buyer_phone: None, + buyer_email: None, + shipping_address: None, + remark: None, + }, + ) + .await + .unwrap(); assert_eq!(order.subtotal, 500); assert_eq!(order.total_amount, 500); assert_eq!(order.currency, "USD"); assert_eq!(order.buyer_name.unwrap(), "John"); + let order_repo = SqlxOrderRepository::new(pool); let items = order_repo .find_items_by_order_id(order.id, None) .await @@ -599,69 +711,62 @@ mod tests { #[tokio::test] async fn create_order_empty_items_error() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; - let err = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap_err(); + let err = svc + .create( + &a, + uid, + CreateOrderRequest { + items: vec![], + currency: None, + buyer_name: None, + buyer_phone: None, + buyer_email: None, + shipping_address: None, + remark: None, + }, + ) + .await + .unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "items_empty")); } #[tokio::test] async fn create_order_product_not_found() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; - let err = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: "nonexistent".into(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap_err(); + let err = svc + .create( + &a, + uid, + CreateOrderRequest { + items: vec![CreateOrderItemRequest { + product_id: "nonexistent".into(), + quantity: 1, + }], + currency: None, + buyer_name: None, + buyer_phone: None, + buyer_email: None, + shipping_address: None, + remark: None, + }, + ) + .await + .unwrap_err(); assert!(matches!(err, AppError::NotFound(_))); } #[tokio::test] async fn create_order_product_not_active() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; @@ -697,73 +802,22 @@ mod tests { .await .unwrap(); - let err = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: draft_product.document_id.clone(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap_err(); + let err = svc + .create(&a, uid, make_create_req(&draft_product.document_id, 1)) + .await + .unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "product_not_active")); } - async fn seed_order_with_product( - pool: &crate::db::Pool, - product_repo: &dyn ProductRepository, - order_repo: &dyn OrderRepository, - auth: &AuthUser, - ) -> (i64, Order) { - let uid = seed_user(pool).await; - let prod = seed_active_product(pool, "Widget", 1000).await; - let order = create_order( - pool, - product_repo, - order_repo, - auth, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); - (uid, order) - } - #[tokio::test] async fn cancel_order_success() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (uid, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (uid, order) = seed_order(svc.as_ref(), &pool, &a).await; - super::cancel_order(&pool, &order_repo, &a, &order.document_id, uid) - .await - .unwrap(); + svc.cancel(&a, &order.document_id, uid).await.unwrap(); + let order_repo = SqlxOrderRepository::new(pool); let found = order_repo .find_by_id(order.id, None) .await @@ -776,47 +830,39 @@ mod tests { #[tokio::test] async fn cancel_order_wrong_user() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - let err = super::cancel_order(&pool, &order_repo, &a, &order.document_id, 999) - .await - .unwrap_err(); + let err = svc.cancel(&a, &order.document_id, 999).await.unwrap_err(); assert!(matches!(err, AppError::Forbidden)); } #[tokio::test] async fn cancel_order_wrong_status() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (uid, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (uid, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await .unwrap(); - let err = super::cancel_order(&pool, &order_repo, &a, &order.document_id, uid) - .await - .unwrap_err(); + let err = svc.cancel(&a, &order.document_id, uid).await.unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "only_pending_can_cancel")); } #[tokio::test] async fn mark_paid_success() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - let paid = super::mark_paid(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap(); + let paid = svc.mark_paid(&a, &order.document_id).await.unwrap(); assert_eq!(paid.status, OrderStatus::Paid); assert!(paid.paid_at.is_some()); } @@ -824,37 +870,33 @@ mod tests { #[tokio::test] async fn mark_paid_wrong_status() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "cancelled", Some("cancelled_at"), None) .await .unwrap(); - let err = super::mark_paid(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap_err(); + let err = svc.mark_paid(&a, &order.document_id).await.unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "only_pending_can_pay")); } #[tokio::test] async fn ship_order_success() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool.clone()); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await .unwrap(); - super::ship_order( - &pool, - &order_repo, + svc.ship( &a, &order.document_id, &ShipOrderRequest { @@ -878,34 +920,32 @@ mod tests { #[tokio::test] async fn ship_order_wrong_status() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - let err = super::ship_order( - &pool, - &order_repo, - &a, - &order.document_id, - &ShipOrderRequest { - tracking_no: None, - carrier: None, - }, - ) - .await - .unwrap_err(); + let err = svc + .ship( + &a, + &order.document_id, + &ShipOrderRequest { + tracking_no: None, + carrier: None, + }, + ) + .await + .unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "only_paid_can_ship")); } #[tokio::test] async fn confirm_receipt_success() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (uid, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (uid, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await @@ -915,7 +955,7 @@ mod tests { .await .unwrap(); - super::confirm_receipt(&pool, &order_repo, &a, &order.document_id, uid) + svc.confirm_receipt(&a, &order.document_id, uid) .await .unwrap(); let found = order_repo @@ -930,11 +970,11 @@ mod tests { #[tokio::test] async fn confirm_receipt_wrong_user() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await @@ -944,7 +984,8 @@ mod tests { .await .unwrap(); - let err = super::confirm_receipt(&pool, &order_repo, &a, &order.document_id, 999) + let err = svc + .confirm_receipt(&a, &order.document_id, 999) .await .unwrap_err(); assert!(matches!(err, AppError::Forbidden)); @@ -953,12 +994,12 @@ mod tests { #[tokio::test] async fn confirm_receipt_wrong_status() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (uid, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (uid, order) = seed_order(svc.as_ref(), &pool, &a).await; - let err = super::confirm_receipt(&pool, &order_repo, &a, &order.document_id, uid) + let err = svc + .confirm_receipt(&a, &order.document_id, uid) .await .unwrap_err(); assert!(matches!(err, AppError::BadRequest(ref s) if s == "only_shipped_can_confirm")); @@ -967,18 +1008,16 @@ mod tests { #[tokio::test] async fn refund_order_from_paid() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await .unwrap(); - super::refund_order(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap(); + svc.refund(&a, &order.document_id).await.unwrap(); let found = order_repo .find_by_id(order.id, None) @@ -992,11 +1031,11 @@ mod tests { #[tokio::test] async fn refund_order_from_shipped() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(order.id, "paid", Some("paid_at"), None) .await @@ -1005,9 +1044,7 @@ mod tests { .update_shipped(order.id, Some("TRK"), None, None) .await .unwrap(); - super::refund_order(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap(); + svc.refund(&a, &order.document_id).await.unwrap(); let found = order_repo .find_by_id(order.id, None) @@ -1020,14 +1057,11 @@ mod tests { #[tokio::test] async fn refund_order_wrong_status() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - let err = super::refund_order(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap_err(); + let err = svc.refund(&a, &order.document_id).await.unwrap_err(); assert!( matches!(err, AppError::BadRequest(ref s) if s == "only_paid_or_shipped_can_refund") ); @@ -1036,14 +1070,11 @@ mod tests { #[tokio::test] async fn get_order_with_items() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - let (found_order, items) = super::get_order(&order_repo, &a, &order.document_id) - .await - .unwrap(); + let (found_order, items) = svc.get(&a, &order.document_id).await.unwrap(); assert_eq!(found_order.id, order.id); assert_eq!(items.len(), 1); assert_eq!(items[0].title, "Widget"); @@ -1052,51 +1083,26 @@ mod tests { #[tokio::test] async fn get_order_not_found() { let pool = setup_pool().await; - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool); let a = auth(None); - assert!( - super::get_order(&order_repo, &a, "nonexistent") - .await - .is_err() - ); + assert!(svc.get(&a, "nonexistent").await.is_err()); } #[tokio::test] async fn list_user_orders() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; let prod = seed_active_product(&pool, "Widget", 1000).await; for _ in 0..3 { - super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); + svc.create(&a, uid, make_create_req(&prod.document_id, 1)) + .await + .unwrap(); } - let (orders, total) = super::list_user_orders(&order_repo, &a, uid, 1, 10) - .await - .unwrap(); + let (orders, total) = svc.list_user(&a, uid, 1, 10).await.unwrap(); assert_eq!(total, 3); assert_eq!(orders.len(), 3); } @@ -1104,37 +1110,16 @@ mod tests { #[tokio::test] async fn list_admin_orders() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; let prod = seed_active_product(&pool, "Widget", 1000).await; - super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); - - let (orders, total) = super::list_admin_orders(&order_repo, &a, 1, 10, None) + svc.create(&a, uid, make_create_req(&prod.document_id, 1)) .await .unwrap(); + + let (orders, total) = svc.list_admin(&a, 1, 10, None).await.unwrap(); assert_eq!(total, 1); assert_eq!(orders.len(), 1); } @@ -1142,14 +1127,14 @@ mod tests { #[tokio::test] async fn update_admin_remark_success() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (_, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (_, order) = seed_order(svc.as_ref(), &pool, &a).await; - super::update_admin_remark(&order_repo, &a, &order.document_id, "verified") + svc.update_admin_remark(&a, &order.document_id, "verified") .await .unwrap(); + let order_repo = SqlxOrderRepository::new(pool); let found = order_repo .find_by_id(order.id, None) .await @@ -1161,56 +1146,22 @@ mod tests { #[tokio::test] async fn get_stats() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); let uid = seed_user(&pool).await; let prod = seed_active_product(&pool, "Widget", 1000).await; - let o1 = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 1, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); + let o1 = svc + .create(&a, uid, make_create_req(&prod.document_id, 1)) + .await + .unwrap(); - let o2 = super::create_order( - &pool, - &product_repo, - &order_repo, - &a, - uid, - CreateOrderRequest { - items: vec![CreateOrderItemRequest { - product_id: prod.document_id.clone(), - quantity: 2, - }], - currency: None, - buyer_name: None, - buyer_phone: None, - buyer_email: None, - shipping_address: None, - remark: None, - }, - ) - .await - .unwrap(); + let _o2 = svc + .create(&a, uid, make_create_req(&prod.document_id, 2)) + .await + .unwrap(); + let order_repo = SqlxOrderRepository::new(pool); order_repo .update_status(o1.id, "paid", Some("paid_at"), None) .await @@ -1224,7 +1175,7 @@ mod tests { .await .unwrap(); - let stats = super::get_stats(&pool, &a).await.unwrap(); + let stats = svc.get_stats(&a).await.unwrap(); assert_eq!(stats.total_orders, 2); assert_eq!(stats.pending_orders, 1); assert_eq!(stats.completed_orders, 1); @@ -1234,25 +1185,18 @@ mod tests { #[tokio::test] async fn full_lifecycle_pending_to_completed() { let pool = setup_pool().await; - let product_repo = SqlxProductRepository::new(pool.clone()); - let order_repo = SqlxOrderRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let (uid, order) = seed_order_with_product(&pool, &product_repo, &order_repo, &a).await; + let (uid, order) = seed_order(svc.as_ref(), &pool, &a).await; - let (o, items) = super::get_order(&order_repo, &a, &order.document_id) - .await - .unwrap(); + let (o, items) = svc.get(&a, &order.document_id).await.unwrap(); assert_eq!(o.status, OrderStatus::Pending); assert_eq!(items.len(), 1); - let paid = super::mark_paid(&pool, &order_repo, &a, &order.document_id) - .await - .unwrap(); + let paid = svc.mark_paid(&a, &order.document_id).await.unwrap(); assert_eq!(paid.status, OrderStatus::Paid); - super::ship_order( - &pool, - &order_repo, + svc.ship( &a, &order.document_id, &ShipOrderRequest { @@ -1263,13 +1207,11 @@ mod tests { .await .unwrap(); - super::confirm_receipt(&pool, &order_repo, &a, &order.document_id, uid) + svc.confirm_receipt(&a, &order.document_id, uid) .await .unwrap(); - let (final_order, _) = super::get_order(&order_repo, &a, &order.document_id) - .await - .unwrap(); + let (final_order, _) = svc.get(&a, &order.document_id).await.unwrap(); assert_eq!(final_order.status, OrderStatus::Completed); assert!(final_order.paid_at.is_some()); assert!(final_order.completed_at.is_some()); diff --git a/src/services/page.rs b/src/services/page.rs index 920342bd..4840d0d6 100644 --- a/src/services/page.rs +++ b/src/services/page.rs @@ -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> { - 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, i64)>; + async fn get_by_slug(&self, slug: &str, auth: &AuthUser) -> AppResult; + async fn get_by_id(&self, id: &str, auth: &AuthUser) -> AppResult; + async fn list_all( + &self, + page_num: i64, + page_size: i64, + status: Option, + auth: &AuthUser, + ) -> AppResult<(Vec, i64)>; + async fn create_page(&self, auth: &AuthUser, cmd: CreatePageCmd) -> AppResult; + async fn update_page( + &self, + auth: &AuthUser, + document_id: &str, + cmd: UpdatePageCmd, + ) -> AppResult; + async fn delete_page(&self, id: &str, auth: &AuthUser) -> AppResult<()>; + async fn update_status(&self, id: &str, status: PageStatus, auth: &AuthUser) + -> AppResult; + async fn reorder(&self, items: Vec<(String, i64)>, auth: &AuthUser) -> AppResult<()>; + async fn sitemap(&self, auth: &AuthUser) -> AppResult)>>; } -pub async fn list_published( - pool: &crate::db::Pool, - page_num: i64, - page_size: i64, - auth: &AuthUser, -) -> AppResult<(Vec, i64)> { - page::list_published(pool, page_num, page_size, auth.tenant_id()).await +pub struct PageServiceImpl { + pool: Arc, + aspect_engine: Arc, } -pub async fn get_by_slug( - pool: &crate::db::Pool, - slug: &str, - auth: &AuthUser, -) -> AppResult { - 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::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, - auth: &AuthUser, -) -> AppResult<(Vec, 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 { - if let Some(ref blocks) = cmd.blocks { - validate_blocks_json(blocks)?; +impl PageServiceImpl { + pub fn new(pool: Arc, aspect_engine: Arc) -> 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 { - if let Some(ref blocks) = cmd.blocks { - validate_blocks_json(blocks)?; + fn validate_blocks_json(blocks: &str) -> AppResult> { + 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 { + 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, 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 { - 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::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::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, + auth: &AuthUser, + ) -> AppResult<(Vec, 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 { + 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 { + 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)>> { - 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 { + 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)>> { + 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()); } } diff --git a/src/services/password_reset.rs b/src/services/password_reset.rs index 2844b844..43c77ef6 100644 --- a/src/services/password_reset.rs +++ b/src/services/password_reset.rs @@ -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!( diff --git a/src/services/payment.rs b/src/services/payment.rs index 5aaf9c37..cf1098e3 100644 --- a/src/services/payment.rs +++ b/src/services/payment.rs @@ -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; + async fn update_channel( + &self, + auth: &AuthUser, + id: &str, + req: UpdatePaymentChannelRequest, + ) -> AppResult; + async fn delete_channel(&self, auth: &AuthUser, id: &str) -> AppResult<()>; + async fn get_channel(&self, auth: &AuthUser, id: &str) -> AppResult; + async fn list_channels(&self, auth: &AuthUser) -> AppResult>; + async fn list_available_channels( + &self, + auth: &AuthUser, + order_id: &str, + country: Option<&str>, + language: Option<&str>, + ) -> AppResult; + 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)>; + 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; + async fn list_user_payment_orders( + &self, + auth: &AuthUser, + user_id: i64, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn handle_callback( + &self, + channel_doc_id: &str, + headers: &axum::http::HeaderMap, + body: &[u8], + ) -> AppResult; + async fn refund_payment_order( + &self, + auth: &AuthUser, + id: &str, + req: CreateRefundRequest, + ) -> AppResult; + async fn list_admin_payment_orders( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + status: Option<&str>, + ) -> AppResult<(Vec, i64)>; + async fn list_admin_transactions( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn list_admin_refunds( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn list_admin_channels( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn list_order_transactions( + &self, + auth: &AuthUser, + user_id: i64, + order_id: &str, + ) -> AppResult>; + async fn list_order_refunds( + &self, + auth: &AuthUser, + user_id: i64, + order_id: &str, + ) -> AppResult>; +} + +pub struct PaymentServiceImpl { + channel_repo: Arc, + order_repo: Arc, + tx_repo: Arc, + refund_repo: Arc, + shop_order_repo: Arc, + product_repo: Arc, + wallet_repo: Arc, + config: Arc, + aspect_engine: Arc, + pool: Arc, +} + +impl PaymentServiceImpl { + #[allow(clippy::too_many_arguments)] + pub fn new( + channel_repo: Arc, + order_repo: Arc, + tx_repo: Arc, + refund_repo: Arc, + shop_order_repo: Arc, + product_repo: Arc, + wallet_repo: Arc, + config: Arc, + aspect_engine: Arc, + pool: Arc, + ) -> 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 { + 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 { + 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 { + get_channel(self.channel_repo.as_ref(), auth, id).await + } + + async fn list_channels(&self, auth: &AuthUser) -> AppResult> { + 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 { + 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)> { + 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 { + 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, 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 { + 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 { + 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, 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, 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, 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, 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> { + 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> { + 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 { 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)] diff --git a/src/services/post.rs b/src/services/post.rs index fa89cb2f..19a91d82 100644 --- a/src/services/post.rs +++ b/src/services/post.rs @@ -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::policy::PostPolicy::can_delete(auth, existing)?; self.aspect_engine - .before_delete(self.repo.pool(), "posts", auth, existing) + .before_delete("posts", auth, existing) .await } diff --git a/src/services/product.rs b/src/services/product.rs index 29e9003a..52a4a131 100644 --- a/src/services/product.rs +++ b/src/services/product.rs @@ -1,190 +1,287 @@ +//! Product service. + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::aspects::engine::AspectEngine; use crate::aspects::slug_aspect; use crate::commands::{CreateProductCmd, UpdateProductCmd}; use crate::dto::{CreateProductRequest, UpdateProductRequest}; use crate::errors::app_error::{AppError, AppResult}; +use crate::event::Event; use crate::middleware::auth::AuthUser; use crate::models::product::Product; use crate::repositories::ProductRepository; -pub async fn create_product( - product_repo: &dyn ProductRepository, - auth: &AuthUser, - req: CreateProductRequest, -) -> AppResult { - let product_type = req.product_type.as_deref().unwrap_or("custom"); - let fulfillment_type = req.fulfillment_type.as_deref().unwrap_or("digital"); - let currency = req.currency.as_deref().unwrap_or("CNY"); - let generated_slug = slug_aspect::generate_slug(&req.title); - let slug = req.slug.as_deref().or(Some(generated_slug.as_str())); - product_repo - .insert( - &CreateProductCmd { - category_id: None, - title: req.title, - description: req.description, - cover_url: req.cover_url, - product_type: product_type.to_string(), - fulfillment_type: fulfillment_type.to_string(), - delivery_hook: req.delivery_hook, - weight: req.weight, - price: req.price, - currency: currency.to_string(), - attributes: req.attributes, - sort_order: req.sort_order.unwrap_or(0), - slug: slug.map(|s| s.to_string()), - content: req.content, - image_ids: req.image_ids, - original_price: req.original_price, - specs: req.specs, - unit: req.unit.as_deref().unwrap_or("piece").to_string(), - min_purchase: req.min_purchase.unwrap_or(1), - max_purchase: req.max_purchase, - virtual_sales: req.virtual_sales.unwrap_or(0), - meta_title: req.meta_title, - meta_description: req.meta_description, - }, - auth.tenant_id(), - ) - .await +/// Product business logic trait. +#[async_trait] +pub trait ProductService: Send + Sync { + async fn create(&self, auth: &AuthUser, req: CreateProductRequest) -> AppResult; + async fn update( + &self, + auth: &AuthUser, + id: &str, + req: UpdateProductRequest, + ) -> AppResult; + async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()>; + async fn get(&self, id: &str, auth: &AuthUser) -> AppResult; + async fn list_active( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)>; + async fn list_admin( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + status: Option<&str>, + ) -> AppResult<(Vec, i64)>; } -pub async fn update_product( - product_repo: &dyn ProductRepository, - auth: &AuthUser, - id: &str, - req: UpdateProductRequest, -) -> AppResult { - let existing = product_repo - .find_by_document_id(id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("product"))?; +pub struct ProductServiceImpl { + repo: Arc, + aspect_engine: Arc, +} - let title = req.title.as_deref().unwrap_or(&existing.title); - let product_type = req - .product_type - .as_deref() - .unwrap_or(existing.product_type.as_str()); - let fulfillment_type = req - .fulfillment_type - .as_deref() - .unwrap_or(existing.fulfillment_type.as_str()); - let currency = req.currency.as_deref().unwrap_or(&existing.currency); - let status = req.status.as_deref().unwrap_or(existing.status.as_str()); - let price = req.price.unwrap_or(existing.price); - let sort_order = req.sort_order.unwrap_or(existing.sort_order); - let slug = req.slug.as_deref().or(existing.slug.as_deref()); - let unit = req.unit.as_deref().unwrap_or(&existing.unit); - let min_purchase = req.min_purchase.unwrap_or(existing.min_purchase); - let total_sales = existing.total_sales; - let virtual_sales = req.virtual_sales.unwrap_or(existing.virtual_sales); - - let existing_published_at_str = existing - .published_at - .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()); - let generated_published_at; - let published_at: Option<&str> = if status == "active" - && existing.status.as_str() != "active" - && existing.published_at.is_none() - { - generated_published_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - Some(generated_published_at.as_str()) - } else { - existing_published_at_str.as_deref() - }; - - let updated = product_repo - .update( - &UpdateProductCmd { - id: existing.id, - category_id: None, - title: title.to_string(), - description: req.description.or(existing.description), - cover_url: req.cover_url.or(existing.cover_url), - product_type: product_type.to_string(), - fulfillment_type: fulfillment_type.to_string(), - delivery_hook: req.delivery_hook.or(existing.delivery_hook), - weight: req.weight.or(existing.weight), - price, - currency: currency.to_string(), - status: status.to_string(), - attributes: req.attributes.or(existing.attributes), - sort_order, - slug: slug.map(|s| s.to_string()), - content: req.content.or(existing.content), - image_ids: req.image_ids.or(existing.image_ids), - original_price: req.original_price.or(existing.original_price), - specs: req.specs.or(existing.specs), - unit: unit.to_string(), - min_purchase, - max_purchase: req.max_purchase.or(existing.max_purchase), - total_sales, - virtual_sales, - meta_title: req.meta_title.or(existing.meta_title), - meta_description: req.meta_description.or(existing.meta_description), - published_at: published_at.map(|s| s.to_string()), - version: req.version, - }, - auth.tenant_id(), - ) - .await?; - - if !updated { - return Err(AppError::Conflict("version_conflict".into())); +impl ProductServiceImpl { + pub fn new(repo: Arc, aspect_engine: Arc) -> Self { + Self { + repo, + aspect_engine, + } } - product_repo - .find_by_id(existing.id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("product")) + async fn before_create( + &self, + auth: &AuthUser, + req: CreateProductRequest, + ) -> AppResult<(CreateProductRequest, crate::aspects::Dispatched)> { + self.aspect_engine + .before_create("products", auth, req) + .await + } + + async fn before_update( + &self, + auth: &AuthUser, + existing: &Product, + req: UpdateProductRequest, + ) -> AppResult<(UpdateProductRequest, crate::aspects::Dispatched)> { + self.aspect_engine + .before_update("products", auth, existing, req) + .await + } + + async fn before_delete( + &self, + auth: &AuthUser, + existing: &Product, + ) -> AppResult { + self.aspect_engine + .before_delete("products", auth, existing) + .await + } + + fn after_created(&self, p: &Product) { + self.aspect_engine.emit(Event::ProductCreated(p.clone())); + } + + fn after_updated(&self, p: &Product) { + self.aspect_engine.emit(Event::ProductUpdated(p.clone())); + } + + fn after_deleted(&self, p: &Product) { + self.aspect_engine.emit(Event::ProductDeleted(p.clone())); + } } -pub async fn delete_product( - product_repo: &dyn ProductRepository, - id: &str, - auth: &AuthUser, -) -> AppResult<()> { - let existing = product_repo - .find_by_document_id(id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("product"))?; - product_repo - .delete_by_id(existing.id, auth.tenant_id()) - .await?; - Ok(()) -} +#[async_trait] +impl ProductService for ProductServiceImpl { + async fn create(&self, auth: &AuthUser, req: CreateProductRequest) -> AppResult { + let (req, _d) = self.before_create(auth, req).await?; + let product_type = req.product_type.as_deref().unwrap_or("custom"); + let fulfillment_type = req.fulfillment_type.as_deref().unwrap_or("digital"); + let currency = req.currency.as_deref().unwrap_or("CNY"); + let generated_slug = slug_aspect::generate_slug(&req.title); + let slug = req.slug.as_deref().or(Some(generated_slug.as_str())); + let p = self + .repo + .insert( + &CreateProductCmd { + category_id: None, + title: req.title, + description: req.description, + cover_url: req.cover_url, + product_type: product_type.to_string(), + fulfillment_type: fulfillment_type.to_string(), + delivery_hook: req.delivery_hook, + weight: req.weight, + price: req.price, + currency: currency.to_string(), + attributes: req.attributes, + sort_order: req.sort_order.unwrap_or(0), + slug: slug.map(|s| s.to_string()), + content: req.content, + image_ids: req.image_ids, + original_price: req.original_price, + specs: req.specs, + unit: req.unit.as_deref().unwrap_or("piece").to_string(), + min_purchase: req.min_purchase.unwrap_or(1), + max_purchase: req.max_purchase, + virtual_sales: req.virtual_sales.unwrap_or(0), + meta_title: req.meta_title, + meta_description: req.meta_description, + }, + auth.tenant_id(), + ) + .await?; + self.after_created(&p); + Ok(p) + } -pub async fn get_product( - product_repo: &dyn ProductRepository, - id: &str, - auth: &AuthUser, -) -> AppResult { - product_repo - .find_by_document_id(id, auth.tenant_id()) - .await? - .ok_or_else(|| AppError::not_found("product")) -} + async fn update( + &self, + auth: &AuthUser, + id: &str, + req: UpdateProductRequest, + ) -> AppResult { + let existing = self + .repo + .find_by_document_id(id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("product"))?; -pub async fn list_active_products( - product_repo: &dyn ProductRepository, - auth: &AuthUser, - page: i64, - page_size: i64, -) -> AppResult<(Vec, i64)> { - product_repo - .find_active_paginated(auth.tenant_id(), page, page_size) - .await -} + let (req, _d) = self.before_update(auth, &existing, req).await?; -pub async fn list_admin_products( - product_repo: &dyn ProductRepository, - auth: &AuthUser, - page: i64, - page_size: i64, - status: Option<&str>, -) -> AppResult<(Vec, i64)> { - product_repo - .find_all_admin(auth.tenant_id(), page, page_size, status) - .await + let title = req.title.as_deref().unwrap_or(&existing.title); + let product_type = req + .product_type + .as_deref() + .unwrap_or(existing.product_type.as_str()); + let fulfillment_type = req + .fulfillment_type + .as_deref() + .unwrap_or(existing.fulfillment_type.as_str()); + let currency = req.currency.as_deref().unwrap_or(&existing.currency); + let status = req.status.as_deref().unwrap_or(existing.status.as_str()); + let price = req.price.unwrap_or(existing.price); + let sort_order = req.sort_order.unwrap_or(existing.sort_order); + let slug = req.slug.as_deref().or(existing.slug.as_deref()); + let unit = req.unit.as_deref().unwrap_or(&existing.unit); + let min_purchase = req.min_purchase.unwrap_or(existing.min_purchase); + let total_sales = existing.total_sales; + let virtual_sales = req.virtual_sales.unwrap_or(existing.virtual_sales); + + let existing_published_at_str = existing + .published_at + .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()); + let generated_published_at; + let published_at: Option<&str> = if status == "active" + && existing.status.as_str() != "active" + && existing.published_at.is_none() + { + generated_published_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + Some(generated_published_at.as_str()) + } else { + existing_published_at_str.as_deref() + }; + + let updated = self + .repo + .update( + &UpdateProductCmd { + id: existing.id, + category_id: None, + title: title.to_string(), + description: req.description.or(existing.description), + cover_url: req.cover_url.or(existing.cover_url), + product_type: product_type.to_string(), + fulfillment_type: fulfillment_type.to_string(), + delivery_hook: req.delivery_hook.or(existing.delivery_hook), + weight: req.weight.or(existing.weight), + price, + currency: currency.to_string(), + status: status.to_string(), + attributes: req.attributes.or(existing.attributes), + sort_order, + slug: slug.map(|s| s.to_string()), + content: req.content.or(existing.content), + image_ids: req.image_ids.or(existing.image_ids), + original_price: req.original_price.or(existing.original_price), + specs: req.specs.or(existing.specs), + unit: unit.to_string(), + min_purchase, + max_purchase: req.max_purchase.or(existing.max_purchase), + total_sales, + virtual_sales, + meta_title: req.meta_title.or(existing.meta_title), + meta_description: req.meta_description.or(existing.meta_description), + published_at: published_at.map(|s| s.to_string()), + version: req.version, + }, + auth.tenant_id(), + ) + .await?; + + if !updated { + return Err(AppError::Conflict("version_conflict".into())); + } + + let result = self + .repo + .find_by_id(existing.id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("product"))?; + self.after_updated(&result); + Ok(result) + } + + 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("product"))?; + self.before_delete(auth, &existing).await?; + self.repo + .delete_by_id(existing.id, auth.tenant_id()) + .await?; + self.after_deleted(&existing); + Ok(()) + } + + async fn get(&self, id: &str, auth: &AuthUser) -> AppResult { + self.repo + .find_by_document_id(id, auth.tenant_id()) + .await? + .ok_or_else(|| AppError::not_found("product")) + } + + async fn list_active( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, i64)> { + self.repo + .find_active_paginated(auth.tenant_id(), page, page_size) + .await + } + + async fn list_admin( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + status: Option<&str>, + ) -> AppResult<(Vec, i64)> { + self.repo + .find_all_admin(auth.tenant_id(), page, page_size, status) + .await + } } #[cfg(test)] @@ -210,42 +307,49 @@ mod tests { ) } + fn make_service(pool: crate::db::Pool) -> Arc { + Arc::new(ProductServiceImpl::new( + Arc::new(SqlxProductRepository::new(pool.clone())), + Arc::new(AspectEngine::new()), + )) + } + #[tokio::test] async fn create_product_basic() { let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Widget".into(), - description: Some("A nice widget".into()), - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 1000, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); + let p = svc + .create( + &a, + CreateProductRequest { + title: "Widget".into(), + description: Some("A nice widget".into()), + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 1000, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); assert_eq!(p.title, "Widget"); assert_eq!(p.price, 1000); assert_eq!(p.status, crate::models::product::ProductStatus::Draft); @@ -254,39 +358,39 @@ mod tests { #[tokio::test] async fn create_product_with_custom_type() { let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "E-Book".into(), - description: None, - cover_url: None, - category_id: None, - product_type: Some("download".into()), - fulfillment_type: Some("digital".into()), - delivery_hook: None, - weight: None, - price: 500, - currency: Some("USD".into()), - attributes: None, - sort_order: Some(10), - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); + let p = svc + .create( + &a, + CreateProductRequest { + title: "E-Book".into(), + description: None, + cover_url: None, + category_id: None, + product_type: Some("download".into()), + fulfillment_type: Some("digital".into()), + delivery_hook: None, + weight: None, + price: 500, + currency: Some("USD".into()), + attributes: None, + sort_order: Some(10), + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); assert_eq!( p.product_type, crate::models::product::ProductType::Download @@ -302,311 +406,13 @@ mod tests { #[tokio::test] async fn get_product_found() { let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Found".into(), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 100, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); - let found = super::get_product(&repo, &p.document_id, &a).await.unwrap(); - assert_eq!(found.id, p.id); - } - - #[tokio::test] - async fn get_product_not_found() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - assert!(super::get_product(&repo, "nonexistent", &a).await.is_err()); - } - - #[tokio::test] - async fn update_product_changes_title() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Old".into(), - description: Some("old desc".into()), - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 100, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); - let updated = super::update_product( - &repo, - &a, - &p.document_id, - UpdateProductRequest { - title: Some("New".into()), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: None, - currency: None, - status: Some("active".into()), - attributes: None, - sort_order: None, - version: 1, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); - assert_eq!(updated.title, "New"); - assert_eq!(updated.description.unwrap(), "old desc"); - assert_eq!(updated.price, 100); - assert_eq!( - updated.status, - crate::models::product::ProductStatus::Active - ); - assert_eq!(updated.version, 2); - } - - #[tokio::test] - async fn update_product_version_conflict() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Conflict".into(), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 100, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); - let err = super::update_product( - &repo, - &a, - &p.document_id, - UpdateProductRequest { - title: Some("New".into()), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: None, - currency: None, - status: None, - attributes: None, - sort_order: None, - version: 999, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, AppError::Conflict(_))); - } - - #[tokio::test] - async fn update_product_not_found() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - let err = super::update_product( - &repo, - &a, - "nonexistent", - UpdateProductRequest { - title: Some("X".into()), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: None, - currency: None, - status: None, - attributes: None, - sort_order: None, - version: 1, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, AppError::NotFound(_))); - } - - #[tokio::test] - async fn delete_product_success() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Bye".into(), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 100, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); - super::delete_product(&repo, &p.document_id, &a) - .await - .unwrap(); - assert!(super::get_product(&repo, &p.document_id, &a).await.is_err()); - } - - #[tokio::test] - async fn delete_product_not_found() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - assert!( - super::delete_product(&repo, "nonexistent", &a) - .await - .is_err() - ); - } - - #[tokio::test] - async fn list_active_products() { - let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); - let a = auth(None); - for i in 0..3 { - let p = super::create_product( - &repo, + let p = svc + .create( &a, CreateProductRequest { - title: format!("P{i}"), + title: "Found".into(), description: None, cover_url: None, category_id: None, @@ -633,13 +439,305 @@ mod tests { ) .await .unwrap(); + let found = svc.get(&p.document_id, &a).await.unwrap(); + assert_eq!(found.id, p.id); + } + + #[tokio::test] + async fn get_product_not_found() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + assert!(svc.get("nonexistent", &a).await.is_err()); + } + + #[tokio::test] + async fn update_product_changes_title() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + let p = svc + .create( + &a, + CreateProductRequest { + title: "Old".into(), + description: Some("old desc".into()), + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 100, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); + let updated = svc + .update( + &a, + &p.document_id, + UpdateProductRequest { + title: Some("New".into()), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: None, + currency: None, + status: Some("active".into()), + attributes: None, + sort_order: None, + version: 1, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); + assert_eq!(updated.title, "New"); + assert_eq!(updated.description.unwrap(), "old desc"); + assert_eq!(updated.price, 100); + assert_eq!( + updated.status, + crate::models::product::ProductStatus::Active + ); + assert_eq!(updated.version, 2); + } + + #[tokio::test] + async fn update_product_version_conflict() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + let p = svc + .create( + &a, + CreateProductRequest { + title: "Conflict".into(), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 100, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); + let err = svc + .update( + &a, + &p.document_id, + UpdateProductRequest { + title: Some("New".into()), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: None, + currency: None, + status: None, + attributes: None, + sort_order: None, + version: 999, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_))); + } + + #[tokio::test] + async fn update_product_not_found() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + let err = svc + .update( + &a, + "nonexistent", + UpdateProductRequest { + title: Some("X".into()), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: None, + currency: None, + status: None, + attributes: None, + sort_order: None, + version: 1, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); + } + + #[tokio::test] + async fn delete_product_success() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + let p = svc + .create( + &a, + CreateProductRequest { + title: "Bye".into(), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 100, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); + svc.delete(&p.document_id, &a).await.unwrap(); + assert!(svc.get(&p.document_id, &a).await.is_err()); + } + + #[tokio::test] + async fn delete_product_not_found() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + assert!(svc.delete("nonexistent", &a).await.is_err()); + } + + #[tokio::test] + async fn list_active_products() { + let pool = setup_pool().await; + let svc = make_service(pool.clone()); + let a = auth(None); + for i in 0..3 { + let p = svc + .create( + &a, + CreateProductRequest { + title: format!("P{i}"), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 100, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); sqlx::query("UPDATE products SET status = 'active' WHERE id = ?") .bind(p.id) .execute(&pool) .await .unwrap(); } - let (items, total) = super::list_active_products(&repo, &a, 1, 10).await.unwrap(); + let (items, total) = svc.list_active(&a, 1, 10).await.unwrap(); assert_eq!(total, 3); assert_eq!(items.len(), 3); } @@ -647,46 +745,45 @@ mod tests { #[tokio::test] async fn list_admin_products_with_filter() { let pool = setup_pool().await; - let repo = SqlxProductRepository::new(pool.clone()); + let svc = make_service(pool.clone()); let a = auth(None); - let p = super::create_product( - &repo, - &a, - CreateProductRequest { - title: "Active".into(), - description: None, - cover_url: None, - category_id: None, - product_type: None, - fulfillment_type: None, - delivery_hook: None, - weight: None, - price: 100, - currency: None, - attributes: None, - sort_order: None, - slug: None, - content: None, - image_ids: None, - original_price: None, - specs: None, - unit: None, - min_purchase: None, - max_purchase: None, - virtual_sales: None, - meta_title: None, - meta_description: None, - }, - ) - .await - .unwrap(); + let p = svc + .create( + &a, + CreateProductRequest { + title: "Active".into(), + description: None, + cover_url: None, + category_id: None, + product_type: None, + fulfillment_type: None, + delivery_hook: None, + weight: None, + price: 100, + currency: None, + attributes: None, + sort_order: None, + slug: None, + content: None, + image_ids: None, + original_price: None, + specs: None, + unit: None, + min_purchase: None, + max_purchase: None, + virtual_sales: None, + meta_title: None, + meta_description: None, + }, + ) + .await + .unwrap(); sqlx::query("UPDATE products SET status = 'active' WHERE id = ?") .bind(p.id) .execute(&pool) .await .unwrap(); - super::create_product( - &repo, + svc.create( &a, CreateProductRequest { title: "Draft".into(), @@ -717,13 +814,9 @@ mod tests { .await .unwrap(); - let (all, total_all) = super::list_admin_products(&repo, &a, 1, 10, None) - .await - .unwrap(); + let (_, total_all) = svc.list_admin(&a, 1, 10, None).await.unwrap(); assert_eq!(total_all, 2); - let (active, total_active) = super::list_admin_products(&repo, &a, 1, 10, Some("active")) - .await - .unwrap(); + let (active, total_active) = svc.list_admin(&a, 1, 10, Some("active")).await.unwrap(); assert_eq!(total_active, 1); assert_eq!(active.len(), 1); } diff --git a/src/services/tag.rs b/src/services/tag.rs index deccc47b..22a392e5 100644 --- a/src/services/tag.rs +++ b/src/services/tag.rs @@ -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 { - 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; + async fn update(&self, auth: &AuthUser, id: &str, name: String, slug: String) + -> AppResult; + async fn delete(&self, id: &str, auth: &AuthUser) -> AppResult<()>; + async fn get(&self, id: &str, auth: &AuthUser) -> AppResult; + async fn list(&self, auth: &AuthUser) -> AppResult>; + async fn list_paginated( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, 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, + aspect_engine: Arc, } -pub async fn get_tag( - tag_repo: &dyn TagRepository, - id: &str, - auth: &AuthUser, -) -> AppResult { - 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, aspect_engine: Arc) -> 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 { + 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 { - 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 { + 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> { - tag_repo.find_all(auth.tenant_id()).await -} + async fn update( + &self, + auth: &AuthUser, + id: &str, + name: String, + slug: String, + ) -> AppResult { + 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, 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 { + 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> { + self.repo.find_all(auth.tenant_id()).await + } + + async fn list_paginated( + &self, + auth: &AuthUser, + page: i64, + page_size: i64, + ) -> AppResult<(Vec, 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 { + 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); } diff --git a/src/services/wallet.rs b/src/services/wallet.rs index 62721811..9aa2dc8e 100644 --- a/src/services/wallet.rs +++ b/src/services/wallet.rs @@ -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, + reference_id: Option<&str>, + metadata: Option<&str>, + ) -> AppResult; + + #[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, + reference_id: Option<&str>, + metadata: Option<&str>, + ) -> AppResult; + + #[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, + reference_id: Option<&str>, + metadata: Option<&str>, + ) -> AppResult<(WalletTransaction, WalletTransaction)>; + + async fn reverse_transaction( + &self, + original_tx_id: i64, + transaction_no: &str, + ) -> AppResult; + + async fn tx_to_response( + &self, + tx: WalletTransaction, + ) -> AppResult; + + async fn tx_list_to_response( + &self, + rows: Vec, + ) -> AppResult>; +} + +pub struct WalletServiceImpl { + repo: Arc, + aspect_engine: Arc, + pool: Arc, +} + +impl WalletServiceImpl { + pub fn new( + repo: Arc, + aspect_engine: Arc, + pool: Arc, + ) -> 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, + reference_id: Option<&str>, + metadata: Option<&str>, + ) -> AppResult { + 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, + reference_id: Option<&str>, + metadata: Option<&str>, + ) -> AppResult { + 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, + 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 { + 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 { + tx_to_response(self.repo.as_ref(), tx).await + } + + async fn tx_list_to_response( + &self, + rows: Vec, + ) -> AppResult> { + tx_list_to_response(self.repo.as_ref(), rows).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/api.rs b/tests/api.rs index 5e565b7b..54b70c5e 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -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: { diff --git a/tests/tauri_tests.rs b/tests/tauri_tests.rs index eeea3749..4ac45fcd 100644 --- a/tests/tauri_tests.rs +++ b/tests/tauri_tests.rs @@ -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 {