Skip to content

الخدمات

الخدمات (Services) تُشكّل طبقة منطق الأعمال في التطبيق، وتقع بين المعالجات والنماذج. تُغلّف قواعد المجال، وتُنسّق استعلامات قاعدة البيانات، وتضمن سلامة البيانات. كل وحدة في مشروع مُولّد بـ FORGE لديها service مخصص تُفوّض له المعالجات.

معمارية الخدمات

Handler                    Service                     Database
───────                    ───────                     ────────
create_user() ────────▶  UserService::create()  ────▶  INSERT INTO users
                          ├─ التحقق من التفرد
                          ├─ تجزئة كلمة المرور
                          ├─ إنشاء سجل المستخدم
                          ├─ تعيين الأدوار
                          └─ إرجاع UserWithRoles

الخدمات تمتلك منطق الأعمال. تُقرر ما يحدث عند إنشاء أو تحديث أو حذف مستخدم. المعالجات ببساطة تُمرر الطلب وتُرجع النتيجة.

نصيحة

إذا وجدت نفسك تكتب منطق أعمال في معالج، انقله للـ service. المعالجات يجب أن تحتوي فقط على تحليل الطلب وتفويض الـ service وتنسيق الاستجابة.

نمط الـ Service

كل service يتبع نفس النمط الهيكلي:

rust
use sqlx::PgPool;
use uuid::Uuid;
use crate::dto::{CreateUserRequest, UpdateUserRequest, PaginatedRequest};
use crate::models::user::{User, UserWithRoles};
use crate::utils::error::AppError;
use crate::utils::password::hash_password;

pub struct UserService;

impl UserService {
    /// List users with pagination and search
    pub async fn list(
        pool: &PgPool,
        params: &PaginatedRequest,
    ) -> Result<(Vec<UserWithRoles>, i64), AppError> {
        // ...
    }

