الخميس، 5 نوفمبر 2020

Asp code How to send push notifications to a browser in ASP.NET Core كود البرمجي كيف ترسل إشعارات تنبيه لموقعك باستخدم اي اس بي

كود كيف ترسل إشعارات تنبيه لموقعك باستخدم asp. NET

 Asp code How to send push notifications to a browser in ASP.NET Core كود البرمجي كيف ترسل إشعارات تنبيه لموقعك باستخدم اي اس بي

Asp code How to send push notifications to a browser in ASP.NET Core كود البرمجي كيف ترسل إشعارات تنبيه لموقعك باستخدم اي اس بي


Asp code How to send push notifications to a browser in ASP.NET Core كود البرمجي كيف ترسل إشعارات تنبيه لموقعك باستخدم اي اس بي ASP.NET 

ماهي تطبيقات الويب التقدمية ودورها في رسائل إشعارات التنبيه 

تمكّن تطبيقات الويب التقدمية (PWAs) موقع الويب من إجراء الكثير من التفاعلات التي تشبه التطبيقات.  من بين هذه الإخطارات.  تتيح لك هذه الوظيفة إنشاء إشعارات أصلية للعديد من الأجهزة المختلفة واستدعاء هذه الإشعارات حتى عندما لا يكون المتصفح نشطًا.  تستخدم الكثير من مواقع الويب مثل تطبيق Twitter على الويب هذه الوظيفة بقصدها الأصلي ، ولكن هناك سبب أيضًا المواقع التي تستخدمها بشكل ضار.  في هذه المقالة ، سنوضح كيف يمكنك الاشتراك في دفع الإخطارات باستخدام ASP.NET Core وكيف يمكنك إرسال إعلامات الدفع من .NET أيضًا.

إعداد الحد الأدنى من PWA من أجل رسائل إشعارات التنبيه web push notification 

 قبل أن نبدأ ، نحتاج إلى إعداد الحد الأدنى من عامل الخدمة والبيان لتلبية الحد الأدنى من متطلبات PWA (تطبيق الويب التقدمي) نظرًا لأننا نحتاج إلى PWA لاستخدام واجهة برمجة تطبيقات الإعلام.
 نقوم أولاً بإنشاء البيان عن طريق إنشاء ملف JSON يسمى manifest.json والذي سنضعه في مجلد wwwroot.

