دليل تقني

بناء إضافات Vendure للمؤسسات: أنماط تنجح في بيئة Multi-Pod

أنماط إنتاجية لتطوير إضافات Vendure. اصطلاحات الكيانات، فصل الـ Resolvers، تواصل EventBus، انتخاب القائد، منع تكرار الإشعارات، والاختبار على سيرفرات حقيقية.

8 فبراير 202618 دقيقة للقراءةفريق هندسة أورنتس

أبعد من الـ Plugin Tutorial

كل دورة Vendure تعلمك كيف تسوي plugin بـ entity واحد، resolver واحد، و service واحد. هذا يوصلك من الصفر لـ "يشتغل على لابتوبي." لكن ما يوصلك للإنتاج مع بودات متعددة، background workers، منع تكرار الإيميلات، و 30+ كيان موزعين على 6 موديولات تتواصل بدون circular dependencies.

بنينا إضافتين مؤسسية لـ Vendure. الـ Data Hub Plugin هو محرك ETL pipeline فيه 9 extractors و 61 transform operator و 24 entity loader. الـ Customer Intelligence Plugin يغطي قوائم الأمنيات، المراجعات، برامج الولاء، استرجاع السلات المتروكة، تنبيهات توفر المنتج، والمنتجات المعروضة مؤخراً. بينهم عندنا 50+ كيان، عشرات الـ resolvers، job queues متعددة، ولوحات تحكم مبنية بـ React و TanStack.

هذا المقال يغطي الأنماط اللي نجحت في الإنتاج. للتقييم على مستوى المنصة، شوف دليل Vendure للإنتاج. هنا نتعمق أكثر في أنماط تطوير الإضافات.

اصطلاحات الكيانات

ثوابت أسماء الجداول

لا تكتب أسماء الجداول يدوياً أبداً. مع 30+ كيان موزعين على إضافات متعددة، النصوص المكتوبة يدوياً تصير كارثة صيانة.

// shared/constants/index.ts
export const TABLE_NAMES = {
    // موديول قائمة الأمنيات
    WISHLIST: 'ci_wishlist',
    WISHLIST_ITEM: 'ci_wishlist_item',

    // موديول المراجعات
    REVIEW: 'ci_review',
    REVIEW_VOTE: 'ci_review_vote',
    REVIEW_RESPONSE: 'ci_review_response',

    // موديول الولاء
    LOYALTY_ACCOUNT: 'ci_loyalty_account',
    LOYALTY_TRANSACTION: 'ci_loyalty_transaction',
    LOYALTY_TIER: 'ci_loyalty_tier',

    // موديول استرجاع السلة
    ABANDONED_CART: 'ci_abandoned_cart',
    RECOVERY_FLOW: 'ci_recovery_flow',

    // البنية التحتية
    IDEMPOTENCY_KEY: 'ci_idempotency_key',
    NOTIFICATION_LOG: 'ci_notification_log',
    AUDIT_ENTRY: 'ci_audit_entry',
} as const;

البادئة ci_ تمنع التعارض مع جداول Vendure الأساسية والإضافات الثانية. كل كيان يستخدم الثابت، مو نص مكتوب يدوياً أبداً.

نمط الكيان الأساسي

كل كيان يتبع نفس البنية:

import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { DeepPartial, VendureEntity } from '@vendure/core';
import { TABLE_NAMES } from '../../shared/constants';

@Entity(TABLE_NAMES.REVIEW)
@Index(['productId', 'status', 'channelId'])
@Index(['customerId', 'createdAt'])
export class CiReview extends VendureEntity {
    constructor(input?: DeepPartial<CiReview>) {
        super(input);
    }

    @Column()
    productId!: number;

    @Column()
    customerId!: number;

    @Column()
    channelId!: number;

    @Column({ type: 'int', default: 0 })
    rating!: number;

    @Column({ type: 'text' })
    body!: string;

    @Column({ type: 'varchar', length: 20, default: 'pending' })
    status!: string; // pending | approved | rejected

    @Column({ type: 'datetime', nullable: true })
    deletedAt?: Date;
}

القواعد:

  • كل كيان يمتد من VendureEntity (يوفر id و createdAt و updatedAt)
  • كل كيان يستخدم TABLE_NAMES.X لاسم الجدول
  • كل كيان يشمل channelId لعزل القنوات المتعددة
  • أسماء الكلاسات تستخدم بادئة Ci: مثل CiReview و CiWishlist و CiLoyaltyAccount
  • الـ Constructor يقبل DeepPartial<T> لإنشاء نظيف
  • الفهارس معرّفة على الكيان نفسه، مو في الـ migrations، عشان تكون واضحة

الحذف الناعم

للكيانات اللي تحتاج سجل تدقيق أو إمكانية استرجاع، أضف deletedAt بدل الحذف الفعلي. فلتر في كل الاستعلامات:

async findActive(ctx: RequestContext, options?: ListQueryOptions<CiReview>) {
    return this.listQueryBuilder
        .build(CiReview, options ?? {}, { ctx })
        .andWhere('entity.deletedAt IS NULL')
        .andWhere('entity.channelId = :channelId', { channelId: ctx.channelId })
        .getManyAndCount()
        .then(([items, totalItems]) => ({ items, totalItems }));
}

فصل الـ Resolvers

الـ Shop API resolvers (واجهة العميل) والـ Admin API resolvers (لوحة الإدارة) دائماً كلاسات منفصلة. هذا مو اقتراح. هذا قيد تصميمي يمنع تعريض عمليات الإدارة للعملاء بالغلط.

// resolvers/review-shop.resolver.ts
@Resolver()
export class ReviewShopResolver {
    constructor(private reviewService: ReviewService) {}

    @Query()
    @Allow(Permission.Public)
    async productReviews(
        @Ctx() ctx: RequestContext,
        @Args() args: { productId: string; options?: ListQueryOptions<CiReview> },
    ) {
        return this.reviewService.findApprovedByProduct(ctx, args.productId, args.options);
    }

    @Mutation()
    @Transaction()
    @Allow(Permission.Owner)
    async submitReview(
        @Ctx() ctx: RequestContext,
        @Args() args: { input: SubmitReviewInput },
    ) {
        return this.reviewService.submit(ctx, args.input);
    }
}

// resolvers/review-admin.resolver.ts
@Resolver()
export class ReviewAdminResolver {
    constructor(private reviewService: ReviewService) {}

    @Query()
    @Allow(Permission.ReadCatalog)
    async allReviews(
        @Ctx() ctx: RequestContext,
        @Args() args: { options?: ListQueryOptions<CiReview> },
    ) {
        return this.reviewService.findAll(ctx, args.options); // يشمل المعلقة والمرفوضة
    }

    @Mutation()
    @Transaction()
    @Allow(Permission.UpdateCatalog)
    async moderateReview(
        @Ctx() ctx: RequestContext,
        @Args() args: { reviewId: string; decision: 'approve' | 'reject'; reason?: string },
    ) {
        return this.reviewService.moderate(ctx, args.reviewId, args.decision, args.reason);
    }
}

الـ Shop resolver يعرض productReviews (الموافق عليها فقط، وصول عام). الـ Admin resolver يعرض allReviews (كل الحالات، وصول إداري) و moderateReview (للإدارة فقط). ما تنحط أبداً في كلاس واحد.

تواصل EventBus

الموديولات تتواصل عبر الـ EventBus فقط. موديول قائمة الأمنيات ما يستورد service من موديول الولاء. موديول المراجعات ما يستورد service من موديول الإشعارات.

// الموديول A يرسل حدث
@Injectable()
export class ReviewService {
    constructor(private eventBus: EventBus) {}

    async submit(ctx: RequestContext, input: SubmitReviewInput): Promise<CiReview> {
        const review = await this.createReview(ctx, input);

        // أرسل الحدث، لا تنادي خدمة الولاء مباشرة
        await this.eventBus.publish(new CiReviewSubmittedEvent(ctx, review));

        return review;
    }
}

// الموديول B يشترك ويعالج
@Injectable()
export class LoyaltyEventHandler {
    constructor(
        private eventBus: EventBus,
        private loyaltyService: LoyaltyService,
    ) {
        this.eventBus.ofType(CiReviewSubmittedEvent).subscribe(async event => {
            // منح نقاط ولاء لتقديم مراجعة
            await this.loyaltyService.awardPoints(
                event.ctx,
                event.review.customerId,
                'review_submitted',
                50, // نقاط
            );
        });
    }
}

هذا يحافظ على حدود الموديولات نظيفة. تقدر تشيل موديول الولاء بدون ما تكسر موديول المراجعات. الحدث ينشر سواء اشترك فيه أحد أو لا.

لكيف نطبق أنماط EventBus في أنظمة غير Vendure، شوف دليل البنية المدفوعة بالأحداث.

التنسيق بين البودات المتعددة

انتخاب القائد للمجدولات

المهام المجدولة (كشف السلات المتروكة، انتهاء نقاط الولاء، التحقق من انخفاض الأسعار) لازم تشتغل على بود واحد بس. بدون انتخاب القائد، كل بود يشغّل المجدول، وتحصل على معالجة مكررة.

JobQueue في Vendure يتعامل مع هذا. عامل واحد بس يلتقط المهمة من الطابور. جدول المهام عبر الطابور، مو عبر setInterval:

@Injectable()
export class CartDetectionService implements OnModuleInit {
    private detectionQueue: JobQueue<Record<string, never>>;

    constructor(private jobQueueService: JobQueueService) {}

    async onModuleInit() {
        this.detectionQueue = await this.jobQueueService.createQueue({
            name: 'ci-cart-detection',
            process: async (ctx) => {
                await this.detectAbandonedCarts(ctx);
            },
        });
    }

    // يُستدعى من مجدول أو cron trigger
    async scheduleDetection(ctx: RequestContext) {
        await this.detectionQueue.add({}, { ctx });
    }
}

منع تكرار الإشعارات

لما كل البودات تستهلك نفس الحدث، كل بود ممكن يحاول يرسل نفس الإشعار. مخزن منع التكرار يمنع الإرسال المزدوج:

@Injectable()
export class NotificationService {
    async sendIfNotDuplicate(
        ctx: RequestContext,
        recipient: string,
        category: string,
        entityRef: string,
    ): Promise<boolean> {
        const dayBucket = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
        const dedupeKey = `${recipient.toLowerCase()}:${category}:${entityRef}:${dayBucket}`;

        const existing = await this.connection
            .getRepository(ctx, CiNotificationLog)
            .findOne({ where: { dedupeKey } });

        if (existing) return false; // انرسل اليوم بالفعل

        await this.connection.getRepository(ctx, CiNotificationLog).save(
            new CiNotificationLog({
                dedupeKey,
                recipient,
                category,
                entityRef,
                channelId: ctx.channelId,
                sentAt: new Date(),
            }),
        );

        await this.emailService.send(recipient, category, entityRef);
        return true;
    }
}

مفتاح منع التكرار يشمل فترة اليوم. نفس الإشعار يقدر ينرسل بكرة لكن مو مرتين بنفس اليوم. القيد الفريد على dedupeKey يمنع حالات السباق بين البودات.

أنماط GraphQL Schema

وسّع واجهات Shop و Admin في Vendure بأنماط GraphQL القياسية:

export const reviewShopSchema = gql`
    type CiReview implements Node {
        id: ID!
        createdAt: DateTime!
        updatedAt: DateTime!
        rating: Int!
        body: String!
        status: String!
        customerName: String
    }

    type CiReviewList implements PaginatedList {
        items: [CiReview!]!
        totalItems: Int!
    }

    input SubmitReviewInput {
        productId: ID!
        rating: Int!
        body: String!
    }

    extend type Query {
        productReviews(productId: ID!, options: CiReviewListOptions): CiReviewList!
    }

    extend type Mutation {
        submitReview(input: SubmitReviewInput!): CiReview!
    }

    input CiReviewListOptions
`;

// خيارات الفرز والفلترة تُنشأ تلقائياً عبر ListQueryBuilder في Vendure

الـ ListQueryBuilder في Vendure يولّد تلقائياً خيارات الفرز والفلترة لأي كيان ينفّذ PaginatedList. تحصل على الفلترة بأي عمود والفرز مجاناً.

تطوير لوحات التحكم (React)

واجهة الإدارة المبنية على React في Vendure 3 توفر نقاط توسيع للوحات التحكم الخاصة بالإضافات:

// dashboard/routes.ts
import { DashboardRouteDefinition } from '@vendure/dashboard';
import { ReviewListPage } from './pages/ReviewListPage';
import { ReviewDetailPage } from './pages/ReviewDetailPage';

export const routes: DashboardRouteDefinition[] = [
    {
        path: 'reviews',
        component: ReviewListPage,
        navMenuItem: {
            id: 'ci-reviews',
            label: 'Reviews',
            icon: 'star',
            section: 'marketing',
        },
    },
    {
        path: 'reviews/:id',
        component: ReviewDetailPage,
    },
];

صفحات لوحة التحكم تستخدم TanStack Query لجلب البيانات ومكونات Vendure Dashboard SDK:

// dashboard/pages/ReviewListPage.tsx
import { Page, PageTitle, PageBlock } from '@vendure/dashboard';
import { useQuery } from '@tanstack/react-query';
import { graphql } from '../codegen';

const ALL_REVIEWS_QUERY = graphql(`
    query AllReviews($options: CiReviewListOptions) {
        allReviews(options: $options) {
            items { id rating body status createdAt }
            totalItems
        }
    }
`);

export function ReviewListPage() {
    const { data, isLoading } = useQuery({
        queryKey: ['reviews'],
        queryFn: () => adminClient.query(ALL_REVIEWS_QUERY),
    });

    return (
        <Page>
            <PageTitle>Reviews</PageTitle>
            <PageBlock>
                {/* عرض قائمة المراجعات مع أدوات الإشراف */}
            </PageBlock>
        </Page>
    );
}

لمزيد عن تقييم المنصة الشامل ومميزات واجهة React الإدارية، شوف دليل بنية Vendure للإنتاج.

الاختبار

اختبارات الوحدة

اختبر ميثودات الخدمة مع dependencies ممثلة:

describe('ReviewService', () => {
    it('should reject review with rating > 5', async () => {
        const service = createTestService();
        await expect(
            service.submit(mockCtx, { productId: '1', rating: 6, body: 'Great' }),
        ).rejects.toThrow('Rating must be between 1 and 5');
    });
});

اختبارات E2E

اختبر واجهة GraphQL كاملة مع سيرفر Vendure حقيقي:

import { createTestEnvironment } from '@vendure/testing';

describe('Review Shop API', () => {
    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);

    beforeAll(async () => {
        await server.init({ initialData, productsCsvPath: './test-data/products.csv' });
        await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
    });

    afterAll(() => server.destroy());

    it('should submit a review', async () => {
        const { submitReview } = await shopClient.query(SUBMIT_REVIEW, {
            input: { productId: '1', rating: 5, body: 'Excellent product' },
        });
        expect(submitReview.status).toBe('pending');
    });

    it('should not allow unauthenticated reviews', async () => {
        await shopClient.asAnonymousUser();
        const result = await shopClient.query(SUBMIT_REVIEW, {
            input: { productId: '1', rating: 5, body: 'Test' },
        });
        expect(result.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
    });

    it('should only show approved reviews publicly', async () => {
        // إرسال مراجعة (معلقة)
        await shopClient.asUserWithCredentials('sara.mustermann@beispiel.de', 'test');
        await shopClient.query(SUBMIT_REVIEW, {
            input: { productId: '1', rating: 4, body: 'Good' },
        });

        // الاستعلام العام ما لازم يعرض المراجعات المعلقة
        await shopClient.asAnonymousUser();
        const { productReviews } = await shopClient.query(PRODUCT_REVIEWS, { productId: '1' });
        expect(productReviews.items.every(r => r.status === 'approved')).toBe(true);
    });
});

الـ createTestEnvironment في Vendure يشغّل سيرفر حقيقي مع SQLite، يشغّل الـ migrations، يزرع بيانات اختبار، ويوفر عملاء مصادق عليهم لكلا الواجهتين. الاختبارات تشتغل على سطح GraphQL الفعلي.

لأنماط اختبار أوسع، شوف دليل هندسة البرمجيات.

الأخطاء الشائعة

  1. أسماء جداول مكتوبة يدوياً. استخدم الثوابت من shared/constants. مع 30+ كيان، النصوص المكتوبة يدوياً تضمن الأخطاء المطبعية وعدم الاتساق.

  2. خلط resolvers الـ Shop والـ Admin. كلاسات منفصلة. دائماً. كلاس resolver واحد فيه استعلامات عامة وإدارية هو حادث أمني ينتظر وقته.

  3. استيراد services عبر الموديولات. استخدم الـ EventBus. إذا الموديول A يستورد service من الموديول B، أنت سويت ارتباط ينكسر لما أي موديول يتغير.

  4. بدون عزل القنوات. كل استعلام لازم يفلتر بـ ctx.channelId. تنسى هذا وتسرّب بيانات بين واجهات المتاجر.

  5. setInterval للجدولة. استخدم JobQueue في Vendure. الـ setInterval يشتغل على كل بود، ما عنده استرجاع من الفشل، وما عنده observability.

  6. بدون منع تكرار الإشعارات. بدون مخزن منع التكرار، كل بود يستلم الحدث يرسل نفس الإيميل. العميل يحصل على 3 نسخ من كل إشعار.

  7. الاختبار على SQLite فقط. TypeORM يتصرف بشكل مختلف على SQLite و PostgreSQL. أنواع الأعمدة، معالجة JSON، وعزل المعاملات كلها تختلف. اختبر على PostgreSQL قبل الإطلاق.

  8. بدون idempotency على الـ mutations. إعادة المحاولة عبر الشبكة تنشئ قوائم أمنيات مكررة، مراجعات مكررة، معاملات ولاء مكررة. ابني الـ idempotency من اليوم الأول.

النقاط الرئيسية

  • اصطلاحات الكيانات تمنع الفوضى على نطاق واسع. ثوابت أسماء الجداول، بادئة Ci، كلاس VendureEntity الأساسي، عزل القنوات، والحذف الناعم. هذي الأنماط مملة لكن ضرورية لما يكون عندك 30+ كيان.

  • فصل الـ Resolvers حدود أمنية. الـ Shop resolvers تعرض بيانات عامة/للمالك. الـ Admin resolvers تعرض عمليات الإدارة. لا تخلطهم أبداً.

  • الـ EventBus هو وسيلة التواصل الوحيدة بين الموديولات. بدون استيراد services عبر الموديولات. الأحداث تحافظ على استقلالية الموديولات وقابلية إزالتها.

  • التنسيق بين البودات المتعددة مو تلقائي. انتخاب القائد للمجدولات، منع التكرار للإشعارات، الـ idempotency للـ mutations. خطط لبودات متعددة من اليوم الأول.

  • اختبر مع سيرفر Vendure حقيقي. الـ createTestEnvironment يعطيك سيرفر حقيقي مع GraphQL حقيقي. اختبر الصلاحيات، عزل البيانات، وقواعد العمل على سطح الـ API الفعلي.

نبني إضافات Vendure المؤسسية كجزء من ممارستنا في التجارة الإلكترونية. إذا تبني إضافة Vendure معقدة أو تحتاج مساعدة في بنية الإضافات، تواصل مع فريقنا أو اطلب عرض سعر. شوف أيضاً دليل Vendure للتجارة بدون واجهة لمزيد عن شغلنا مع Vendure.

المواضيع المغطاة

تطوير إضافات Vendureإضافة Vendure مخصصةVendure NestJSكيانات VendureVendure EventBusVendure متعدد البوداتاختبار Vendureإضافات Vendure للمؤسسات

جاهز لبناء أنظمة ذكاء اصطناعي جاهزة للإنتاج؟

فريقنا متخصص في بناء أنظمة ذكاء اصطناعي جاهزة للإنتاج. خلينا نحكي كيف نقدر نساعد.

ابدأ محادثة