    /// Find a single user by ID
    pub async fn find_by_id(
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<UserWithRoles>, AppError> {
        // ...
    }

    /// Create a new user
    pub async fn create(
        pool: &PgPool,
        payload: CreateUserRequest,
    ) -> Result<UserWithRoles, AppError> {
        // ...
    }

    /// Update an existing user
    pub async fn update(
        pool: &PgPool,
        id: Uuid,
        payload: UpdateUserRequest,
    ) -> Result<Option<UserWithRoles>, AppError> {
        // ...
    }

    /// Soft delete a user
    pub async fn delete(
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<()>, AppError> {
        // ...
    }
}

مثال: UserService

تنفيذ service كامل يُوضّح جميع عمليات CRUD:

القائمة مع ترقيم الصفحات

rust
impl UserService {
    pub async fn list(
        pool: &PgPool,
        params: &PaginatedRequest,
    ) -> Result<(Vec<UserWithRoles>, i64), AppError> {
        let offset = (params.page - 1) * params.per_page;
        let search = params.search.as_deref();

        // Get total count
        let total = sqlx::query_scalar!(
            r#"
            SELECT COUNT(*) as "count!"
            FROM users
            WHERE deleted_at IS NULL
            AND ($1::text IS NULL
                 OR name ILIKE '%' || $1 || '%'
                 OR email ILIKE '%' || $1 || '%')
            "#,
            search
        )
        .fetch_one(pool)
        .await?;

        // Get paginated results
        let users = sqlx::query_as!(
            User,
            r#"
            SELECT id, name, email, password, mobile, avatar,
                   email_verified_at, is_active,
                   created_at, updated_at, deleted_at
            FROM users
            WHERE deleted_at IS NULL
            AND ($1::text IS NULL
                 OR name ILIKE '%' || $1 || '%'
                 OR email ILIKE '%' || $1 || '%')
            ORDER BY created_at DESC
            LIMIT $2 OFFSET $3
            "#,
            search,
            params.per_page,
            offset
        )
        .fetch_all(pool)
        .await?;

        // Load roles for each user
        let mut result = Vec::new();
        for user in users {
            let roles = Self::load_roles(pool, user.id).await?;
            result.push(UserWithRoles { user, roles });
        }

        Ok((result, total))
    }
}

الإنشاء

rust
impl UserService {
    pub async fn create(
        pool: &PgPool,
        payload: CreateUserRequest,
    ) -> Result<UserWithRoles, AppError> {
        // Check email uniqueness
        let existing = sqlx::query_scalar!(
            "SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL",
            &payload.email
        )
        .fetch_optional(pool)
        .await?;

        if existing.is_some() {
            return Err(AppError::ValidationError(
                serde_json::json!({
                    "email": ["This email is already in use"]
                })
            ));
        }

        // Hash password
        let password_hash = hash_password(&payload.password)?;

        // Insert user
        let user = sqlx::query_as!(
            User,
            r#"
            INSERT INTO users (name, email, password, mobile, is_active)
            VALUES ($1, $2, $3, $4, $5)
            RETURNING id, name, email, password, mobile, avatar,
                      email_verified_at, is_active,
                      created_at, updated_at, deleted_at
            "#,
            payload.name,
            payload.email,
            password_hash,
            payload.mobile,
            payload.is_active.unwrap_or(true)
        )
        .fetch_one(pool)
        .await?;

        // Assign roles if provided
        if let Some(role_ids) = payload.role_ids {
            Self::assign_roles(pool, user.id, &role_ids).await?;
        }

        // Return user with roles
        let roles = Self::load_roles(pool, user.id).await?;
        Ok(UserWithRoles { user, roles })
    }
}

التحديث

rust
impl UserService {
    pub async fn update(
        pool: &PgPool,
        id: Uuid,
        payload: UpdateUserRequest,
    ) -> Result<Option<UserWithRoles>, AppError> {
        // Check if user exists
        let existing = sqlx::query!(
            "SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL",
            id
        )
        .fetch_optional(pool)
        .await?;

        if existing.is_none() {
            return Ok(None);
        }

        // Check email uniqueness if changed
        if let Some(ref email) = payload.email {
            let conflict = sqlx::query_scalar!(
                "SELECT id FROM users WHERE email = $1 AND id != $2 AND deleted_at IS NULL",
                email,
                id
            )
            .fetch_optional(pool)
            .await?;

            if conflict.is_some() {
                return Err(AppError::ValidationError(
                    serde_json::json!({
                        "email": ["This email is already in use"]
                    })
                ));
            }
        }

        // Build dynamic update query
        let password_hash = match payload.password {
            Some(ref pw) => Some(hash_password(pw)?),
            None => None,
        };

        let user = sqlx::query_as!(
            User,
            r#"
            UPDATE users SET
                name = COALESCE($1, name),
                email = COALESCE($2, email),
                password = COALESCE($3, password),
                mobile = COALESCE($4, mobile),
                is_active = COALESCE($5, is_active),
                updated_at = now()
            WHERE id = $6 AND deleted_at IS NULL
            RETURNING id, name, email, password, mobile, avatar,
                      email_verified_at, is_active,
                      created_at, updated_at, deleted_at
            "#,
            payload.name,
            payload.email,
            password_hash,
            payload.mobile,
            payload.is_active,
            id
        )
        .fetch_one(pool)
        .await?;

        // Update roles if provided
        if let Some(role_ids) = payload.role_ids {
            Self::sync_roles(pool, id, &role_ids).await?;
        }

        let roles = Self::load_roles(pool, user.id).await?;
        Ok(Some(UserWithRoles { user, roles }))
    }
}

الحذف (حذف ناعم)

rust
impl UserService {
    pub async fn delete(
        pool: &PgPool,
        id: Uuid,
    ) -> Result<Option<()>, AppError> {
        let result = sqlx::query!(
            r#"
            UPDATE users
            SET deleted_at = now(), updated_at = now()
            WHERE id = $1 AND deleted_at IS NULL
            "#,
            id
        )
        .execute(pool)
        .await?;

        if result.rows_affected() == 0 {
            return Ok(None);
        }

        Ok(Some(()))
    }
}

مساعدات الأدوار والصلاحيات

الخدمات تتضمن دوال مساعدة لإدارة العلاقات:

rust
impl UserService {
    /// Load all roles for a user
    async fn load_roles(pool: &PgPool, user_id: Uuid) -> Result<Vec<Role>, AppError> {
        let roles = sqlx::query_as!(
            Role,
            r#"
            SELECT r.id, r.name, r.display_name, r.description,
                   r.is_system, r.created_at, r.updated_at
            FROM roles r
            INNER JOIN role_user ru ON ru.role_id = r.id
            WHERE ru.user_id = $1
            "#,
            user_id
        )
        .fetch_all(pool)
        .await?;

        Ok(roles)
    }

    /// Replace all roles for a user (sync)
    async fn sync_roles(
        pool: &PgPool,
        user_id: Uuid,
        role_ids: &[Uuid],
    ) -> Result<(), AppError> {
        // Remove existing roles
        sqlx::query!("DELETE FROM role_user WHERE user_id = $1", user_id)
            .execute(pool)
            .await?;

        // Assign new roles
        Self::assign_roles(pool, user_id, role_ids).await
    }

    /// Assign roles to a user (additive)
    async fn assign_roles(
        pool: &PgPool,
        user_id: Uuid,
        role_ids: &[Uuid],
    ) -> Result<(), AppError> {
        for role_id in role_ids {
            sqlx::query!(
                r#"
                INSERT INTO role_user (user_id, role_id)
                VALUES ($1, $2)
                ON CONFLICT DO NOTHING
                "#,
                user_id,
                role_id
            )
            .execute(pool)
            .await?;
        }

        Ok(())
    }
}

ContentService مع الترجمات

service المحتوى يُوضّح العمل مع النماذج القابلة للترجمة:

rust
pub struct ContentService;

impl ContentService {
    pub async fn create(
        pool: &PgPool,
        payload: CreateContentRequest,
    ) -> Result<Content, AppError> {
        // Check slug uniqueness
        let existing = sqlx::query_scalar!(
            "SELECT id FROM contents WHERE slug = $1 AND deleted_at IS NULL",
            &payload.slug
        )
        .fetch_optional(pool)
        .await?;

        if existing.is_some() {
            return Err(AppError::ValidationError(
                serde_json::json!({
                    "slug": ["This slug is already in use"]
                })
            ));
        }

        let content = sqlx::query_as!(
            Content,
            r#"
            INSERT INTO contents (slug, content_type, translations, metadata, is_active, sort_order)
            VALUES ($1, $2, $3, $4, $5, $6)
            RETURNING *
            "#,
            payload.slug,
            payload.content_type,
            payload.translations,
            payload.metadata,
            payload.is_active.unwrap_or(true),
            payload.sort_order.unwrap_or(0)
        )
        .fetch_one(pool)
        .await?;

        Ok(content)
    }

    pub async fn find_by_slug(
        pool: &PgPool,
        slug: &str,
    ) -> Result<Option<Content>, AppError> {
        let content = sqlx::query_as!(
            Content,
            "SELECT * FROM contents WHERE slug = $1 AND deleted_at IS NULL",
            slug
        )
        .fetch_optional(pool)
        .await?;

        Ok(content)
    }
}

انتشار الأخطاء

الخدمات تستخدم عامل ? لنشر الأخطاء. أخطاء SQLx تتحول تلقائياً لـ AppError:

rust
// sqlx::Error converts automatically to AppError via From trait
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
    .fetch_optional(pool)
    .await?;  // sqlx::Error -> AppError::InternalError

// Business logic errors are returned explicitly
if existing.is_some() {
    return Err(AppError::ValidationError(
        serde_json::json!({"email": ["Already in use"]})
    ));
}

تحذير

لا تستخدم panic أبداً في كود الـ service. أرجع دائماً Result<T, AppError> ودع المعالج يحوّله لاستجابة HTTP مناسبة. الـ panics تُعطّل معالج الطلب وتُنتج أخطاء 500 غير مفيدة.

تسجيل الوحدات

سجّل الخدمات في src/services/mod.rs:

rust
// src/services/mod.rs
pub mod auth;
pub mod content;
pub mod lookup;
pub mod media;
pub mod menu;
pub mod user;
pub mod role;

pub use auth::AuthService;
pub use content::ContentService;
pub use lookup::LookupService;
pub use media::MediaService;
pub use menu::MenuService;
pub use user::UserService;
pub use role::RoleService;

انظر أيضاً

Released under the MIT License.