{
    "name": "PWA Push Notification",
    "short_name": "Push",
    "icons": [
        {
            "src": "/images/icon-48x48.png",
            "sizes": "48x48",
            "type": "image/png"
        },
        {
            "src": "/images/icon-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        },
        {
            "src": "/images/icon-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        }
    ],
    "start_url": "/",
    "display": "standalone",
    "background_color": "#959595",
    "theme_color": "#FFFFFF"
}


نحدد هنا اسم التطبيق ، والاسم الذي سيتم عرضه في التطبيق ، إذا تم تثبيته على جهاز ، ورموز التطبيق بأحجام متنوعة ، وعنوان URL للصفحة الأولى للموقع إذا كان من الممكن تثبيت موقع الويب على أنه  التطبيق المستقل ، وفي نهاية الموضوع الألوان.  نضيف إشارة إلى البيان في رأس ملف التخطيط الخاص بنا والذي يتم استخدامه في جميع طرق العرض لدينا <link rel = "manifest" href = "/ manifest.json">.


 نحتاج بعد ذلك إلى إضافة عامل خدمة.  نحن نجعل الحد الأدنى من عامل الخدمة من خلال إنشاء ملف JavaScript ServiceWorker.js ، أيضًا في مجلد wwwroot بالمحتوى التالي..

self.addEventListener('fetch',

نشير إلى عامل الخدمة عن طريق إدخال البرنامج النصي التالي إما في عنصر علامة البرنامج النصي في ملف التخطيط الخاص بك أو مباشرة في ملف js الرئيسي إذا كان لديك مثل هذا..

if ('serviceWorker' in navigator) {
    window.addEventListener("load", () => {
        navigator.serviceWorker.register("/ServiceWorker.js");
    });
}


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

الاشتراك وتدفق الدفع

 هناك 3 جهات فاعلة أساسية في تدفق الاشتراك / الدفع.  صفحة الويب (بما في ذلك عامل الخدمة) وخدمة الدفع والخادم.  يستخدم هذا التدفق زوجًا من المفاتيح ، خاص وعام من معيار يسمى VAPID (تعريف خادم التطبيق الطوعي).  هناك طرق أخرى لتأمين الاتصال مثل  FCM (Firebase Cloud Messaging) ، لكن VAPID لا يتطلب منك استخدام أي نظام أساسي معين ، لذلك نستخدم ذلك.  للبدء ، نحتاج إلى إنشاء زوج مفاتيح وحفظه في إعدادات التطبيق لدينا.  إنه معيار مفتوح يستخدم تشفير المنحنى الإهليلجي وهناك الكثير من التطبيقات التي تولد هذه الأزواج مثل مكتبة Python py-vapid أو دفع الويب لوحدة العقدة أو الأدوات عبر الإنترنت .reactpwa.com/vapid.  سننشئ زوجًا ونضيفه إلى appsettings.json..

{
    "VAPID": {
        "subject": "mailto:mail@example.com",
        "publicKey": "BPTnFPVQFAhlIFSWqAjFPtQeEz ... udykg",
        "privateKey":  "4jO3OrjQY2ilE ... yuhZWho47Q"
    }
}

لا يتم استخدام المفتاح الخاص مطلقًا في أماكن أخرى غير الخادم ويجب الاحتفاظ به سراً.  يتم توزيع المفتاح العام على صفحة الويب وخدمة الدفع للتحقق من صحة الرسائل.  سنرسل المفتاح العمومي في الجزء التالي وستقوم صفحة الويب بتوزيعه على خدمة الدفع عند الاشتراك.  يتم استخدام المفتاح العام عند الاشتراك بحيث يمكن مصادقة رسائل الدفع من الخادم لأنها موقعة باستخدام المفتاح الخاص.  عندما تشترك صفحة الويب في خدمة الدفع ، فإنها تستعيد نقطة نهاية يستخدمها الخادم لإرسال رسالة الدفع الخاصة به ، وسيتم استخدام المتغيرين p256dh والمصادقة لتحديد الاشتراك نظرًا لاستخدامهما عند إنشاء الرسالة.

اشترك في دفع الإخطارات رسائل إشعارات التنبيه عبر الويب web push notification 

 نريد الآن تقديم عرض حيث ننتقل من خلال بضع خطوات للاشتراك في Push Notifications.  نقسم هذا إلى 3 حالات.  أولاً ، لدينا جزء سنعرضه إذا لم يقرر المستخدم ما إذا كان سيسمح بالإشعارات.  ثم لدينا جزء نعرضه إما إذا كان المتصفح لا يدعم الإشعارات أو إذا قام المستخدم بحظر الإشعارات Push Notifications .  أخيرًا ، لدينا نموذج سنستخدمه لإرسال معرف للمستخدم ونقطة النهاية ومتغيري تعريف الاشتراك p256dh والمصادقة..

<h1>Subscribe to Push Notifications</h1>
<div id="GiveAccess" style="display:none;">
    Give access to making notifications:
    <button id="PromptForAccessBtn">Prompt</button>
</div>
<div id="NoSupport" style="display:none;">
    Your browser does not support Push Notifications or you have blocked notifications
</div>
<form asp-action="Index" id="form" style="display:none;">
    <label for="client">Your name: </label>
    <input id="client" name="client" /><br />

    <input id="endpoint" name="endpoint" hidden />
    <input id="p256dh" name="p256dh" hidden />
    <input id="auth" name="auth" hidden />

    <button type="submit">Subscribe</button>
</form>
سنكتب القليل من JavaScript للتحكم في وقت عرض هذه الأجزاء المختلفة والحصول على الحقول المخفية للنموذج..
@section Scripts {
    <script>
        if ('serviceWorker' in navigator) {
            window.addEventListener("load", () => {
                navigator.serviceWorker.register("/ServiceWorker.js")
                    .then((reg) => {
                        if (Notification.permission === "granted") {
                            $("#form").show();
                            getSubscription(reg);
                        } else if (Notification.permission === "blocked") {
                            $("#NoSupport").show();
                        } else {
                            $("#GiveAccess").show();
                            $("#PromptForAccessBtn").click(() => requestNotificationAccess(reg));
                        }
                    });
            });
        } else {
            $("#NoSupport").show();
        }

        function requestNotificationAccess(reg) {
            Notification.requestPermission(function (status) {
                $("#GiveAccess").hide();
                if (status == "granted") {
                    $("#form").show();
                    getSubscription(reg);
                } else {
                    $("#NoSupport").show();
                }
            });
        }

        function getSubscription(reg) {
            reg.pushManager.getSubscription().then(function (sub) {
                if (sub === null) {
                    reg.pushManager.subscribe({
                        userVisibleOnly: true,
                        applicationServerKey: "@ViewBag.applicationServerKey"
                    }).then(function (sub) {
                        fillSubscribeFields(sub);
                    }).catch(function (e) {
                        console.error("Unable to subscribe to push", e);
                    });
                } else {
                    fillSubscribeFields(sub);
                }
            });
        }

        function fillSubscribeFields(sub) {
            $("#endpoint").val(sub.endpoint);
            $("#p256dh").val(arrayBufferToBase64(sub.getKey("p256dh")));
            $("#auth").val(arrayBufferToBase64(sub.getKey("auth")));
        }

        function arrayBufferToBase64(buffer) {
            var binary = '';
            var bytes = new Uint8Array(buffer);
            var len = bytes.byteLength;
            for (var i = 0; i < len; i++) {
                binary += String.fromCharCode(bytes[i]);
            }
            return window.btoa(binary);
        }
    </script>
}

هناك الكثير من الوظائف في كتلة البرنامج النصي هذا.  دعنا نمر عليهم واحدًا تلو الآخر.

 أولاً ، نقوم بتسجيل عامل الخدمة لدينا مرة أخرى.  نقوم بذلك معًا للتحقق مما إذا كان يمكن تسجيله.  إذا تعذر ذلك ، فسنعرض جزء واجهة المستخدم المناسب.  نقوم بذلك أيضًا للحصول على كائن التسجيل لعامل الخدمة.  نتحقق بعد ذلك مما إذا كان المستخدم قد منح حق الوصول لإجراء الإشعارات.  إذا منحوا حق الوصول ، فإننا نعرض النموذج وندعو وظيفة getSubscription التي تملأ الحقول المخفية في النموذج أيضًا.  إذا قاموا بحظر الإشعارات web Notification ، فسنعرض جزء عدم دعم واجهة المستخدم.  السيناريو الثالث هو أن المستخدم لم يقرر بعد في هذه الحالة سنعرض جزء واجهة المستخدم لطلب الوصول لتقديم الإشعارات web Notification .  نشترك أيضًا في حدث النقر الخاص بـ PromptForAccessBtn وفي هذه الحالة سنقوم باستدعاء وظيفة requestNotificationAccess.  ما يلي هو كيف يبدو عند استدعاء المطالبة في Chrome على سطح المكتب.

.

Your desktop browser asking for permission
متصفح سطح المكتب الخاص بك يطلب الإذن
 تطلب وظيفة requestNotificationAccess ببساطة الإذن بإجراء الإخطارات web Notification وتتفاعل مع نتيجة المطالبة.  إذا منحوا الإذن ، فسنعرض النموذج وندعو وظيفة getSubscription مرة أخرى ، وإذا قاموا بحظرهم ، فإننا نعرض جزء no-support UI.

 في وظيفة getSubscription نريد الحصول على كائن اشتراك من عامل الخدمة.  نحاول أولاً الحصول عليه عن طريق استدعاء reg.pushManager.getSubscription.  تقوم هذه الطريقة بإرجاع كائن اشتراك إذا كان موجودًا بالفعل.  إذا أعاد الاشتراك ، فنحن نحلل ذلك إلى وظيفة fillSubscribeFields.  إذا لم يكن هناك اشتراك ، فسنحاول إنشاء اشتراك عن طريق استدعاء reg.pushManager.subscribe.  نقوم بتحليل المفتاح العام لهذه الوظيفة باستخدام razor ViewBag.  لتحليل هذا من وحدة التحكم ، نضيف ببساطة ما يلي إلى وحدة التحكم والإجراء الخاص بنا لهذا العرض.  في المثال الصغير الخاص بي ، أنا فقط استخدم HomeController لأن هذا مثال صغير..
public class HomeController : Controller
{
    private readonly IConfiguration configuration;

    public HomeController(IConfiguration configuration)
    {
        this.configuration = configuration;
    }
    public IActionResult Index()
    {
        ViewBag.applicationServerKey = configuration["VAPID:publicKey"];
        return View();
    }
}

لاحظ أننا نقوم بحقن تكوين IConfiguration في وحدة التحكم ، لكننا لم نقم بإضافتها في ملف startup.cs.  يمكن القيام بذلك لأن تكوين IConfiguration يتم حقن التبعية تلقائيًا في ASP.NET.  إذا أعاد reg.pushManager.subscribe اشتراكًا ، فإننا نستخدمه ببساطة لاستدعاء fillSubscribeFields الآن.  إذا حدث خطأ ما ولم نحصل على اشتراك ، فلن يكون لدينا خيار آخر سوى تسجيل خطأ.  قد يحدث هذا الخطأ إذا لم يتم إنشاء المفتاح العام بشكل صحيح.

 تملأ وظيفة fillSubscribeFields المدخلات المخفية في النموذج.  نقطة النهاية متاحة كحقل في كائن الاشتراك.  تتوفر متغيرات p256dh و auth من خلال دالة getKey على الكائن ولكن يتم إرجاعها كمخازن مؤقتة للصفيف.  لذلك نستخدم وظيفة قمنا بإنشائها تسمى arrayBufferToBase64 والتي تحول Array Buffers إلى سلسلة أساسية 64.

حفظ الاشتراك رسائل إشعارات التنبيه عبر الويب الإخطارات web Notification 

 لقد صنعنا نموذجًا ينشر معلومات الاشتراك.  نحتاج إلى تحديد الإجراء الذي يتلقى هذا المنشور وحفظه بطريقة ما.  نقوم بإنشاء إجراء جديد في وحدة التحكم الخاصة بنا..
[HttpPost]
public IActionResult Index(string client, string endpoint, string p256dh, string auth)
{
    if (client == null)
    {
        return BadRequest("No Client Name parsed.");
    }
    if (PersistentStorage.GetClientNames().Contains(client))
    {
        return BadRequest("Client Name already used.");
    }
    var subscription = new PushSubscription(endpoint, p256dh, auth);
    PersistentStorage.SaveSubscription(client, subscription);
    return View("Notify", PersistentStorage.GetClientNames());
}
نحدد أن هذا الإجراء يتعامل مع منشور عن طريق تعيين السمة [HttpPost].  يأخذ الإجراء نفس الحجج التي قمنا بتحليلها من النموذج.  الهدف من هذا الإجراء هو حفظ المعلمات المحددة بحيث يمكن استخدامها لاحقًا.  نستخدم عنصرًا نائبًا لأي نوع من التخزين الدائم نطلق عليه اسم PersistentStorage.  قد يكون هذا أي نوع من قواعد البيانات أو حتى تخزين Azure Blob.  في الجسم ، نتحقق أولاً مما إذا كان اسم العميل المقدم فارغًا وفي هذه الحالة نعيد BadRequest.  من المحتمل أن يتم التعامل مع هذا مع شاشة خطأ من نوع ما إذا كان هذا سيناريو حقيقي.  نتحقق أيضًا مما إذا كان اسم العميل مستخدمًا بالفعل وفي هذه الحالة نقوم أيضًا بإرجاع BadRequest.  ثم إذا كانت الأمور تبدو جيدة ، فنحن نصنع عنصر PushSubscription جديدًا يأخذ كل تفاصيل الاشتراك كوسيطات.  فئة PushSubscription هي من الحزمة WebPush-NetCore التي تتعامل مع كيفية إرسال إشعار لنا.  ثم نقوم بحفظ عنصر PushSubscription في تخزيننا الدائم.  إذا كان سيتم حفظ هذا في قاعدة بيانات ، فمن المحتمل أن تكون هناك حاجة إلى بعض عمليات التسلسل / إلغاء التسلسل لهذا أو كبديل فقط احفظ كل حقل على حدة.  في النهاية ، نعيد عرضًا جديدًا يمكننا من خلاله تقديم إشعار الدفع.  سنسمح لأي شخص بالوصول إلى هذه الصفحة لأغراض العرض التوضيحي ، ولكن من المحتمل أن تكون هذه الصفحة متاحة فقط للمسؤولين في معظم الحالات.  سنقوم بهذا الإجراء ونعرضه بعد القسم التالي.

المستمعين للحدث في عامل الخدمة

 الآن ، لقد قمنا بذلك بحيث يكون لدى الخادم نقطة النهاية والمفاتيح التي يحتاجها لتقديم إشعار دفع.  ولكن قبل دفع الإشعار ، نحتاج إلى جعل المستمعين يتعاملون مع الإشعار.  يتم تعريف هذه في عامل الخدمة لدينا.  أولاً ، نجعل المستمع لرسالة دفع جديدة عن طريق إضافة ما يلي إلى ServiceWorker.js..
self.addEventListener('push', function (e) {
    var body;

    if (e.data) {
        body = e.data.text();
    } else {
        body = "Standard Message";
    }

    var options = {
        body: body,
        icon: "images/icon-512x512.png",
        vibrate: [100, 50, 100],
        data: {
            dateOfArrival: Date.now()
        },
        actions: [
            {
                action: "explore", title: "Go interact with this!",
                icon: "images/checkmark.png"
            },
            {
                action: "close", title: "Ignore",
                icon: "images/red_x.png"
            },
        ]
    };
    e.waitUntil(
        self.registration.showNotification("Push Notification", options)
    );
});
يتلقى مستمع الحدث حزمة تحتوي على بعض البيانات.  البيانات في حالتنا هي مجرد نص عادي نسترده عن طريق استدعاء .text ().  يمكن أن يكون كائن JSON وفي هذه الحالة نسمي .json () بدلاً من ذلك.  إذا كانت رسالة فارغة ، فنحن فقط نتخيل بعض الرسائل القياسية.  ثم يأتي الجزء الأساسي من هذه الوظيفة ، كائن الخيارات.  يحدد هذا النص الذي سيتم عرضه في الإشعار ، والرمز الذي سيتم عرضه ، وكيف سيهتز الإشعار (متاح فقط على الهواتف) ، والبيانات الإضافية التي قد تستخدمها بعض أنظمة التشغيل عند إظهار الإشعار.  يمكننا أيضًا تحديد إجراءات مختلفة في كائن الخيارات.  لقد اتخذنا عملين.  واحد يشير إلى أن شيئًا ما سيحدث والآخر يغلق الإشعار.  لقد حددنا معرفًا ونصًا للإجراء وأيقونة للإجراء لكل إجراء.  الأيقونة اختيارية نحن نستخدم كائن الخيارات كوسيط لـ showNotification الذي يجعل الإخطار الفعلي.  يتم أيضًا تحديد عنوان للإشعار في هذه الوظيفة وسيكون عادةً هو نفسه اسم تطبيق الويب الخاص بك حيث سيظهر مع شعارك أعلى الإشعار في معظم الأنظمة الأساسية.  لقد حددنا إجراءين ، لكننا لم نحدد بعد ما يحدث عند استخدامهما.  لهذا ، نحدد مستمع حدث آخر في عامل الخدمة..
self.addEventListener('notificationclick', function (e) {
    var notification = e.notification;
    var action = e.action;

    if (action === 'close') {
        notification.close();
    } else {
        // Some actions
        clients.openWindow('http://www.example.com');
        notification.close();
    }
});

يحصل هذا المستمع على حزمة تحتوي على مرجع للإعلام وحقل إجراء يحتوي على المعرف للإجراء الذي تم تحديده.  ثم نغلق الإشعار إذا ضغطوا على إجراء الإغلاق وفعلوا ما نريد القيام به آخر.  في هذه الحالة ، نوجه المستخدم إلى موقع ويب ، ولكن يمكننا أيضًا تشغيل أي كود JavaScript آخر.  كان بإمكاننا استدعاء إجراء في وحدة التحكم الخاصة بنا وتسجيل الإجراء إذا كنا مهتمين بمعرفة عدد المستخدمين الذين نقروا / أغلقوا الإشعارات web Notification .


دفع الإخطار pushing notifications 

 الآن ، وصلنا إلى الجزء الأخير: إرسال إشعار الدفع بالفعل.  أولاً ، نقدم عرضًا جديدًا نختار فيه عميلًا نرسل رسالة إليه.  هذا هو عرض الإخطار web Notification الذي أشرنا إليه سابقًا..
@model List<string>

<h1>Send Push Notifications</h1>
@if (Model.Count() == 0)
{
    <p>There are no active subscribers</p>
}
else
{
    <form asp-action="Notify">
        <label for="message">Message: </label>
        <input id="message" name="message" /><br />
        @foreach (string client in Model)
        {
            <input type="radio" id="@(client)_identifier" name="client" value="@client">
            <label for="@(client)_identifier">@client</label><br>
        }
        <button type="submit">Push</button>
    </form>
}

الصورة التالية توضح نتيجة هذه الصورة...
Push notification view

عرض رسائل إشعارات التنبيه pushing Notification 

 يستخدم العرض قائمة السلاسل كنموذج لها.  كل سلسلة سوف تمثل العميل.  نحن نحلل هذه القائمة إلى وجهة نظرنا من عملنا..
public IActionResult Notify()
{
    return View(PersistentStorage.GetClientNames());
}
نتحقق أولاً من عدم وجود سلاسل في القائمة وعرض رسالة مناسبة إذا كان الأمر كذلك.  إذا كانت هناك سلاسل في القائمة ، فنحن على استعداد للذهاب.  نحن نصنع نموذجًا سينشر على إجراء نسميه أيضًا Notify.  في النموذج ، نقوم أولاً بإدخال إدخال للرسالة التي سنرسلها.  بعد ذلك ، نقوم بعمل زر اختيار لكل عميل في القائمة بالتسميات المقابلة.  الآن نحن فقط بحاجة إلى اتخاذ إجراء للمنصب..
[HttpPost]
public IActionResult Notify(string message, string client)
{
    if (client == null)
    {
        return BadRequest("No Client Name parsed.");
    }
    PushSubscription subscription = PersistentStorage.GetSubscription(client);
    if (subscription == null)
    {
        return BadRequest("Client was not found");
    }

    var subject = configuration["VAPID:subject"];
    var publicKey = configuration["VAPID:publicKey"];
    var privateKey = configuration["VAPID:privateKey"];

    var vapidDetails = new VapidDetails(subject, publicKey, privateKey);

    var webPushClient = new WebPushClient();
    try
    {
        webPushClient.SendNotification(subscription, message, vapidDetails);
    }
    catch (Exception exception)
    {
        // Log error
    }

    return View(PersistentStorage.GetClientNames());
}
في الإجراء ، نتحقق أولاً مما إذا تم اختيار العميل وما إذا كان العميل موجودًا.  ثم نستخرج المفتاح العام والمفتاح الخاص والبريد الإلكتروني من تكوين الرمز الذي تم حقنه مسبقًا.  يتم تمرير هذه إلى مُنشئ VapidDetails الجديد والذي يكون أيضًا من حزمة WebPush-NetCore.  ثم يتم إنشاء WebPushClient والذي يمكنه إرسال إشعار الدفع.  يتم استدعاء طريقة SendNotification في كتلة try-catch.  يتم ذلك بسبب حدوث خطأين مختلفين ، على سبيل المثال  إذا ألغى المستخدم اشتراكه من الإشعار أو إذا تم تحديث عامل الخدمة دون إعادة الاشتراك.  في النهاية ، يتم إرجاع طريقة العرض Notify مرة أخرى بحيث يمكن دفع إشعار pushing notifications جديد.  .

خاتمة
 الآن قمنا بإعداد الحد الأدنى من عامل الخدمة والبيان.  لقد قمنا برؤية تمكن المستخدم من السماح بالإشعارات والاشتراك في خدمة الإخطارات.  لقد جعلنا مستمعين للأحداث في عامل الخدمة الذي يتعامل مع الإشعارات عند دفعها.  في النهاية ، دفعنا إشعارًا من الخادم باستخدام حزمة WebPush-NetCore.  هناك العديد من الأماكن حيث يمكن تحسين هذا المثال فيما يتعلق بالأمان والجماليات ، لكنه لا يزال يلخص الوظيفة الرئيسية لإعلامات الدفع وكيف يعمل مع ASP.NET Core.  إذا كانت لديك ملاحظات أو أسئلة ، فلا تتردد في التواصل ومشاركة أفكارك.




التسميات: