build: Tạo các tệp đầu ra debug ban đầu cho dự án AppClientBase trên iOS và Mac Catalyst.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -83,3 +83,7 @@ infra/traefik/certs/*
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# MAUI
|
||||
obj
|
||||
bin
|
||||
14
apps/app-client-base/App.xaml
Normal file
14
apps/app-client-base/App.xaml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:AppClientBase"
|
||||
x:Class="AppClientBase.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
26
apps/app-client-base/App.xaml.cs
Normal file
26
apps/app-client-base/App.xaml.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace AppClientBase;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Main application class.
|
||||
/// VI: Lớp ứng dụng chính.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Constructor - initializes XAML components.
|
||||
/// VI: Constructor - khởi tạo các thành phần XAML.
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creates the main window with AppShell.
|
||||
/// VI: Tạo cửa sổ chính với AppShell.
|
||||
/// </summary>
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
return new Window(new AppShell());
|
||||
}
|
||||
}
|
||||
66
apps/app-client-base/AppClientBase.csproj
Normal file
66
apps/app-client-base/AppClientBase.csproj
Normal file
@@ -0,0 +1,66 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
|
||||
<!-- Note for MacCatalyst:
|
||||
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
|
||||
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
|
||||
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
|
||||
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
|
||||
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>AppClientBase</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>AppClientBase</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.companyname.appclientbase</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
|
||||
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Maui" Version="9.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
15
apps/app-client-base/AppShell.xaml
Normal file
15
apps/app-client-base/AppShell.xaml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Shell
|
||||
x:Class="AppClientBase.AppShell"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:AppClientBase"
|
||||
Shell.FlyoutBehavior="Disabled"
|
||||
Title="AppClientBase">
|
||||
|
||||
<ShellContent
|
||||
Title="Home"
|
||||
ContentTemplate="{DataTemplate local:MainPage}"
|
||||
Route="MainPage" />
|
||||
|
||||
</Shell>
|
||||
9
apps/app-client-base/AppShell.xaml.cs
Normal file
9
apps/app-client-base/AppShell.xaml.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace AppClientBase;
|
||||
|
||||
public partial class AppShell : Shell
|
||||
{
|
||||
public AppShell()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
38
apps/app-client-base/MainPage.xaml
Normal file
38
apps/app-client-base/MainPage.xaml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="AppClientBase.MainPage">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout
|
||||
Padding="30,0"
|
||||
Spacing="25">
|
||||
|
||||
<Image
|
||||
Source="dotnet_bot.png"
|
||||
HeightRequest="185"
|
||||
Aspect="AspectFit"
|
||||
SemanticProperties.Description="dot net bot waving" />
|
||||
|
||||
<Label
|
||||
Text="Hello, World!"
|
||||
Style="{StaticResource Headline}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
|
||||
<Label
|
||||
Text="Welcome to .NET Multi-platform App UI"
|
||||
Style="{StaticResource SubHeadline}"
|
||||
SemanticProperties.HeadingLevel="Level2"
|
||||
SemanticProperties.Description="Welcome to dot net Multi platform App U I" />
|
||||
|
||||
<Button
|
||||
x:Name="CounterBtn"
|
||||
Text="Click me"
|
||||
SemanticProperties.Hint="Counts the number of times you click"
|
||||
Clicked="OnCounterClicked"
|
||||
HorizontalOptions="Fill" />
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</ContentPage>
|
||||
23
apps/app-client-base/MainPage.xaml.cs
Normal file
23
apps/app-client-base/MainPage.xaml.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace AppClientBase;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
int count = 0;
|
||||
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnCounterClicked(object sender, EventArgs e)
|
||||
{
|
||||
count++;
|
||||
|
||||
if (count == 1)
|
||||
CounterBtn.Text = $"Clicked {count} time";
|
||||
else
|
||||
CounterBtn.Text = $"Clicked {count} times";
|
||||
|
||||
SemanticScreenReader.Announce(CounterBtn.Text);
|
||||
}
|
||||
}
|
||||
31
apps/app-client-base/MauiProgram.cs
Normal file
31
apps/app-client-base/MauiProgram.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using CommunityToolkit.Maui;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Main entry point for MAUI application configuration.
|
||||
/// VI: Điểm khởi đầu chính để cấu hình ứng dụng MAUI.
|
||||
/// </summary>
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.UseMauiCommunityToolkit()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
||||
});
|
||||
|
||||
#if DEBUG
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
10
apps/app-client-base/Platforms/Android/MainActivity.cs
Normal file
10
apps/app-client-base/Platforms/Android/MainActivity.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
}
|
||||
15
apps/app-client-base/Platforms/Android/MainApplication.cs
Normal file
15
apps/app-client-base/Platforms/Android/MainApplication.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Android.App;
|
||||
using Android.Runtime;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
[Application]
|
||||
public class MainApplication : MauiApplication
|
||||
{
|
||||
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
|
||||
: base(handle, ownership)
|
||||
{
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#512BD4</color>
|
||||
<color name="colorPrimaryDark">#2B0B98</color>
|
||||
<color name="colorAccent">#2B0B98</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
|
||||
<dict>
|
||||
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
40
apps/app-client-base/Platforms/MacCatalyst/Info.plist
Normal file
40
apps/app-client-base/Platforms/MacCatalyst/Info.plist
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The Mac App Store requires you specify if the app uses encryption. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
|
||||
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
|
||||
<!-- Please indicate <true/> or <false/> here. -->
|
||||
|
||||
<!-- Specify the category for your app here. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
|
||||
<!-- <key>LSApplicationCategoryType</key> -->
|
||||
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.lifestyle</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
apps/app-client-base/Platforms/MacCatalyst/Program.cs
Normal file
15
apps/app-client-base/Platforms/MacCatalyst/Program.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
8
apps/app-client-base/Platforms/Windows/App.xaml
Normal file
8
apps/app-client-base/Platforms/Windows/App.xaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<maui:MauiWinUIApplication
|
||||
x:Class="AppClientBase.WinUI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:maui="using:Microsoft.Maui"
|
||||
xmlns:local="using:AppClientBase.WinUI">
|
||||
|
||||
</maui:MauiWinUIApplication>
|
||||
24
apps/app-client-base/Platforms/Windows/App.xaml.cs
Normal file
24
apps/app-client-base/Platforms/Windows/App.xaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace AppClientBase.WinUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : MauiWinUIApplication
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
||||
46
apps/app-client-base/Platforms/Windows/Package.appxmanifest
Normal file
46
apps/app-client-base/Platforms/Windows/Package.appxmanifest
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="0999736D-C9A3-48A0-921A-5A49E7E146D2" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>$placeholder$</DisplayName>
|
||||
<PublisherDisplayName>User Name</PublisherDisplayName>
|
||||
<Logo>$placeholder$.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="$placeholder$"
|
||||
Description="$placeholder$"
|
||||
Square150x150Logo="$placeholder$.png"
|
||||
Square44x44Logo="$placeholder$.png"
|
||||
BackgroundColor="transparent">
|
||||
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
|
||||
<uap:SplashScreen Image="$placeholder$.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
|
||||
</Package>
|
||||
17
apps/app-client-base/Platforms/Windows/app.manifest
Normal file
17
apps/app-client-base/Platforms/Windows/app.manifest
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="AppClientBase.WinUI.app"/>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- The combination of below two tags have the following effect:
|
||||
1) Per-Monitor for >= Windows 10 Anniversary Update
|
||||
2) System < Windows 10 Anniversary Update
|
||||
-->
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
9
apps/app-client-base/Platforms/iOS/AppDelegate.cs
Normal file
9
apps/app-client-base/Platforms/iOS/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
32
apps/app-client-base/Platforms/iOS/Info.plist
Normal file
32
apps/app-client-base/Platforms/iOS/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
apps/app-client-base/Platforms/iOS/Program.cs
Normal file
15
apps/app-client-base/Platforms/iOS/Program.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace AppClientBase;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
|
||||
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
|
||||
|
||||
You are responsible for adding extra entries as needed for your application.
|
||||
|
||||
More information: https://aka.ms/maui-privacy-manifest
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!--
|
||||
The entry below is only needed when you're using the Preferences API in your app.
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict> -->
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
8
apps/app-client-base/Properties/launchSettings.json
Normal file
8
apps/app-client-base/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
||||
87
apps/app-client-base/README.md
Normal file
87
apps/app-client-base/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# AppClientBase - .NET MAUI Enterprise Base App
|
||||
|
||||
Base frontend application cho iOS và Android được xây dựng với .NET MAUI theo chuẩn Enterprise.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **.NET 10 MAUI** - Cross-platform framework
|
||||
- **CommunityToolkit.Mvvm 8.4.0** - MVVM implementation
|
||||
- **CommunityToolkit.Maui 9.1.0** - MAUI extensions
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
AppClientBase/
|
||||
├── MauiProgram.cs # DI & configuration
|
||||
├── App.xaml # Application resources
|
||||
├── AppShell.xaml # Navigation shell
|
||||
├── ViewModels/ # MVVM ViewModels
|
||||
│ ├── BaseViewModel.cs # Base với IsBusy, Title
|
||||
│ └── MainViewModel.cs # Main page ViewModel
|
||||
├── Views/ # XAML Pages
|
||||
│ └── MainPage.xaml # Main page với compiled bindings
|
||||
├── Services/ # Business services
|
||||
│ ├── INavigationService.cs # Navigation interface
|
||||
│ ├── NavigationService.cs # Shell navigation
|
||||
│ ├── ISettingsService.cs # Settings interface
|
||||
│ └── SettingsService.cs # MAUI Preferences wrapper
|
||||
├── Models/ # Data models
|
||||
├── Resources/
|
||||
│ └── Styles/
|
||||
│ ├── Colors.xaml # Brand, Semantic, Surface colors
|
||||
│ ├── Typography.xaml # Font families, sizes, text styles
|
||||
│ ├── Theme.xaml # Button, Entry, Card styles
|
||||
│ └── Styles.xaml # Template implicit styles
|
||||
└── Platforms/ # Platform-specific code
|
||||
├── Android/
|
||||
└── iOS/
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### MVVM + Dependency Injection
|
||||
- Constructor injection qua DI container
|
||||
- Singleton cho stateful services (NavigationService, SettingsService)
|
||||
- Transient cho ViewModels và Views
|
||||
|
||||
### Branding System
|
||||
- **Colors.xaml**: Light/Dark colors riêng biệt
|
||||
- **Typography.xaml**: Font scale (Caption → Display)
|
||||
- **Theme.xaml**: ButtonPrimary, ButtonSecondary, CardDefault styles
|
||||
- AppThemeBinding cho Light/Dark mode support
|
||||
|
||||
### Compiled Bindings
|
||||
- `x:DataType` cho performance tối ưu
|
||||
- Compile-time binding validation
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Navigate to project
|
||||
cd apps/app-client-base
|
||||
|
||||
# Restore packages
|
||||
dotnet restore
|
||||
|
||||
# Build for macCatalyst
|
||||
dotnet build -c Debug -f net10.0-maccatalyst
|
||||
|
||||
# Build for iOS (requires Xcode with iOS simulator)
|
||||
dotnet build -c Debug -f net10.0-ios
|
||||
|
||||
# Build for Android (requires Android SDK)
|
||||
dotnet build -c Debug -f net10.0-android
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- .NET 10 SDK
|
||||
- MAUI workload (`dotnet workload install maui`)
|
||||
- For iOS: macOS with Xcode
|
||||
- For Android: Android SDK
|
||||
|
||||
## Related Skills
|
||||
|
||||
Xem các MAUI Skills để hiểu thêm về patterns được sử dụng:
|
||||
- `.agent/skills/maui-enterprise-architect/SKILL.md`
|
||||
- `.agent/skills/maui-branding-expert/SKILL.md`
|
||||
4
apps/app-client-base/Resources/AppIcon/appicon.svg
Normal file
4
apps/app-client-base/Resources/AppIcon/appicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |
8
apps/app-client-base/Resources/AppIcon/appiconfg.svg
Normal file
8
apps/app-client-base/Resources/AppIcon/appiconfg.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/app-client-base/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
apps/app-client-base/Resources/Fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
apps/app-client-base/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
apps/app-client-base/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
BIN
apps/app-client-base/Resources/Images/dotnet_bot.png
Normal file
BIN
apps/app-client-base/Resources/Images/dotnet_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
15
apps/app-client-base/Resources/Raw/AboutAssets.txt
Normal file
15
apps/app-client-base/Resources/Raw/AboutAssets.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
Any raw assets you want to be deployed with your application can be placed in
|
||||
this directory (and child directories). Deployment of the asset to your application
|
||||
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
|
||||
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
These files will be deployed with your package and will be accessible using Essentials:
|
||||
|
||||
async Task LoadMauiAsset()
|
||||
{
|
||||
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var contents = reader.ReadToEnd();
|
||||
}
|
||||
8
apps/app-client-base/Resources/Splash/splash.svg
Normal file
8
apps/app-client-base/Resources/Splash/splash.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
81
apps/app-client-base/Resources/Styles/Colors.xaml
Normal file
81
apps/app-client-base/Resources/Styles/Colors.xaml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- ==================== BRAND COLORS ==================== -->
|
||||
<!-- EN: Primary brand color - main CTAs and accents -->
|
||||
<!-- VI: Màu thương hiệu chính - cho CTA và điểm nhấn -->
|
||||
<Color x:Key="BrandPrimary">#2196F3</Color>
|
||||
<Color x:Key="BrandPrimaryDark">#1976D2</Color>
|
||||
<Color x:Key="BrandPrimaryLight">#64B5F6</Color>
|
||||
|
||||
<!-- EN: Secondary brand color - secondary actions -->
|
||||
<!-- VI: Màu thương hiệu phụ - cho hành động phụ -->
|
||||
<Color x:Key="BrandSecondary">#FF5722</Color>
|
||||
<Color x:Key="BrandSecondaryDark">#E64A19</Color>
|
||||
|
||||
<!-- ==================== SEMANTIC COLORS ==================== -->
|
||||
<!-- EN: Status colors for feedback -->
|
||||
<!-- VI: Màu trạng thái cho phản hồi -->
|
||||
<Color x:Key="ColorSuccess">#4CAF50</Color>
|
||||
<Color x:Key="ColorWarning">#FF9800</Color>
|
||||
<Color x:Key="ColorError">#F44336</Color>
|
||||
<Color x:Key="ColorInfo">#2196F3</Color>
|
||||
|
||||
<!-- ==================== SURFACE COLORS (Light/Dark) ==================== -->
|
||||
<!-- EN: Background and surface colors - separate definitions for themes -->
|
||||
<!-- VI: Màu nền và bề mặt - định nghĩa riêng cho từng theme -->
|
||||
<Color x:Key="SurfaceBackgroundLight">#FAFAFA</Color>
|
||||
<Color x:Key="SurfaceBackgroundDark">#121212</Color>
|
||||
<Color x:Key="SurfaceCardLight">#FFFFFF</Color>
|
||||
<Color x:Key="SurfaceCardDark">#1E1E1E</Color>
|
||||
<Color x:Key="SurfaceOverlayLight">#00000033</Color>
|
||||
<Color x:Key="SurfaceOverlayDark">#FFFFFF33</Color>
|
||||
|
||||
<!-- ==================== TEXT COLORS (Light/Dark) ==================== -->
|
||||
<!-- EN: Text colors with proper contrast for both themes -->
|
||||
<!-- VI: Màu chữ với độ tương phản phù hợp cho cả 2 theme -->
|
||||
<Color x:Key="TextPrimaryLight">#212121</Color>
|
||||
<Color x:Key="TextPrimaryDark">#FFFFFF</Color>
|
||||
<Color x:Key="TextSecondaryLight">#757575</Color>
|
||||
<Color x:Key="TextSecondaryDark">#B0B0B0</Color>
|
||||
<Color x:Key="TextDisabledLight">#9E9E9E</Color>
|
||||
<Color x:Key="TextDisabledDark">#616161</Color>
|
||||
<Color x:Key="TextOnPrimary">#FFFFFF</Color>
|
||||
|
||||
<!-- ==================== BORDER COLORS ==================== -->
|
||||
<!-- EN: Border and divider colors -->
|
||||
<!-- VI: Màu viền và đường phân cách -->
|
||||
<Color x:Key="BorderDefaultLight">#E0E0E0</Color>
|
||||
<Color x:Key="BorderDefaultDark">#424242</Color>
|
||||
<Color x:Key="BorderFocused">#2196F3</Color>
|
||||
|
||||
<!-- ==================== THEME-AWARE COLORS (For use in XAML) ==================== -->
|
||||
<!-- EN: Use AppThemeBinding in Styles/Views, not directly in Color -->
|
||||
<!-- VI: Dùng AppThemeBinding trong Styles/Views, không trực tiếp trong Color -->
|
||||
|
||||
<!-- ==================== LEGACY COLORS (for template compatibility) ==================== -->
|
||||
<!-- EN: Legacy colors from MAUI template -->
|
||||
<!-- VI: Màu kế thừa từ MAUI template -->
|
||||
<Color x:Key="Primary">#2196F3</Color>
|
||||
<Color x:Key="PrimaryDark">#1976D2</Color>
|
||||
<Color x:Key="Secondary">#FF5722</Color>
|
||||
<Color x:Key="White">White</Color>
|
||||
<Color x:Key="Black">Black</Color>
|
||||
<Color x:Key="OffBlack">#1f1f1f</Color>
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
||||
<Color x:Key="Gray300">#ACACAC</Color>
|
||||
<Color x:Key="Gray400">#919191</Color>
|
||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
||||
<Color x:Key="Gray600">#404040</Color>
|
||||
<Color x:Key="Gray900">#212121</Color>
|
||||
<Color x:Key="Gray950">#141414</Color>
|
||||
|
||||
<!-- ==================== BRUSHES ==================== -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource BrandPrimary}"/>
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource BrandSecondary}"/>
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
434
apps/app-client-base/Resources/Styles/Styles.xaml
Normal file
434
apps/app-client-base/Resources/Styles/Styles.xaml
Normal file
@@ -0,0 +1,434 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Style TargetType="ActivityIndicator">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="IndicatorView">
|
||||
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
||||
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="StrokeShape" Value="Rectangle"/>
|
||||
<Setter Property="StrokeThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="BoxView">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14,10"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DatePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ImageButton">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
<Setter Property="BorderColor" Value="Transparent"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="0"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="Headline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="32" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="SubHeadline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Picker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RefreshView">
|
||||
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchBar">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchHandler">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shadow">
|
||||
<Setter Property="Radius" Value="15" />
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Offset" Value="10,10" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SwipeItem">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="On">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Off">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TimePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
<Style TargetType="TitleBar">
|
||||
<Setter Property="MinimumHeightRequest" Value="32"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="TitleActiveStates">
|
||||
<VisualState x:Name="TitleBarTitleActive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="TitleBarTitleInactive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
-->
|
||||
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TabbedPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
95
apps/app-client-base/Resources/Styles/Theme.xaml
Normal file
95
apps/app-client-base/Resources/Styles/Theme.xaml
Normal file
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- ==================== BUTTON STYLES ==================== -->
|
||||
<!-- EN: Primary Button (Filled) -->
|
||||
<!-- VI: Button chính (Đặc) -->
|
||||
<Style x:Key="ButtonPrimary" TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource BrandPrimary}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource TextOnPrimary}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilySemiBold}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeBody}" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="24,12" />
|
||||
<Setter Property="MinimumHeightRequest" Value="48" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Pressed">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource BrandPrimaryDark}" />
|
||||
<Setter Property="Scale" Value="0.98" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource TextDisabledLight}, Dark={StaticResource TextDisabledDark}}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource SurfaceCardLight}, Dark={StaticResource SurfaceCardDark}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- EN: Secondary Button (Outlined) -->
|
||||
<!-- VI: Button phụ (Viền) -->
|
||||
<Style x:Key="ButtonSecondary" TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="TextColor" Value="{StaticResource BrandPrimary}" />
|
||||
<Setter Property="BorderColor" Value="{StaticResource BrandPrimary}" />
|
||||
<Setter Property="BorderWidth" Value="2" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilySemiBold}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeBody}" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="24,12" />
|
||||
<Setter Property="MinimumHeightRequest" Value="48" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Pressed">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource BrandPrimaryLight}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource TextOnPrimary}" />
|
||||
<Setter Property="Scale" Value="0.98" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ==================== ENTRY STYLES ==================== -->
|
||||
<!-- EN: Default Entry Style -->
|
||||
<!-- VI: Style Entry mặc định -->
|
||||
<Style x:Key="EntryDefault" TargetType="Entry">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceCardLight}, Dark={StaticResource SurfaceCardDark}}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextPrimaryLight}, Dark={StaticResource TextPrimaryDark}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource TextSecondaryLight}, Dark={StaticResource TextSecondaryDark}}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilyRegular}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeBody}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="48" />
|
||||
</Style>
|
||||
|
||||
<!-- ==================== FRAME/CARD STYLES ==================== -->
|
||||
<!-- EN: Default Card Style -->
|
||||
<!-- VI: Style Card mặc định -->
|
||||
<Style x:Key="CardDefault" TargetType="Frame">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceCardLight}, Dark={StaticResource SurfaceCardDark}}" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="HasShadow" Value="True" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="BorderColor" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<!-- ==================== PAGE STYLES ==================== -->
|
||||
<!-- EN: Base Page Style -->
|
||||
<!-- VI: Style Page cơ sở -->
|
||||
<Style x:Key="PageBase" TargetType="ContentPage">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceBackgroundLight}, Dark={StaticResource SurfaceBackgroundDark}}" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
63
apps/app-client-base/Resources/Styles/Typography.xaml
Normal file
63
apps/app-client-base/Resources/Styles/Typography.xaml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- ==================== FONT FAMILIES ==================== -->
|
||||
<!-- EN: Font family definitions (cross-platform) -->
|
||||
<!-- VI: Định nghĩa font family (đa nền tảng) -->
|
||||
<OnPlatform x:Key="FontFamilyRegular" x:TypeArguments="x:String">
|
||||
<On Platform="Android, iOS" Value="OpenSansRegular" />
|
||||
<On Platform="WinUI" Value="Assets/Fonts/OpenSans-Regular.ttf#Open Sans" />
|
||||
</OnPlatform>
|
||||
|
||||
<OnPlatform x:Key="FontFamilySemiBold" x:TypeArguments="x:String">
|
||||
<On Platform="Android, iOS" Value="OpenSansSemibold" />
|
||||
<On Platform="WinUI" Value="Assets/Fonts/OpenSans-Semibold.ttf#Open Sans" />
|
||||
</OnPlatform>
|
||||
|
||||
<!-- ==================== FONT SIZES (Scalable) ==================== -->
|
||||
<!-- EN: Typography scale based on 16px base -->
|
||||
<!-- VI: Thang typography dựa trên base 16px -->
|
||||
<x:Double x:Key="FontSizeCaption">12</x:Double>
|
||||
<x:Double x:Key="FontSizeBody">16</x:Double>
|
||||
<x:Double x:Key="FontSizeSubtitle">18</x:Double>
|
||||
<x:Double x:Key="FontSizeTitle">24</x:Double>
|
||||
<x:Double x:Key="FontSizeHeadline">32</x:Double>
|
||||
<x:Double x:Key="FontSizeDisplay">48</x:Double>
|
||||
|
||||
<!-- ==================== TEXT STYLES ==================== -->
|
||||
<!-- EN: Pre-defined text styles with AppThemeBinding for colors -->
|
||||
<!-- VI: Các text style định sẵn với AppThemeBinding cho màu sắc -->
|
||||
|
||||
<Style x:Key="TextCaption" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilyRegular}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeCaption}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextSecondaryLight}, Dark={StaticResource TextSecondaryDark}}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TextBody" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilyRegular}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeBody}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextPrimaryLight}, Dark={StaticResource TextPrimaryDark}}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TextSubtitle" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilySemiBold}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeSubtitle}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextPrimaryLight}, Dark={StaticResource TextPrimaryDark}}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TextTitle" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilySemiBold}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeTitle}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextPrimaryLight}, Dark={StaticResource TextPrimaryDark}}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TextHeadline" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource FontFamilySemiBold}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeHeadline}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource TextPrimaryLight}, Dark={StaticResource TextPrimaryDark}}" />
|
||||
<Setter Property="SemanticProperties.HeadingLevel" Value="Level1" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
20
apps/app-client-base/Services/INavigationService.cs
Normal file
20
apps/app-client-base/Services/INavigationService.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace AppClientBase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Navigation service interface for Shell-based navigation.
|
||||
/// VI: Interface dịch vụ điều hướng cho điều hướng dựa trên Shell.
|
||||
/// </summary>
|
||||
public interface INavigationService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Navigate to a route with optional parameters.
|
||||
/// VI: Điều hướng đến một route với các tham số tùy chọn.
|
||||
/// </summary>
|
||||
Task GoToAsync(string route, IDictionary<string, object>? parameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Navigate back.
|
||||
/// VI: Điều hướng quay lại.
|
||||
/// </summary>
|
||||
Task GoBackAsync();
|
||||
}
|
||||
38
apps/app-client-base/Services/ISettingsService.cs
Normal file
38
apps/app-client-base/Services/ISettingsService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace AppClientBase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Settings service interface for app preferences.
|
||||
/// VI: Interface dịch vụ cài đặt cho tùy chọn ứng dụng.
|
||||
/// </summary>
|
||||
public interface ISettingsService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a setting value.
|
||||
/// VI: Lấy giá trị cài đặt.
|
||||
/// </summary>
|
||||
T Get<T>(string key, T defaultValue);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set a setting value.
|
||||
/// VI: Đặt giá trị cài đặt.
|
||||
/// </summary>
|
||||
void Set<T>(string key, T value);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if a setting exists.
|
||||
/// VI: Kiểm tra xem cài đặt có tồn tại không.
|
||||
/// </summary>
|
||||
bool Contains(string key);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a setting.
|
||||
/// VI: Xóa một cài đặt.
|
||||
/// </summary>
|
||||
void Remove(string key);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Clear all settings.
|
||||
/// VI: Xóa tất cả cài đặt.
|
||||
/// </summary>
|
||||
void Clear();
|
||||
}
|
||||
33
apps/app-client-base/Services/NavigationService.cs
Normal file
33
apps/app-client-base/Services/NavigationService.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace AppClientBase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Navigation service using Shell navigation.
|
||||
/// VI: Dịch vụ điều hướng sử dụng Shell navigation.
|
||||
/// </summary>
|
||||
public class NavigationService : INavigationService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Navigate to a route with optional parameters.
|
||||
/// VI: Điều hướng đến một route với các tham số tùy chọn.
|
||||
/// </summary>
|
||||
public async Task GoToAsync(string route, IDictionary<string, object>? parameters = null)
|
||||
{
|
||||
if (parameters != null)
|
||||
{
|
||||
await Shell.Current.GoToAsync(route, parameters);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Shell.Current.GoToAsync(route);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Navigate back.
|
||||
/// VI: Điều hướng quay lại.
|
||||
/// </summary>
|
||||
public async Task GoBackAsync()
|
||||
{
|
||||
await Shell.Current.GoToAsync("..");
|
||||
}
|
||||
}
|
||||
53
apps/app-client-base/Services/SettingsService.cs
Normal file
53
apps/app-client-base/Services/SettingsService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace AppClientBase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Settings service using MAUI Preferences API.
|
||||
/// VI: Dịch vụ cài đặt sử dụng MAUI Preferences API.
|
||||
/// </summary>
|
||||
public class SettingsService : ISettingsService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a setting value.
|
||||
/// VI: Lấy giá trị cài đặt.
|
||||
/// </summary>
|
||||
public T Get<T>(string key, T defaultValue)
|
||||
{
|
||||
return Preferences.Default.Get(key, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set a setting value.
|
||||
/// VI: Đặt giá trị cài đặt.
|
||||
/// </summary>
|
||||
public void Set<T>(string key, T value)
|
||||
{
|
||||
Preferences.Default.Set(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if a setting exists.
|
||||
/// VI: Kiểm tra xem cài đặt có tồn tại không.
|
||||
/// </summary>
|
||||
public bool Contains(string key)
|
||||
{
|
||||
return Preferences.Default.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a setting.
|
||||
/// VI: Xóa một cài đặt.
|
||||
/// </summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
Preferences.Default.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Clear all settings.
|
||||
/// VI: Xóa tất cả cài đặt.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
Preferences.Default.Clear();
|
||||
}
|
||||
}
|
||||
58
apps/app-client-base/ViewModels/BaseViewModel.cs
Normal file
58
apps/app-client-base/ViewModels/BaseViewModel.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using AppClientBase.Services;
|
||||
|
||||
namespace AppClientBase.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base ViewModel class with common functionality.
|
||||
/// VI: Lớp ViewModel cơ sở với chức năng chung.
|
||||
/// </summary>
|
||||
public abstract partial class BaseViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Navigation service instance.
|
||||
/// VI: Instance của dịch vụ điều hướng.
|
||||
/// </summary>
|
||||
protected readonly INavigationService NavigationService;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Indicates if the ViewModel is currently loading data.
|
||||
/// VI: Cho biết ViewModel đang tải dữ liệu hay không.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
|
||||
private bool _isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Title of the current page.
|
||||
/// VI: Tiêu đề của trang hiện tại.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _title = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Indicates if the ViewModel is not busy.
|
||||
/// VI: Cho biết ViewModel không bận.
|
||||
/// </summary>
|
||||
public bool IsNotBusy => !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Constructor with navigation service.
|
||||
/// VI: Constructor với dịch vụ điều hướng.
|
||||
/// </summary>
|
||||
protected BaseViewModel(INavigationService navigationService)
|
||||
{
|
||||
NavigationService = navigationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Navigate back to previous page.
|
||||
/// VI: Điều hướng quay lại trang trước.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
protected virtual async Task GoBackAsync()
|
||||
{
|
||||
await NavigationService.GoBackAsync();
|
||||
}
|
||||
}
|
||||
84
apps/app-client-base/ViewModels/MainViewModel.cs
Normal file
84
apps/app-client-base/ViewModels/MainViewModel.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using AppClientBase.Services;
|
||||
|
||||
namespace AppClientBase.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Main page ViewModel.
|
||||
/// VI: ViewModel của trang chính.
|
||||
/// </summary>
|
||||
public partial class MainViewModel : BaseViewModel
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Welcome message displayed on the main page.
|
||||
/// VI: Thông điệp chào mừng hiển thị trên trang chính.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _welcomeMessage = "Welcome to AppClientBase!";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Click counter for demonstration.
|
||||
/// VI: Bộ đếm click để minh họa.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private int _clickCount;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Button text that updates with click count.
|
||||
/// VI: Text nút cập nhật theo số lần click.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private string _buttonText = "Click me";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Constructor with required services.
|
||||
/// VI: Constructor với các services cần thiết.
|
||||
/// </summary>
|
||||
public MainViewModel(
|
||||
INavigationService navigationService,
|
||||
ISettingsService settingsService)
|
||||
: base(navigationService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
Title = "Home";
|
||||
|
||||
// Defer loading settings to avoid startup crash
|
||||
// ClickCount will be loaded on first interaction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Increment click counter command.
|
||||
/// VI: Command tăng bộ đếm click.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void IncrementCounter()
|
||||
{
|
||||
ClickCount++;
|
||||
try
|
||||
{
|
||||
_settingsService.Set("ClickCount", ClickCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore settings errors
|
||||
}
|
||||
UpdateButtonText();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update button text based on click count.
|
||||
/// VI: Cập nhật text nút dựa trên số lần click.
|
||||
/// </summary>
|
||||
private void UpdateButtonText()
|
||||
{
|
||||
ButtonText = ClickCount switch
|
||||
{
|
||||
0 => "Click me",
|
||||
1 => "Clicked 1 time",
|
||||
_ => $"Clicked {ClickCount} times"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# EN: Docker Compose for Chat Service local development
|
||||
# VI: Docker Compose cho Chat Service phát triển local
|
||||
|
||||
services:
|
||||
chatservice-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: chatservice-api
|
||||
ports:
|
||||
- "5003:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DATABASE_URL=Host=postgres;Port=5432;Database=chatservice_db;Username=postgres;Password=postgres
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- chatservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: chatservice-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: chatservice_db
|
||||
ports:
|
||||
- "5435:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- chatservice-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: chatservice-redis
|
||||
ports:
|
||||
- "6382:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- chatservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
chatservice-network:
|
||||
driver: bridge
|
||||
@@ -1,271 +1,456 @@
|
||||
# Architecture Documentation
|
||||
# Chat Service Architecture Documentation
|
||||
|
||||
> Detailed architecture documentation for the .NET 10 Microservice Template.
|
||||
> Detailed architecture for Chat Service with SignalR, scalability patterns, and AI integration.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "API Layer"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
subgraph "Clients"
|
||||
WEB[Web App]
|
||||
MOB[Mobile App]
|
||||
BOT[Bot Client]
|
||||
end
|
||||
|
||||
subgraph "Domain Layer"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
subgraph "Load Balancer"
|
||||
LB[NGINX/Traefik]
|
||||
SS[Sticky Sessions]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure Layer"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
subgraph "Chat Service Instances"
|
||||
CS1[Instance 1<br/>SignalR Hub]
|
||||
CS2[Instance 2<br/>SignalR Hub]
|
||||
CS3[Instance 3<br/>SignalR Hub]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
subgraph "Backplane"
|
||||
RD[(Redis<br/>Pub/Sub)]
|
||||
end
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
subgraph "Storage"
|
||||
PG[(PostgreSQL<br/>Messages)]
|
||||
RC[(Redis<br/>Cache)]
|
||||
end
|
||||
|
||||
subgraph "AI Service"
|
||||
AI[OpenAI<br/>GPT-4]
|
||||
end
|
||||
|
||||
WEB & MOB & BOT --> LB
|
||||
LB --> SS --> CS1 & CS2 & CS3
|
||||
CS1 & CS2 & CS3 <--> RD
|
||||
CS1 & CS2 & CS3 --> PG
|
||||
CS1 & CS2 & CS3 --> RC
|
||||
CS1 & CS2 & CS3 --> AI
|
||||
|
||||
style LB fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style RD fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
style PG fill:#27ae60,stroke:#1e8449,color:#fff
|
||||
style AI fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
## SignalR Hub Architecture
|
||||
|
||||
### 1. Domain Layer (ChatService.Domain)
|
||||
|
||||
The heart of the application containing pure business logic. This layer:
|
||||
- Has **ZERO** external dependencies (except MediatR.Contracts for events)
|
||||
- Contains only POCO classes
|
||||
- Implements DDD tactical patterns
|
||||
|
||||
#### Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots with their entities and value objects |
|
||||
| **Events** | Domain events for cross-aggregate communication |
|
||||
| **Exceptions** | Domain-specific exceptions for business rule violations |
|
||||
|
||||
### 2. Infrastructure Layer (ChatService.Infrastructure)
|
||||
|
||||
Technical implementations and external concerns:
|
||||
- Database access (EF Core)
|
||||
- Repository implementations
|
||||
- External service integrations
|
||||
|
||||
### 3. API Layer (ChatService.API)
|
||||
|
||||
Application entry point and CQRS implementation:
|
||||
- Controllers for HTTP handling
|
||||
- Commands for write operations
|
||||
- Queries for read operations
|
||||
- MediatR behaviors for cross-cutting concerns
|
||||
|
||||
## CQRS Flow
|
||||
### Connection Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Controller
|
||||
participant MediatR
|
||||
participant LoggingBehavior
|
||||
participant ValidatorBehavior
|
||||
participant TransactionBehavior
|
||||
participant CommandHandler
|
||||
participant Repository
|
||||
participant DbContext
|
||||
participant Hub as ChatHub
|
||||
participant Groups
|
||||
participant DB as Database
|
||||
participant Redis
|
||||
|
||||
Client->>Controller: HTTP Request
|
||||
Controller->>MediatR: Send(Command)
|
||||
MediatR->>LoggingBehavior: Handle
|
||||
LoggingBehavior->>ValidatorBehavior: Next()
|
||||
ValidatorBehavior->>TransactionBehavior: Next()
|
||||
TransactionBehavior->>CommandHandler: Next()
|
||||
CommandHandler->>Repository: Add/Update/Delete
|
||||
Repository->>DbContext: SaveEntitiesAsync()
|
||||
DbContext-->>Repository: Success
|
||||
Repository-->>CommandHandler: Result
|
||||
CommandHandler-->>Controller: Response
|
||||
Controller-->>Client: HTTP Response
|
||||
```
|
||||
|
||||
## Domain Events
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AR[Aggregate Root] -->|Raises| DE[Domain Event]
|
||||
DE -->|Dispatched by| CTX[DbContext]
|
||||
CTX -->|Publishes to| M[MediatR]
|
||||
M -->|Handled by| H1[Handler 1]
|
||||
M -->|Handled by| H2[Handler 2]
|
||||
Client->>Hub: Connect (JWT Token)
|
||||
Hub->>Hub: OnConnectedAsync()
|
||||
Hub->>DB: Load user rooms
|
||||
Hub->>Groups: AddToGroupAsync(roomId)
|
||||
Hub->>Redis: Publish(UserOnline)
|
||||
Hub-->>Client: Connected
|
||||
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DE fill:#f39c12,stroke:#d68910,color:#fff
|
||||
style M fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Sample Aggregate
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
samples {
|
||||
uuid id PK
|
||||
varchar(200) name
|
||||
varchar(1000) description
|
||||
int status_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
Note over Client,Hub: User is now online
|
||||
|
||||
sample_statuses {
|
||||
int id PK
|
||||
varchar(50) name
|
||||
}
|
||||
|
||||
samples ||--o{ sample_statuses : has
|
||||
Client->>Hub: SendMessage(roomId, content)
|
||||
Hub->>DB: Save message
|
||||
Hub->>Redis: Publish(NewMessage)
|
||||
Redis-->>Hub: Broadcast to all instances
|
||||
Hub->>Groups: Clients.Group(roomId)
|
||||
Hub-->>Client: ReceiveMessage
|
||||
```
|
||||
|
||||
## MediatR Pipeline
|
||||
### Hub Implementation
|
||||
|
||||
```
|
||||
Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Log start/end Validate Begin/Commit
|
||||
+ timing with Transaction
|
||||
FluentValidation
|
||||
```
|
||||
|
||||
### Behavior Order
|
||||
|
||||
1. **LoggingBehavior** - Logs request handling with timing
|
||||
2. **ValidatorBehavior** - Validates request using FluentValidation
|
||||
3. **TransactionBehavior** - Wraps command handlers in database transactions
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exception Hierarchy
|
||||
|
||||
```
|
||||
Exception
|
||||
└── DomainException
|
||||
└── SampleDomainException
|
||||
```
|
||||
|
||||
### Problem Details (RFC 7807)
|
||||
|
||||
All errors are returned in Problem Details format:
|
||||
|
||||
```json
|
||||
```csharp
|
||||
public class ChatHub : Hub<IChatClient>
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7807",
|
||||
"title": "Validation Error",
|
||||
"status": 400,
|
||||
"detail": "One or more validation errors occurred.",
|
||||
"errors": {
|
||||
"Name": ["Name is required"]
|
||||
}
|
||||
private readonly IChatRoomRepository _roomRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IAIService _aiService;
|
||||
|
||||
public async Task JoinRoom(Guid roomId)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
|
||||
// Add to SignalR Group
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, roomId.ToString());
|
||||
|
||||
// Notify other members
|
||||
await Clients.Group(roomId.ToString())
|
||||
.UserJoined(userId, roomId);
|
||||
|
||||
// Persist state to DB
|
||||
await _roomRepository.AddParticipantAsync(roomId, userId);
|
||||
}
|
||||
|
||||
public async Task SendMessage(Guid roomId, string content)
|
||||
{
|
||||
var message = new Message(roomId, Context.UserIdentifier, content);
|
||||
await _messageRepository.AddAsync(message);
|
||||
|
||||
// Broadcast message
|
||||
await Clients.Group(roomId.ToString())
|
||||
.ReceiveMessage(message.ToDto());
|
||||
|
||||
// Check AI trigger
|
||||
if (content.StartsWith("@gpt "))
|
||||
{
|
||||
await StreamAIResponse(roomId, content[5..]);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> StreamAIResponse(
|
||||
Guid roomId,
|
||||
string prompt,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
// Get chat history for context
|
||||
var history = await _messageRepository.GetHistoryAsync(roomId, 20);
|
||||
|
||||
await foreach (var chunk in _aiService.StreamAsync(prompt, history, ct))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
## Scalability Architecture
|
||||
|
||||
### Redis Backplane
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
HC[Health Check Endpoint]
|
||||
HC --> |/health/live| L[Liveness]
|
||||
HC --> |/health/ready| R[Readiness]
|
||||
HC --> |/health| F[Full Status]
|
||||
graph LR
|
||||
subgraph "Instance 1"
|
||||
C1[Client A] --> H1[Hub 1]
|
||||
end
|
||||
|
||||
R --> PG[(PostgreSQL)]
|
||||
R --> RD[(Redis)]
|
||||
subgraph "Instance 2"
|
||||
C2[Client B] --> H2[Hub 2]
|
||||
end
|
||||
|
||||
style HC fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style L fill:#2ecc71,stroke:#27ae60,color:#fff
|
||||
style R fill:#f39c12,stroke:#d68910,color:#fff
|
||||
subgraph "Redis"
|
||||
CH[Channel: chat.messages]
|
||||
end
|
||||
|
||||
H1 -->|Publish| CH
|
||||
CH -->|Subscribe| H2
|
||||
H2 -->|Deliver| C2
|
||||
|
||||
style CH fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
### Scaling Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **Redis Backplane** | Simple, on-premise | Requires Redis cluster management |
|
||||
| **Azure SignalR** | Serverless, no sticky sessions | Vendor lock-in, cost |
|
||||
| **Sticky Sessions** | Simplest | Imperfect for failover |
|
||||
|
||||
```csharp
|
||||
// Redis Backplane Configuration
|
||||
builder.Services.AddSignalR()
|
||||
.AddStackExchangeRedis(options =>
|
||||
{
|
||||
options.Configuration.ChannelPrefix =
|
||||
RedisChannel.Literal("ChatService");
|
||||
options.Configuration.AbortOnConnectFail = false;
|
||||
});
|
||||
|
||||
// Azure SignalR Service
|
||||
builder.Services.AddSignalR()
|
||||
.AddAzureSignalR(options =>
|
||||
{
|
||||
options.ConnectionString = azureSignalRConnectionString;
|
||||
options.ServerStickyMode = ServerStickyMode.Required;
|
||||
});
|
||||
```
|
||||
|
||||
## User & Group Management
|
||||
|
||||
### User Mapping
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Mapping"
|
||||
U1[User ID: user-123]
|
||||
C1[Connection 1<br/>Phone]
|
||||
C2[Connection 2<br/>Laptop]
|
||||
C3[Connection 3<br/>Tablet]
|
||||
end
|
||||
|
||||
U1 --> C1 & C2 & C3
|
||||
|
||||
style U1 fill:#3498db,stroke:#2980b9,color:#fff
|
||||
```
|
||||
|
||||
### IUserIdProvider
|
||||
|
||||
```csharp
|
||||
public class ClaimsUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
{
|
||||
// Get User ID from JWT Claims
|
||||
return connection.User?
|
||||
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Registration
|
||||
builder.Services.AddSingleton<IUserIdProvider, ClaimsUserIdProvider>();
|
||||
```
|
||||
|
||||
### Group State Management
|
||||
|
||||
```csharp
|
||||
// Problem: SignalR Groups don't persist on restart
|
||||
// Solution: Store in database
|
||||
|
||||
public class GroupStateService
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly IChatRoomRepository _repository;
|
||||
|
||||
public async Task RestoreUserGroups(string userId, string connectionId)
|
||||
{
|
||||
var rooms = await _repository.GetUserRoomsAsync(userId);
|
||||
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
await _hubContext.Groups.AddToGroupAsync(
|
||||
connectionId,
|
||||
room.Id.ToString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI Integration Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Hub as ChatHub
|
||||
participant AI as AIService
|
||||
participant OpenAI
|
||||
participant DB as Database
|
||||
|
||||
User->>Hub: "@gpt Explain DDD"
|
||||
Hub->>DB: Load history (20 messages)
|
||||
Hub->>AI: StreamAsync(prompt, history)
|
||||
AI->>OpenAI: ChatCompletion (stream: true)
|
||||
|
||||
loop Streaming
|
||||
OpenAI-->>AI: Token chunk
|
||||
AI-->>Hub: yield chunk
|
||||
Hub-->>User: ReceiveAIChunk
|
||||
end
|
||||
|
||||
Hub->>DB: Save AI response
|
||||
Hub-->>User: AIResponseComplete
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Roots
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ChatRoom ||--o{ Message : contains
|
||||
ChatRoom ||--o{ Participant : has
|
||||
ChatRoom {
|
||||
uuid id PK
|
||||
string name
|
||||
enum type
|
||||
uuid owner_id
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
Message {
|
||||
uuid id PK
|
||||
uuid room_id FK
|
||||
uuid sender_id
|
||||
string content
|
||||
enum type
|
||||
timestamp sent_at
|
||||
}
|
||||
|
||||
Participant {
|
||||
uuid id PK
|
||||
uuid room_id FK
|
||||
uuid user_id
|
||||
enum role
|
||||
timestamp joined_at
|
||||
timestamp last_read
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Trigger | Handler |
|
||||
|-------|---------|---------|
|
||||
| `MessageSentEvent` | New message | Update last_activity, broadcast |
|
||||
| `UserJoinedRoomEvent` | User joins room | Notify members |
|
||||
| `UserLeftRoomEvent` | User leaves room | Notify members |
|
||||
| `RoomCreatedEvent` | Create new room | Add creator as admin |
|
||||
|
||||
## MessagePack Protocol
|
||||
|
||||
### JSON vs MessagePack Comparison
|
||||
|
||||
| Metric | JSON | MessagePack |
|
||||
|--------|------|-------------|
|
||||
| Size | 100% | ~50-70% |
|
||||
| Parse time | 1x | 0.5-0.8x |
|
||||
| Type safety | Weak | Strong |
|
||||
|
||||
### Configuration
|
||||
|
||||
```csharp
|
||||
// Server
|
||||
builder.Services.AddSignalR()
|
||||
.AddMessagePackProtocol(options =>
|
||||
{
|
||||
options.SerializerOptions =
|
||||
MessagePackSerializerOptions.Standard
|
||||
.WithSecurity(MessagePackSecurity.UntrustedData)
|
||||
.WithCompression(MessagePackCompression.Lz4BlockArray);
|
||||
});
|
||||
```
|
||||
|
||||
## Resiliency Patterns
|
||||
|
||||
### Automatic Reconnect
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Connected
|
||||
Connected --> Disconnected: Connection lost
|
||||
Disconnected --> Reconnecting: Auto retry
|
||||
Reconnecting --> Connected: Success
|
||||
Reconnecting --> Reconnecting: Retry with backoff
|
||||
Reconnecting --> Disconnected: Max retries exceeded
|
||||
Disconnected --> [*]: User logout
|
||||
```
|
||||
|
||||
### Stateful Reconnect (.NET 8+)
|
||||
|
||||
```csharp
|
||||
// Server configuration
|
||||
builder.Services.AddSignalR(options =>
|
||||
{
|
||||
options.StatefulReconnectBufferSize = 32 * 1024; // 32KB
|
||||
});
|
||||
|
||||
// With UseStatefulReconnect
|
||||
app.MapHub<ChatHub>("/chatHub", options =>
|
||||
{
|
||||
options.AllowStatefulReconnects = true;
|
||||
});
|
||||
```
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Docker Compose (Local)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice-api:
|
||||
build: .
|
||||
ports: ["5000:8080"]
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
### Kubernetes with Sticky Sessions
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myservice-api
|
||||
name: chatservice
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: myservice:latest
|
||||
- name: chatservice
|
||||
image: chatservice:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: chat-secrets
|
||||
key: redis-url
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: chatservice
|
||||
annotations:
|
||||
service.beta.kubernetes.io/aws-load-balancer-sticky-sessions: "true"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
sessionAffinity: ClientIP
|
||||
sessionAffinityConfig:
|
||||
clientIP:
|
||||
timeoutSeconds: 3600
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
### Ingress with WebSocket Support
|
||||
|
||||
1. **Authentication**: JWT Bearer token (configure in production)
|
||||
2. **Authorization**: Role-based access control
|
||||
3. **Input Validation**: FluentValidation on all requests
|
||||
4. **SQL Injection**: EF Core parameterized queries
|
||||
5. **Secrets**: Environment variables, never in code
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: chatservice
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri"
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
spec:
|
||||
rules:
|
||||
- host: chat.goodgo.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: chatservice
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
## Health Checks
|
||||
|
||||
1. **Connection Pooling**: EF Core with Npgsql connection resilience
|
||||
2. **Async/Await**: All I/O operations are async
|
||||
3. **Response Caching**: Add caching headers for queries
|
||||
4. **Database Indexes**: Configure in EntityConfigurations
|
||||
```csharp
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(connectionString, name: "postgresql")
|
||||
.AddRedis(redisConnectionString, name: "redis")
|
||||
.AddSignalRHub<ChatHub>(name: "signalr-hub");
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers)
|
||||
- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/)
|
||||
- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html)
|
||||
- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
|
||||
- [ASP.NET Core SignalR](https://docs.microsoft.com/en-us/aspnet/core/signalr/)
|
||||
- [SignalR Scale-out with Redis](https://docs.microsoft.com/en-us/aspnet/core/signalr/redis-backplane)
|
||||
- [Stateful Reconnect](https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration)
|
||||
- [Azure SignalR Service](https://docs.microsoft.com/en-us/azure/azure-signalr/)
|
||||
|
||||
@@ -1,228 +1,209 @@
|
||||
# .NET 10 Microservice Template
|
||||
# Chat Service
|
||||
|
||||
> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns.
|
||||
> Real-time chat service for GoodGo platform, built on ASP.NET Core SignalR.
|
||||
|
||||
## Overview
|
||||
|
||||
This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with:
|
||||
Chat Service provides real-time communication capabilities in the microservices system with:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR
|
||||
- **Clean Architecture** - Domain, Infrastructure, API layered separation
|
||||
- **EF Core 10** - PostgreSQL with connection resilience
|
||||
- **FluentValidation** - Request validation
|
||||
- **API Versioning** - URL segment versioning
|
||||
- **Health Checks** - Kubernetes-ready probes
|
||||
- **Structured Logging** - Serilog with console and Seq
|
||||
- **Real-time Communication** - ASP.NET Core SignalR with WebSockets/SSE/Long Polling
|
||||
- **Scalability** - Redis Backplane or Azure SignalR Service
|
||||
- **User & Group Management** - Chat rooms, cross-device user mapping
|
||||
- **AI Integration** - Smart chatbot with streaming response
|
||||
- **High Performance** - MessagePack protocol
|
||||
- **Resiliency** - Auto reconnect, Stateful Reconnect
|
||||
|
||||
## Prerequisites
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (or use Docker) |
|
||||
|
||||
```bash
|
||||
# Check .NET version
|
||||
dotnet --version
|
||||
# Should output: 10.0.xxx
|
||||
```
|
||||
| PostgreSQL | 16+ |
|
||||
| Redis | 7+ |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create New Service
|
||||
|
||||
```bash
|
||||
# Copy template to new service
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Navigate to service directory
|
||||
cd services/your-service-name
|
||||
|
||||
# Rename all occurrences of "ChatService" to "YourService"
|
||||
find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} +
|
||||
find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} +
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
### 1. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your configuration
|
||||
# Edit configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 3. Run with Docker
|
||||
### 2. Run with Docker
|
||||
|
||||
```bash
|
||||
# Start all services (API + PostgreSQL + Redis)
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f myservice-api
|
||||
docker-compose logs -f chatservice-api
|
||||
```
|
||||
|
||||
### 4. Run Locally
|
||||
### 3. Run Locally
|
||||
|
||||
```bash
|
||||
# Restore dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build all projects
|
||||
dotnet build
|
||||
|
||||
# Run the API
|
||||
dotnet run --project src/ChatService.API
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Feature Details
|
||||
|
||||
### A. Real-time Communication
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **SignalR Hub** | Central hub handling all real-time connections |
|
||||
| **Multi-Transport** | Auto-select WebSockets → SSE → Long Polling |
|
||||
| **Streaming** | IAsyncEnumerable support for streaming AI responses |
|
||||
|
||||
```csharp
|
||||
// Send message to group
|
||||
await Clients.Group(roomId).SendAsync("ReceiveMessage", message);
|
||||
|
||||
// Streaming AI response
|
||||
public async IAsyncEnumerable<string> StreamAIResponse(string prompt)
|
||||
{
|
||||
await foreach (var chunk in _aiService.StreamAsync(prompt))
|
||||
yield return chunk;
|
||||
}
|
||||
```
|
||||
_template_dot_net/
|
||||
├── src/
|
||||
│ ├── ChatService.API/ # Presentation Layer (Controllers, CQRS)
|
||||
│ │ ├── Controllers/ # API endpoints
|
||||
│ │ ├── Application/ # CQRS Implementation
|
||||
│ │ │ ├── Commands/ # Write operations (MediatR)
|
||||
│ │ │ ├── Queries/ # Read operations
|
||||
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
|
||||
│ │ │ └── Validations/ # FluentValidation validators
|
||||
│ │ ├── Middleware/ # Custom middleware
|
||||
│ │ └── Program.cs # Application entry point
|
||||
│ │
|
||||
│ ├── ChatService.Domain/ # Domain Layer (Pure business logic)
|
||||
│ │ ├── AggregatesModel/ # Aggregate roots and entities
|
||||
│ │ ├── Events/ # Domain events
|
||||
│ │ ├── Exceptions/ # Domain exceptions
|
||||
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
|
||||
│ │
|
||||
│ └── ChatService.Infrastructure/ # Infrastructure Layer (Data access)
|
||||
│ ├── EntityConfigurations/ # EF Core Fluent API configurations
|
||||
│ ├── Repositories/ # Repository implementations
|
||||
│ ├── Idempotency/ # Request idempotency handling
|
||||
│ └── ChatServiceContext.cs # DbContext with Unit of Work
|
||||
│
|
||||
├── tests/
|
||||
│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application)
|
||||
│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints)
|
||||
│
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Local development setup
|
||||
├── global.json # .NET SDK version pinning
|
||||
└── Directory.Build.props # Common MSBuild properties
|
||||
|
||||
### B. Scalability
|
||||
|
||||
| Solution | Use Case |
|
||||
|----------|----------|
|
||||
| **Redis Backplane** | On-premise, multi-instance deployment |
|
||||
| **Azure SignalR Service** | Azure cloud, serverless scenarios |
|
||||
| **Sticky Sessions** | Fallback when not using Azure SignalR |
|
||||
|
||||
```csharp
|
||||
// Configure Redis Backplane
|
||||
builder.Services.AddSignalR()
|
||||
.AddStackExchangeRedis(connectionString, options => {
|
||||
options.Configuration.ChannelPrefix = RedisChannel.Literal("ChatService");
|
||||
});
|
||||
```
|
||||
|
||||
### C. User & Group Management
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Groups** | Group connections by chat room |
|
||||
| **User Mapping** | Map ConnectionId → UserId via Claims |
|
||||
| **Persistent State** | Save group state to database |
|
||||
|
||||
```csharp
|
||||
// Custom User ID Provider
|
||||
public class CustomUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
=> connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
```
|
||||
|
||||
### D. AI Integration
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **AI Assistant** | In-group chatbot (trigger: @gpt) |
|
||||
| **Streaming Response** | Push response chunks in real-time |
|
||||
| **Context History** | Save chat history for AI context |
|
||||
|
||||
### E. MessagePack Protocol
|
||||
|
||||
```csharp
|
||||
// Server configuration
|
||||
builder.Services.AddSignalR()
|
||||
.AddMessagePackProtocol();
|
||||
|
||||
// Client configuration (JavaScript)
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/chatHub")
|
||||
.withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
|
||||
.build();
|
||||
```
|
||||
|
||||
### F. Resiliency
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Auto Reconnect** | Client auto-reconnects |
|
||||
| **Stateful Reconnect** | Buffer data during interruption (.NET 8+) |
|
||||
|
||||
```csharp
|
||||
// Server: Enable Stateful Reconnect
|
||||
builder.Services.AddSignalR(options => {
|
||||
options.EnableDetailedErrors = true;
|
||||
options.StatefulReconnectBufferSize = 32 * 1024; // 32KB buffer
|
||||
});
|
||||
|
||||
// Client: Configure reconnect
|
||||
connection.WithAutomaticReconnect([0, 2000, 10000, 30000]);
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### HTTP REST APIs
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/samples` | Get all samples |
|
||||
| `GET` | `/api/v1/samples/{id}` | Get sample by ID |
|
||||
| `POST` | `/api/v1/samples` | Create new sample |
|
||||
| `PUT` | `/api/v1/samples/{id}` | Update sample |
|
||||
| `DELETE` | `/api/v1/samples/{id}` | Delete sample |
|
||||
| `PATCH` | `/api/v1/samples/{id}/status` | Change status |
|
||||
| `GET` | `/api/v1/rooms` | Get chat room list |
|
||||
| `POST` | `/api/v1/rooms` | Create new chat room |
|
||||
| `GET` | `/api/v1/rooms/{id}/messages` | Get message history |
|
||||
| `POST` | `/api/v1/rooms/{id}/participants` | Add participant |
|
||||
|
||||
### SignalR Hub Methods
|
||||
|
||||
| Method | Direction | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `SendMessage` | Client → Server | Send message |
|
||||
| `JoinRoom` | Client → Server | Join room |
|
||||
| `LeaveRoom` | Client → Server | Leave room |
|
||||
| `ReceiveMessage` | Server → Client | Receive message |
|
||||
| `UserJoined` | Server → Client | New user notification |
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/health` | Full health status |
|
||||
| `/health` | Full status |
|
||||
| `/health/live` | Liveness probe |
|
||||
| `/health/ready` | Readiness probe |
|
||||
|
||||
## CQRS Pattern
|
||||
|
||||
### Commands (Write Operations)
|
||||
|
||||
```csharp
|
||||
// Define command
|
||||
public record CreateSampleCommand(string Name, string? Description)
|
||||
: IRequest<CreateSampleCommandResult>;
|
||||
|
||||
// Handle command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Read Operations)
|
||||
|
||||
```csharp
|
||||
// Define query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Business logic validation
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
// State transition
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run with coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Run specific test project
|
||||
dotnet test tests/ChatService.UnitTests
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | - |
|
||||
| `REDIS_URL` | Redis connection string | - |
|
||||
| `JWT_SECRET` | JWT signing secret (min 32 chars) | - |
|
||||
| `ASPNETCORE_ENVIRONMENT` | Environment | `Development` |
|
||||
| `DATABASE_URL` | PostgreSQL connection | - |
|
||||
| `REDIS_URL` | Redis connection | - |
|
||||
| `AZURE_SIGNALR_CONNECTION` | Azure SignalR (optional) | - |
|
||||
| `OPENAI_API_KEY` | OpenAI API key (optional) | - |
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=localhost;Database=chatservice;Username=postgres;Password=postgres",
|
||||
"Redis": "localhost:6379"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
"SignalR": {
|
||||
"EnableMessagePack": true,
|
||||
"StatefulReconnectBufferSize": 32768
|
||||
},
|
||||
"AI": {
|
||||
"Provider": "OpenAI",
|
||||
"Model": "gpt-4",
|
||||
"MaxHistoryMessages": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -232,33 +213,20 @@ dotnet test tests/ChatService.UnitTests
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t myservice:latest .
|
||||
|
||||
# Run container
|
||||
docker run -p 5000:8080 --env-file .env myservice:latest
|
||||
docker build -t chatservice:latest .
|
||||
docker run -p 5000:8080 --env-file .env chatservice:latest
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests.
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed Kubernetes configuration with Sticky Sessions.
|
||||
|
||||
## What's New in .NET 10
|
||||
## References
|
||||
|
||||
- **C# 14** language features
|
||||
- Improved **Native AOT** support
|
||||
- Better **async/await** performance
|
||||
- Enhanced **JSON serialization**
|
||||
- Performance improvements across the board
|
||||
- 3-year **LTS** support (until November 2028)
|
||||
|
||||
## Resources
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture
|
||||
- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10)
|
||||
- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
- [MediatR](https://github.com/jbogard/MediatR) - CQRS library
|
||||
- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library
|
||||
- [ASP.NET Core SignalR](https://docs.microsoft.com/en-us/aspnet/core/signalr/)
|
||||
- [Redis Backplane](https://docs.microsoft.com/en-us/aspnet/core/signalr/redis-backplane)
|
||||
- [Azure SignalR Service](https://docs.microsoft.com/en-us/azure/azure-signalr/)
|
||||
- [MessagePack Protocol](https://docs.microsoft.com/en-us/aspnet/core/signalr/messagepackhubprotocol)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,271 +1,456 @@
|
||||
# Tài Liệu Kiến Trúc
|
||||
# Tài Liệu Kiến Trúc Chat Service
|
||||
|
||||
> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10.
|
||||
> Kiến trúc chi tiết cho Chat Service với SignalR, scalability patterns, và AI integration.
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Lớp API"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
subgraph "Clients"
|
||||
WEB[Web App]
|
||||
MOB[Mobile App]
|
||||
BOT[Bot Client]
|
||||
end
|
||||
|
||||
subgraph "Lớp Domain"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
subgraph "Load Balancer"
|
||||
LB[NGINX/Traefik]
|
||||
SS[Sticky Sessions]
|
||||
end
|
||||
|
||||
subgraph "Lớp Infrastructure"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
subgraph "Chat Service Instances"
|
||||
CS1[Instance 1<br/>SignalR Hub]
|
||||
CS2[Instance 2<br/>SignalR Hub]
|
||||
CS3[Instance 3<br/>SignalR Hub]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
subgraph "Backplane"
|
||||
RD[(Redis<br/>Pub/Sub)]
|
||||
end
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
subgraph "Storage"
|
||||
PG[(PostgreSQL<br/>Messages)]
|
||||
RC[(Redis<br/>Cache)]
|
||||
end
|
||||
|
||||
subgraph "AI Service"
|
||||
AI[OpenAI<br/>GPT-4]
|
||||
end
|
||||
|
||||
WEB & MOB & BOT --> LB
|
||||
LB --> SS --> CS1 & CS2 & CS3
|
||||
CS1 & CS2 & CS3 <--> RD
|
||||
CS1 & CS2 & CS3 --> PG
|
||||
CS1 & CS2 & CS3 --> RC
|
||||
CS1 & CS2 & CS3 --> AI
|
||||
|
||||
style LB fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style RD fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
style PG fill:#27ae60,stroke:#1e8449,color:#fff
|
||||
style AI fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Trách Nhiệm Các Lớp
|
||||
## SignalR Hub Architecture
|
||||
|
||||
### 1. Lớp Domain (ChatService.Domain)
|
||||
|
||||
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
|
||||
- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events)
|
||||
- Chỉ chứa các class POCO
|
||||
- Triển khai các tactical patterns của DDD
|
||||
|
||||
#### Thành Phần
|
||||
|
||||
| Thành phần | Mục Đích |
|
||||
|------------|----------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots với entities và value objects |
|
||||
| **Events** | Domain events cho giao tiếp cross-aggregate |
|
||||
| **Exceptions** | Domain exceptions cho vi phạm business rules |
|
||||
|
||||
### 2. Lớp Infrastructure (ChatService.Infrastructure)
|
||||
|
||||
Triển khai kỹ thuật và các mối quan tâm bên ngoài:
|
||||
- Truy cập database (EF Core)
|
||||
- Triển khai repositories
|
||||
- Tích hợp external services
|
||||
|
||||
### 3. Lớp API (ChatService.API)
|
||||
|
||||
Điểm vào ứng dụng và triển khai CQRS:
|
||||
- Controllers để xử lý HTTP
|
||||
- Commands cho các thao tác ghi
|
||||
- Queries cho các thao tác đọc
|
||||
- MediatR behaviors cho cross-cutting concerns
|
||||
|
||||
## Luồng CQRS
|
||||
### Connection Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Controller
|
||||
participant MediatR
|
||||
participant LoggingBehavior
|
||||
participant ValidatorBehavior
|
||||
participant TransactionBehavior
|
||||
participant CommandHandler
|
||||
participant Repository
|
||||
participant DbContext
|
||||
participant Hub as ChatHub
|
||||
participant Groups
|
||||
participant DB as Database
|
||||
participant Redis
|
||||
|
||||
Client->>Controller: HTTP Request
|
||||
Controller->>MediatR: Send(Command)
|
||||
MediatR->>LoggingBehavior: Handle
|
||||
LoggingBehavior->>ValidatorBehavior: Next()
|
||||
ValidatorBehavior->>TransactionBehavior: Next()
|
||||
TransactionBehavior->>CommandHandler: Next()
|
||||
CommandHandler->>Repository: Add/Update/Delete
|
||||
Repository->>DbContext: SaveEntitiesAsync()
|
||||
DbContext-->>Repository: Success
|
||||
Repository-->>CommandHandler: Result
|
||||
CommandHandler-->>Controller: Response
|
||||
Controller-->>Client: HTTP Response
|
||||
```
|
||||
|
||||
## Domain Events
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AR[Aggregate Root] -->|Phát sinh| DE[Domain Event]
|
||||
DE -->|Dispatch bởi| CTX[DbContext]
|
||||
CTX -->|Publish tới| M[MediatR]
|
||||
M -->|Xử lý bởi| H1[Handler 1]
|
||||
M -->|Xử lý bởi| H2[Handler 2]
|
||||
Client->>Hub: Connect (JWT Token)
|
||||
Hub->>Hub: OnConnectedAsync()
|
||||
Hub->>DB: Load user rooms
|
||||
Hub->>Groups: AddToGroupAsync(roomId)
|
||||
Hub->>Redis: Publish(UserOnline)
|
||||
Hub-->>Client: Connected
|
||||
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DE fill:#f39c12,stroke:#d68910,color:#fff
|
||||
style M fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Schema Database
|
||||
|
||||
### Sample Aggregate
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
samples {
|
||||
uuid id PK
|
||||
varchar(200) name
|
||||
varchar(1000) description
|
||||
int status_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
Note over Client,Hub: User is now online
|
||||
|
||||
sample_statuses {
|
||||
int id PK
|
||||
varchar(50) name
|
||||
}
|
||||
|
||||
samples ||--o{ sample_statuses : has
|
||||
Client->>Hub: SendMessage(roomId, content)
|
||||
Hub->>DB: Save message
|
||||
Hub->>Redis: Publish(NewMessage)
|
||||
Redis-->>Hub: Broadcast to all instances
|
||||
Hub->>Groups: Clients.Group(roomId)
|
||||
Hub-->>Client: ReceiveMessage
|
||||
```
|
||||
|
||||
## Pipeline MediatR
|
||||
### Hub Implementation
|
||||
|
||||
```
|
||||
Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Log start/end Validate Begin/Commit
|
||||
+ timing với Transaction
|
||||
FluentValidation
|
||||
```
|
||||
|
||||
### Thứ Tự Behaviors
|
||||
|
||||
1. **LoggingBehavior** - Ghi log xử lý request với timing
|
||||
2. **ValidatorBehavior** - Validate request sử dụng FluentValidation
|
||||
3. **TransactionBehavior** - Bao bọc command handlers trong database transactions
|
||||
|
||||
## Xử Lý Lỗi
|
||||
|
||||
### Phân Cấp Exceptions
|
||||
|
||||
```
|
||||
Exception
|
||||
└── DomainException
|
||||
└── SampleDomainException
|
||||
```
|
||||
|
||||
### Problem Details (RFC 7807)
|
||||
|
||||
Tất cả lỗi được trả về theo định dạng Problem Details:
|
||||
|
||||
```json
|
||||
```csharp
|
||||
public class ChatHub : Hub<IChatClient>
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7807",
|
||||
"title": "Lỗi Validation",
|
||||
"status": 400,
|
||||
"detail": "Một hoặc nhiều lỗi validation đã xảy ra.",
|
||||
"errors": {
|
||||
"Name": ["Tên là bắt buộc"]
|
||||
}
|
||||
private readonly IChatRoomRepository _roomRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IAIService _aiService;
|
||||
|
||||
public async Task JoinRoom(Guid roomId)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
|
||||
// Thêm vào SignalR Group
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, roomId.ToString());
|
||||
|
||||
// Thông báo cho members khác
|
||||
await Clients.Group(roomId.ToString())
|
||||
.UserJoined(userId, roomId);
|
||||
|
||||
// Lưu trạng thái vào DB
|
||||
await _roomRepository.AddParticipantAsync(roomId, userId);
|
||||
}
|
||||
|
||||
public async Task SendMessage(Guid roomId, string content)
|
||||
{
|
||||
var message = new Message(roomId, Context.UserIdentifier, content);
|
||||
await _messageRepository.AddAsync(message);
|
||||
|
||||
// Broadcast tin nhắn
|
||||
await Clients.Group(roomId.ToString())
|
||||
.ReceiveMessage(message.ToDto());
|
||||
|
||||
// Kiểm tra AI trigger
|
||||
if (content.StartsWith("@gpt "))
|
||||
{
|
||||
await StreamAIResponse(roomId, content[5..]);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> StreamAIResponse(
|
||||
Guid roomId,
|
||||
string prompt,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
// Lấy lịch sử chat cho context
|
||||
var history = await _messageRepository.GetHistoryAsync(roomId, 20);
|
||||
|
||||
await foreach (var chunk in _aiService.StreamAsync(prompt, history, ct))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
## Scalability Architecture
|
||||
|
||||
### Redis Backplane
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
HC[Health Check Endpoint]
|
||||
HC --> |/health/live| L[Liveness]
|
||||
HC --> |/health/ready| R[Readiness]
|
||||
HC --> |/health| F[Full Status]
|
||||
graph LR
|
||||
subgraph "Instance 1"
|
||||
C1[Client A] --> H1[Hub 1]
|
||||
end
|
||||
|
||||
R --> PG[(PostgreSQL)]
|
||||
R --> RD[(Redis)]
|
||||
subgraph "Instance 2"
|
||||
C2[Client B] --> H2[Hub 2]
|
||||
end
|
||||
|
||||
style HC fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style L fill:#2ecc71,stroke:#27ae60,color:#fff
|
||||
style R fill:#f39c12,stroke:#d68910,color:#fff
|
||||
subgraph "Redis"
|
||||
CH[Channel: chat.messages]
|
||||
end
|
||||
|
||||
H1 -->|Publish| CH
|
||||
CH -->|Subscribe| H2
|
||||
H2 -->|Deliver| C2
|
||||
|
||||
style CH fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Kiến Trúc Deployment
|
||||
### Cấu Hình Scaling
|
||||
|
||||
### Docker Compose (Local)
|
||||
| Option | Ưu điểm | Nhược điểm |
|
||||
|--------|---------|------------|
|
||||
| **Redis Backplane** | Đơn giản, on-premise | Cần quản lý Redis cluster |
|
||||
| **Azure SignalR** | Serverless, không sticky sessions | Vendor lock-in, chi phí |
|
||||
| **Sticky Sessions** | Đơn giản nhất | Không hoàn hảo cho failover |
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice-api:
|
||||
build: .
|
||||
ports: ["5000:8080"]
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
```csharp
|
||||
// Redis Backplane Configuration
|
||||
builder.Services.AddSignalR()
|
||||
.AddStackExchangeRedis(options =>
|
||||
{
|
||||
options.Configuration.ChannelPrefix =
|
||||
RedisChannel.Literal("ChatService");
|
||||
options.Configuration.AbortOnConnectFail = false;
|
||||
});
|
||||
|
||||
// Azure SignalR Service
|
||||
builder.Services.AddSignalR()
|
||||
.AddAzureSignalR(options =>
|
||||
{
|
||||
options.ConnectionString = azureSignalRConnectionString;
|
||||
options.ServerStickyMode = ServerStickyMode.Required;
|
||||
});
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
## User & Group Management
|
||||
|
||||
### User Mapping
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Mapping"
|
||||
U1[User ID: user-123]
|
||||
C1[Connection 1<br/>Phone]
|
||||
C2[Connection 2<br/>Laptop]
|
||||
C3[Connection 3<br/>Tablet]
|
||||
end
|
||||
|
||||
U1 --> C1 & C2 & C3
|
||||
|
||||
style U1 fill:#3498db,stroke:#2980b9,color:#fff
|
||||
```
|
||||
|
||||
### IUserIdProvider
|
||||
|
||||
```csharp
|
||||
public class ClaimsUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
{
|
||||
// Lấy User ID từ JWT Claims
|
||||
return connection.User?
|
||||
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Registration
|
||||
builder.Services.AddSingleton<IUserIdProvider, ClaimsUserIdProvider>();
|
||||
```
|
||||
|
||||
### Group State Management
|
||||
|
||||
```csharp
|
||||
// Vấn đề: SignalR Groups không persist khi restart
|
||||
// Giải pháp: Lưu trữ trong database
|
||||
|
||||
public class GroupStateService
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly IChatRoomRepository _repository;
|
||||
|
||||
public async Task RestoreUserGroups(string userId, string connectionId)
|
||||
{
|
||||
var rooms = await _repository.GetUserRoomsAsync(userId);
|
||||
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
await _hubContext.Groups.AddToGroupAsync(
|
||||
connectionId,
|
||||
room.Id.ToString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI Integration Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Hub as ChatHub
|
||||
participant AI as AIService
|
||||
participant OpenAI
|
||||
participant DB as Database
|
||||
|
||||
User->>Hub: "@gpt Giải thích DDD"
|
||||
Hub->>DB: Load history (20 messages)
|
||||
Hub->>AI: StreamAsync(prompt, history)
|
||||
AI->>OpenAI: ChatCompletion (stream: true)
|
||||
|
||||
loop Streaming
|
||||
OpenAI-->>AI: Token chunk
|
||||
AI-->>Hub: yield chunk
|
||||
Hub-->>User: ReceiveAIChunk
|
||||
end
|
||||
|
||||
Hub->>DB: Save AI response
|
||||
Hub-->>User: AIResponseComplete
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Roots
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ChatRoom ||--o{ Message : contains
|
||||
ChatRoom ||--o{ Participant : has
|
||||
ChatRoom {
|
||||
uuid id PK
|
||||
string name
|
||||
enum type
|
||||
uuid owner_id
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
Message {
|
||||
uuid id PK
|
||||
uuid room_id FK
|
||||
uuid sender_id
|
||||
string content
|
||||
enum type
|
||||
timestamp sent_at
|
||||
}
|
||||
|
||||
Participant {
|
||||
uuid id PK
|
||||
uuid room_id FK
|
||||
uuid user_id
|
||||
enum role
|
||||
timestamp joined_at
|
||||
timestamp last_read
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Trigger | Handler |
|
||||
|-------|---------|---------|
|
||||
| `MessageSentEvent` | Tin nhắn mới | Update last_activity, broadcast |
|
||||
| `UserJoinedRoomEvent` | User join room | Notify members |
|
||||
| `UserLeftRoomEvent` | User leave room | Notify members |
|
||||
| `RoomCreatedEvent` | Tạo room mới | Add creator as admin |
|
||||
|
||||
## MessagePack Protocol
|
||||
|
||||
### So sánh JSON vs MessagePack
|
||||
|
||||
| Metric | JSON | MessagePack |
|
||||
|--------|------|-------------|
|
||||
| Size | 100% | ~50-70% |
|
||||
| Parse time | 1x | 0.5-0.8x |
|
||||
| Type safety | Weak | Strong |
|
||||
|
||||
### Cấu Hình
|
||||
|
||||
```csharp
|
||||
// Server
|
||||
builder.Services.AddSignalR()
|
||||
.AddMessagePackProtocol(options =>
|
||||
{
|
||||
options.SerializerOptions =
|
||||
MessagePackSerializerOptions.Standard
|
||||
.WithSecurity(MessagePackSecurity.UntrustedData)
|
||||
.WithCompression(MessagePackCompression.Lz4BlockArray);
|
||||
});
|
||||
```
|
||||
|
||||
## Resiliency Patterns
|
||||
|
||||
### Automatic Reconnect
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Connected
|
||||
Connected --> Disconnected: Connection lost
|
||||
Disconnected --> Reconnecting: Auto retry
|
||||
Reconnecting --> Connected: Success
|
||||
Reconnecting --> Reconnecting: Retry with backoff
|
||||
Reconnecting --> Disconnected: Max retries exceeded
|
||||
Disconnected --> [*]: User logout
|
||||
```
|
||||
|
||||
### Stateful Reconnect (.NET 8+)
|
||||
|
||||
```csharp
|
||||
// Server configuration
|
||||
builder.Services.AddSignalR(options =>
|
||||
{
|
||||
options.StatefulReconnectBufferSize = 32 * 1024; // 32KB
|
||||
});
|
||||
|
||||
// Với UseStatefulReconnect
|
||||
app.MapHub<ChatHub>("/chatHub", options =>
|
||||
{
|
||||
options.AllowStatefulReconnects = true;
|
||||
});
|
||||
```
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Kubernetes với Sticky Sessions
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myservice-api
|
||||
name: chatservice
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: myservice:latest
|
||||
- name: chatservice
|
||||
image: chatservice:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: chat-secrets
|
||||
key: redis-url
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: chatservice
|
||||
annotations:
|
||||
service.beta.kubernetes.io/aws-load-balancer-sticky-sessions: "true"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
sessionAffinity: ClientIP
|
||||
sessionAffinityConfig:
|
||||
clientIP:
|
||||
timeoutSeconds: 3600
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
```
|
||||
|
||||
## Cân Nhắc Bảo Mật
|
||||
### Ingress với WebSocket Support
|
||||
|
||||
1. **Authentication**: JWT Bearer token (cấu hình trong production)
|
||||
2. **Authorization**: Role-based access control
|
||||
3. **Input Validation**: FluentValidation trên tất cả requests
|
||||
4. **SQL Injection**: EF Core parameterized queries
|
||||
5. **Secrets**: Biến môi trường, không bao giờ trong code
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: chatservice
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri"
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
spec:
|
||||
rules:
|
||||
- host: chat.goodgo.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: chatservice
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Tối Ưu Hiệu Năng
|
||||
## Health Checks
|
||||
|
||||
1. **Connection Pooling**: EF Core với Npgsql connection resilience
|
||||
2. **Async/Await**: Tất cả I/O operations đều async
|
||||
3. **Response Caching**: Thêm caching headers cho queries
|
||||
4. **Database Indexes**: Cấu hình trong EntityConfigurations
|
||||
```csharp
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(connectionString, name: "postgresql")
|
||||
.AddRedis(redisConnectionString, name: "redis")
|
||||
.AddSignalRHub<ChatHub>(name: "signalr-hub");
|
||||
```
|
||||
|
||||
## Tài Liệu Tham Khảo
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers)
|
||||
- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/)
|
||||
- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html)
|
||||
- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
|
||||
- [ASP.NET Core SignalR](https://docs.microsoft.com/en-us/aspnet/core/signalr/)
|
||||
- [SignalR Scale-out with Redis](https://docs.microsoft.com/en-us/aspnet/core/signalr/redis-backplane)
|
||||
- [Stateful Reconnect](https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration)
|
||||
- [Azure SignalR Service](https://docs.microsoft.com/en-us/azure/azure-signalr/)
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
# Template Microservice .NET 10
|
||||
# Chat Service
|
||||
|
||||
> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture.
|
||||
> Dịch vụ Chat thời gian thực cho nền tảng GoodGo, xây dựng trên ASP.NET Core SignalR.
|
||||
|
||||
## Tổng Quan
|
||||
|
||||
Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với:
|
||||
Chat Service cung cấp khả năng giao tiếp thời gian thực trong hệ thống microservices với:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR
|
||||
- **Clean Architecture** - Phân tầng Domain, Infrastructure, API
|
||||
- **EF Core 10** - PostgreSQL với connection resilience
|
||||
- **FluentValidation** - Validation request
|
||||
- **API Versioning** - Versioning theo URL segment
|
||||
- **Health Checks** - Probes sẵn sàng cho Kubernetes
|
||||
- **Structured Logging** - Serilog với console và Seq
|
||||
- **Giao tiếp thời gian thực** - ASP.NET Core SignalR với WebSockets/SSE/Long Polling
|
||||
- **Khả năng mở rộng** - Redis Backplane hoặc Azure SignalR Service
|
||||
- **Quản lý người dùng & nhóm** - Phòng chat, ánh xạ user across devices
|
||||
- **Tích hợp AI** - Chatbot thông minh với streaming response
|
||||
- **Hiệu năng cao** - Giao thức MessagePack
|
||||
- **Khả năng phục hồi** - Auto reconnect, Stateful Reconnect
|
||||
|
||||
## Yêu Cầu
|
||||
|
||||
@@ -21,208 +19,191 @@ Template này cung cấp cấu trúc sẵn sàng production cho microservices .N
|
||||
|---------|-----------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (hoặc dùng Docker) |
|
||||
|
||||
```bash
|
||||
# Kiểm tra phiên bản .NET
|
||||
dotnet --version
|
||||
# Kết quả nên là: 10.0.xxx
|
||||
```
|
||||
| PostgreSQL | 16+ |
|
||||
| Redis | 7+ |
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### 1. Tạo Service Mới
|
||||
|
||||
```bash
|
||||
# Sao chép template sang service mới
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Di chuyển đến thư mục service
|
||||
cd services/your-service-name
|
||||
|
||||
# Đổi tên tất cả "ChatService" thành "YourService"
|
||||
find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} +
|
||||
find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} +
|
||||
```
|
||||
|
||||
### 2. Cấu Hình Môi Trường
|
||||
### 1. Cấu Hình Môi Trường
|
||||
|
||||
```bash
|
||||
# Sao chép template môi trường
|
||||
cp .env.example .env
|
||||
|
||||
# Chỉnh sửa với cấu hình của bạn
|
||||
# Chỉnh sửa cấu hình
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 3. Chạy với Docker
|
||||
### 2. Chạy với Docker
|
||||
|
||||
```bash
|
||||
# Khởi động tất cả services (API + PostgreSQL + Redis)
|
||||
# Khởi động tất cả services
|
||||
docker-compose up -d
|
||||
|
||||
# Xem logs
|
||||
docker-compose logs -f myservice-api
|
||||
docker-compose logs -f chatservice-api
|
||||
```
|
||||
|
||||
### 4. Chạy Local
|
||||
### 3. Chạy Local
|
||||
|
||||
```bash
|
||||
# Khôi phục dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build tất cả projects
|
||||
dotnet build
|
||||
|
||||
# Chạy API
|
||||
dotnet run --project src/ChatService.API
|
||||
```
|
||||
|
||||
## Cấu Trúc Dự Án
|
||||
## Tính Năng Chi Tiết
|
||||
|
||||
```
|
||||
_template_dot_net/
|
||||
├── src/
|
||||
│ ├── ChatService.API/ # Lớp Presentation (Controllers, CQRS)
|
||||
│ │ ├── Controllers/ # Các API endpoints
|
||||
│ │ ├── Application/ # Triển khai CQRS
|
||||
│ │ │ ├── Commands/ # Thao tác ghi (MediatR)
|
||||
│ │ │ ├── Queries/ # Thao tác đọc
|
||||
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
|
||||
│ │ │ └── Validations/ # FluentValidation validators
|
||||
│ │ ├── Middleware/ # Custom middleware
|
||||
│ │ └── Program.cs # Điểm vào ứng dụng
|
||||
│ │
|
||||
│ ├── ChatService.Domain/ # Lớp Domain (Business logic thuần túy)
|
||||
│ │ ├── AggregatesModel/ # Aggregate roots và entities
|
||||
│ │ ├── Events/ # Domain events
|
||||
│ │ ├── Exceptions/ # Domain exceptions
|
||||
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
|
||||
│ │
|
||||
│ └── ChatService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu)
|
||||
│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API
|
||||
│ ├── Repositories/ # Triển khai repositories
|
||||
│ ├── Idempotency/ # Xử lý idempotency request
|
||||
│ └── ChatServiceContext.cs # DbContext với Unit of Work
|
||||
│
|
||||
├── tests/
|
||||
│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application)
|
||||
│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints)
|
||||
│
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Thiết lập phát triển local
|
||||
├── global.json # Pin phiên bản .NET SDK
|
||||
└── Directory.Build.props # Thuộc tính MSBuild chung
|
||||
### A. Giao Tiếp Thời Gian Thực
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|-----------|-------|
|
||||
| **SignalR Hub** | Hub trung tâm xử lý tất cả real-time connections |
|
||||
| **Multi-Transport** | Tự động chọn WebSockets → SSE → Long Polling |
|
||||
| **Streaming** | Hỗ trợ IAsyncEnumerable cho streaming AI responses |
|
||||
|
||||
```csharp
|
||||
// Gửi tin nhắn đến nhóm
|
||||
await Clients.Group(roomId).SendAsync("ReceiveMessage", message);
|
||||
|
||||
// Streaming AI response
|
||||
public async IAsyncEnumerable<string> StreamAIResponse(string prompt)
|
||||
{
|
||||
await foreach (var chunk in _aiService.StreamAsync(prompt))
|
||||
yield return chunk;
|
||||
}
|
||||
```
|
||||
|
||||
## Các Endpoint API
|
||||
### B. Khả Năng Mở Rộng
|
||||
|
||||
| Method | Endpoint | Mô Tả |
|
||||
| Giải pháp | Use Case |
|
||||
|-----------|----------|
|
||||
| **Redis Backplane** | On-premise, multi-instance deployment |
|
||||
| **Azure SignalR Service** | Azure cloud, serverless scenarios |
|
||||
| **Sticky Sessions** | Fallback khi không dùng Azure SignalR |
|
||||
|
||||
```csharp
|
||||
// Cấu hình Redis Backplane
|
||||
builder.Services.AddSignalR()
|
||||
.AddStackExchangeRedis(connectionString, options => {
|
||||
options.Configuration.ChannelPrefix = RedisChannel.Literal("ChatService");
|
||||
});
|
||||
```
|
||||
|
||||
### C. Quản Lý Người Dùng & Nhóm
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|-----------|-------|
|
||||
| **Groups** | Gom kết nối theo phòng chat |
|
||||
| **User Mapping** | Ánh xạ ConnectionId → UserId qua Claims |
|
||||
| **Persistent State** | Lưu trạng thái nhóm vào database |
|
||||
|
||||
```csharp
|
||||
// Custom User ID Provider
|
||||
public class CustomUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
=> connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
```
|
||||
|
||||
### D. Tích Hợp AI
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|-----------|-------|
|
||||
| **AI Assistant** | Chatbot trong nhóm (trigger: @gpt) |
|
||||
| **Streaming Response** | Đẩy từng phần câu trả lời realtime |
|
||||
| **Context History** | Lưu lịch sử chat cho context AI |
|
||||
|
||||
### E. Giao Thức MessagePack
|
||||
|
||||
```csharp
|
||||
// Server configuration
|
||||
builder.Services.AddSignalR()
|
||||
.AddMessagePackProtocol();
|
||||
|
||||
// Client configuration (JavaScript)
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/chatHub")
|
||||
.withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
|
||||
.build();
|
||||
```
|
||||
|
||||
### F. Khả Năng Phục Hồi
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|-----------|-------|
|
||||
| **Auto Reconnect** | Client tự động kết nối lại |
|
||||
| **Stateful Reconnect** | Buffer dữ liệu khi gián đoạn (.NET 8+) |
|
||||
|
||||
```csharp
|
||||
// Server: Bật Stateful Reconnect
|
||||
builder.Services.AddSignalR(options => {
|
||||
options.EnableDetailedErrors = true;
|
||||
options.StatefulReconnectBufferSize = 32 * 1024; // 32KB buffer
|
||||
});
|
||||
|
||||
// Client: Cấu hình reconnect
|
||||
connection.WithAutomaticReconnect([0, 2000, 10000, 30000]);
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### HTTP REST APIs
|
||||
|
||||
| Method | Endpoint | Mô tả |
|
||||
|--------|----------|-------|
|
||||
| `GET` | `/api/v1/samples` | Lấy tất cả samples |
|
||||
| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID |
|
||||
| `POST` | `/api/v1/samples` | Tạo sample mới |
|
||||
| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample |
|
||||
| `DELETE` | `/api/v1/samples/{id}` | Xóa sample |
|
||||
| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái |
|
||||
| `GET` | `/api/v1/rooms` | Lấy danh sách phòng chat |
|
||||
| `POST` | `/api/v1/rooms` | Tạo phòng chat mới |
|
||||
| `GET` | `/api/v1/rooms/{id}/messages` | Lấy lịch sử tin nhắn |
|
||||
| `POST` | `/api/v1/rooms/{id}/participants` | Thêm thành viên |
|
||||
|
||||
### SignalR Hub Methods
|
||||
|
||||
| Method | Direction | Mô tả |
|
||||
|--------|-----------|-------|
|
||||
| `SendMessage` | Client → Server | Gửi tin nhắn |
|
||||
| `JoinRoom` | Client → Server | Tham gia phòng |
|
||||
| `LeaveRoom` | Client → Server | Rời phòng |
|
||||
| `ReceiveMessage` | Server → Client | Nhận tin nhắn |
|
||||
| `UserJoined` | Server → Client | Thông báo user mới |
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
| Endpoint | Mục Đích |
|
||||
| Endpoint | Mục đích |
|
||||
|----------|----------|
|
||||
| `/health` | Trạng thái health đầy đủ |
|
||||
| `/health/live` | Kiểm tra sống |
|
||||
| `/health/ready` | Kiểm tra sẵn sàng |
|
||||
|
||||
## Pattern CQRS
|
||||
|
||||
### Commands (Thao Tác Ghi)
|
||||
|
||||
```csharp
|
||||
// Định nghĩa command
|
||||
public record CreateSampleCommand(string Name, string? Description)
|
||||
: IRequest<CreateSampleCommandResult>;
|
||||
|
||||
// Xử lý command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Thao Tác Đọc)
|
||||
|
||||
```csharp
|
||||
// Định nghĩa query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Validation business logic
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Tên sample không được để trống");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt");
|
||||
// Chuyển đổi trạng thái
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kiểm Thử
|
||||
|
||||
```bash
|
||||
# Chạy tất cả tests
|
||||
dotnet test
|
||||
|
||||
# Chạy với coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Chạy project test cụ thể
|
||||
dotnet test tests/ChatService.UnitTests
|
||||
```
|
||||
| `/health` | Trạng thái đầy đủ |
|
||||
| `/health/live` | Liveness probe |
|
||||
| `/health/ready` | Readiness probe |
|
||||
|
||||
## Cấu Hình
|
||||
|
||||
### Biến Môi Trường
|
||||
|
||||
| Biến | Mô Tả | Mặc định |
|
||||
| Biến | Mô tả | Mặc định |
|
||||
|------|-------|----------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` |
|
||||
| `DATABASE_URL` | Connection string PostgreSQL | - |
|
||||
| `REDIS_URL` | Connection string Redis | - |
|
||||
| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - |
|
||||
| `ASPNETCORE_ENVIRONMENT` | Môi trường | `Development` |
|
||||
| `DATABASE_URL` | PostgreSQL connection | - |
|
||||
| `REDIS_URL` | Redis connection | - |
|
||||
| `AZURE_SIGNALR_CONNECTION` | Azure SignalR (optional) | - |
|
||||
| `OPENAI_API_KEY` | OpenAI API key (optional) | - |
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=localhost;Database=chatservice;Username=postgres;Password=postgres",
|
||||
"Redis": "localhost:6379"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
"SignalR": {
|
||||
"EnableMessagePack": true,
|
||||
"StatefulReconnectBufferSize": 32768
|
||||
},
|
||||
"AI": {
|
||||
"Provider": "OpenAI",
|
||||
"Model": "gpt-4",
|
||||
"MaxHistoryMessages": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -232,33 +213,20 @@ dotnet test tests/ChatService.UnitTests
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t myservice:latest .
|
||||
|
||||
# Chạy container
|
||||
docker run -p 5000:8080 --env-file .env myservice:latest
|
||||
docker build -t chatservice:latest .
|
||||
docker run -p 5000:8080 --env-file .env chatservice:latest
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes.
|
||||
Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết chi tiết cấu hình Kubernetes với Sticky Sessions.
|
||||
|
||||
## Có Gì Mới Trong .NET 10
|
||||
## Tài Liệu Tham Khảo
|
||||
|
||||
- Tính năng ngôn ngữ **C# 14**
|
||||
- Hỗ trợ **Native AOT** được cải thiện
|
||||
- Hiệu suất **async/await** tốt hơn
|
||||
- **JSON serialization** được nâng cao
|
||||
- Cải thiện hiệu suất toàn diện
|
||||
- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028)
|
||||
|
||||
## Tài Nguyên
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu
|
||||
- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10)
|
||||
- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS
|
||||
- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation
|
||||
- [ASP.NET Core SignalR](https://docs.microsoft.com/en-us/aspnet/core/signalr/)
|
||||
- [Redis Backplane](https://docs.microsoft.com/en-us/aspnet/core/signalr/redis-backplane)
|
||||
- [Azure SignalR Service](https://docs.microsoft.com/en-us/azure/azure-signalr/)
|
||||
- [MessagePack Protocol](https://docs.microsoft.com/en-us/aspnet/core/signalr/messagepackhubprotocol)
|
||||
|
||||
## Giấy Phép
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
<!-- EN: SignalR for real-time communication / VI: SignalR cho real-time communication -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
350
services/chat-service-net/src/ChatService.API/Hubs/ChatHub.cs
Normal file
350
services/chat-service-net/src/ChatService.API/Hubs/ChatHub.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ChatService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using ChatService.Domain.Contracts;
|
||||
|
||||
namespace ChatService.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Main SignalR hub for real-time chat functionality.
|
||||
/// VI: SignalR hub chính cho chức năng chat thời gian thực.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class ChatHub : Hub<IChatHubClient>
|
||||
{
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
private readonly IAIService _aiService;
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
|
||||
// EN: Track active connections per user / VI: Theo dõi kết nối active theo user
|
||||
private static readonly Dictionary<string, HashSet<string>> _userConnections = new();
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public ChatHub(
|
||||
IConversationRepository conversationRepository,
|
||||
IAIService aiService,
|
||||
ILogger<ChatHub> logger)
|
||||
{
|
||||
_conversationRepository = conversationRepository;
|
||||
_aiService = aiService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
#region Connection Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// EN: Called when a new connection is established.
|
||||
/// VI: Được gọi khi kết nối mới được thiết lập.
|
||||
/// </summary>
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("Connection attempted without user identifier");
|
||||
await base.OnConnectedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {UserId} connected with connection {ConnectionId}",
|
||||
userId, Context.ConnectionId);
|
||||
|
||||
// EN: Track user connection / VI: Theo dõi kết nối user
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_userConnections.ContainsKey(userId))
|
||||
{
|
||||
_userConnections[userId] = new HashSet<string>();
|
||||
}
|
||||
_userConnections[userId].Add(Context.ConnectionId);
|
||||
}
|
||||
|
||||
// EN: Join user's conversation rooms / VI: Tham gia các phòng hội thoại của user
|
||||
try
|
||||
{
|
||||
var conversations = await _conversationRepository.GetUserConversationsAsync(
|
||||
Guid.Parse(userId),
|
||||
0,
|
||||
100, // Max rooms to auto-join
|
||||
CancellationToken.None);
|
||||
|
||||
foreach (var conv in conversations)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, conv.Id.ToString());
|
||||
_logger.LogDebug("User {UserId} auto-joined room {RoomId}", userId, conv.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to auto-join rooms for user {UserId}", userId);
|
||||
}
|
||||
|
||||
// EN: Notify others that user is online / VI: Thông báo user đã online
|
||||
await Clients.Others.UserStatusChanged(userId, true, null);
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Called when a connection is terminated.
|
||||
/// VI: Được gọi khi kết nối bị ngắt.
|
||||
/// </summary>
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var userId = Context.UserIdentifier;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
bool isLastConnection;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_userConnections.TryGetValue(userId, out var connections))
|
||||
{
|
||||
connections.Remove(Context.ConnectionId);
|
||||
isLastConnection = connections.Count == 0;
|
||||
if (isLastConnection)
|
||||
{
|
||||
_userConnections.Remove(userId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
isLastConnection = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLastConnection)
|
||||
{
|
||||
// EN: User is completely offline / VI: User hoàn toàn offline
|
||||
await Clients.Others.UserStatusChanged(userId, false, DateTime.UtcNow);
|
||||
_logger.LogInformation("User {UserId} is now offline", userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
_logger.LogError(exception, "Connection {ConnectionId} disconnected with error",
|
||||
Context.ConnectionId);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Room Management
|
||||
|
||||
/// <summary>
|
||||
/// EN: Join a specific chat room/conversation.
|
||||
/// VI: Tham gia phòng chat/cuộc hội thoại cụ thể.
|
||||
/// </summary>
|
||||
public async Task JoinRoom(Guid roomId)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
_logger.LogInformation("User {UserId} joining room {RoomId}", userId, roomId);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, roomId.ToString());
|
||||
|
||||
// EN: Get user name from claims / VI: Lấy tên user từ claims
|
||||
var userName = Context.User?.FindFirst("name")?.Value
|
||||
?? Context.User?.FindFirst("preferred_username")?.Value
|
||||
?? userId;
|
||||
|
||||
// EN: Notify other members / VI: Thông báo các thành viên khác
|
||||
await Clients.OthersInGroup(roomId.ToString())
|
||||
.UserJoined(userId, userName, roomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Leave a specific chat room/conversation.
|
||||
/// VI: Rời phòng chat/cuộc hội thoại cụ thể.
|
||||
/// </summary>
|
||||
public async Task LeaveRoom(Guid roomId)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
_logger.LogInformation("User {UserId} leaving room {RoomId}", userId, roomId);
|
||||
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId.ToString());
|
||||
|
||||
// EN: Notify other members / VI: Thông báo các thành viên khác
|
||||
await Clients.OthersInGroup(roomId.ToString())
|
||||
.UserLeft(userId, roomId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Messaging
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a message to a room. The message is saved and broadcast to all room members.
|
||||
/// VI: Gửi tin nhắn đến phòng. Tin nhắn được lưu và broadcast đến tất cả thành viên.
|
||||
/// </summary>
|
||||
public async Task SendMessage(Guid roomId, string content, string? messageType = null)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
throw new HubException("Message content cannot be empty");
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {UserId} sending message to room {RoomId}", userId, roomId);
|
||||
|
||||
// EN: Get user info from claims / VI: Lấy thông tin user từ claims
|
||||
var userName = Context.User?.FindFirst("name")?.Value
|
||||
?? Context.User?.FindFirst("preferred_username")?.Value
|
||||
?? "Unknown";
|
||||
|
||||
// EN: Create message notification / VI: Tạo thông báo tin nhắn
|
||||
var messageId = Guid.NewGuid();
|
||||
var notification = new MessageNotification(
|
||||
Id: messageId,
|
||||
ConversationId: roomId,
|
||||
SenderId: userId,
|
||||
SenderName: userName,
|
||||
Content: content,
|
||||
MessageType: messageType ?? "text",
|
||||
SentAt: DateTime.UtcNow
|
||||
);
|
||||
|
||||
// EN: Broadcast to room / VI: Broadcast đến phòng
|
||||
await Clients.Group(roomId.ToString()).ReceiveMessage(notification);
|
||||
|
||||
// EN: Check for AI trigger / VI: Kiểm tra AI trigger
|
||||
if (content.StartsWith("@gpt ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var prompt = content[5..].Trim();
|
||||
if (!string.IsNullOrEmpty(prompt))
|
||||
{
|
||||
// EN: Start streaming AI response in background
|
||||
// VI: Bắt đầu streaming AI response trong background
|
||||
_ = StreamAIResponseToRoom(roomId, prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send typing indicator to a room.
|
||||
/// VI: Gửi typing indicator đến phòng.
|
||||
/// </summary>
|
||||
public async Task SendTypingIndicator(Guid roomId, bool isTyping)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
var userName = Context.User?.FindFirst("name")?.Value
|
||||
?? Context.User?.FindFirst("preferred_username")?.Value
|
||||
?? "Unknown";
|
||||
|
||||
await Clients.OthersInGroup(roomId.ToString())
|
||||
.TypingIndicator(userId, userName, roomId, isTyping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark a message as read.
|
||||
/// VI: Đánh dấu tin nhắn đã đọc.
|
||||
/// </summary>
|
||||
public async Task MarkMessageRead(Guid roomId, Guid messageId)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
await Clients.OthersInGroup(roomId.ToString())
|
||||
.MessageRead(userId, messageId, roomId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AI Integration
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stream AI response to the caller (invokable from client).
|
||||
/// VI: Stream AI response đến caller (có thể gọi từ client).
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<string> StreamAIResponse(
|
||||
string prompt,
|
||||
Guid roomId,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var userId = Context.UserIdentifier ?? throw new HubException("User not authenticated");
|
||||
|
||||
_logger.LogInformation("User {UserId} requested AI response in room {RoomId}", userId, roomId);
|
||||
|
||||
// EN: Get conversation history for context
|
||||
// VI: Lấy lịch sử hội thoại cho context
|
||||
var history = await GetConversationHistoryAsync(roomId, ct);
|
||||
|
||||
await foreach (var chunk in _aiService.StreamResponseAsync(prompt, history, ct))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Internal method to stream AI response to entire room.
|
||||
/// VI: Method nội bộ để stream AI response đến toàn bộ phòng.
|
||||
/// </summary>
|
||||
private async Task StreamAIResponseToRoom(Guid roomId, string prompt)
|
||||
{
|
||||
var aiMessageId = Guid.NewGuid();
|
||||
var fullResponse = new System.Text.StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
var history = await GetConversationHistoryAsync(roomId, CancellationToken.None);
|
||||
|
||||
await foreach (var chunk in _aiService.StreamResponseAsync(prompt, history))
|
||||
{
|
||||
fullResponse.Append(chunk);
|
||||
await Clients.Group(roomId.ToString())
|
||||
.ReceiveAIChunk(chunk, aiMessageId);
|
||||
}
|
||||
|
||||
await Clients.Group(roomId.ToString())
|
||||
.AIResponseComplete(aiMessageId, fullResponse.ToString());
|
||||
|
||||
_logger.LogInformation("AI response completed for room {RoomId}", roomId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AI streaming failed for room {RoomId}", roomId);
|
||||
await Clients.Group(roomId.ToString())
|
||||
.AIResponseComplete(aiMessageId, "[AI Error: Unable to generate response]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get conversation history for AI context.
|
||||
/// VI: Lấy lịch sử hội thoại cho AI context.
|
||||
/// </summary>
|
||||
private async Task<IEnumerable<ChatMessage>> GetConversationHistoryAsync(
|
||||
Guid roomId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conversation = await _conversationRepository.GetByIdAsync(roomId, ct);
|
||||
if (conversation == null)
|
||||
{
|
||||
return Enumerable.Empty<ChatMessage>();
|
||||
}
|
||||
|
||||
return conversation.Messages
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(20)
|
||||
.Reverse()
|
||||
.Select(m => new ChatMessage(
|
||||
Role: m.SenderId.ToString() == Context.UserIdentifier ? "user" : "assistant",
|
||||
Content: m.EncryptedContent, // EN: Note: Encrypted content / VI: Lưu ý: Nội dung đã mã hóa
|
||||
Timestamp: m.CreatedAt
|
||||
));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get conversation history for room {RoomId}", roomId);
|
||||
return Enumerable.Empty<ChatMessage>();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ChatService.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom user ID provider that extracts user ID from JWT claims.
|
||||
/// VI: Custom user ID provider lấy user ID từ JWT claims.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This enables sending messages to specific users regardless of which device they're connected from.
|
||||
/// VI: Cho phép gửi tin nhắn đến user cụ thể bất kể họ đang kết nối từ thiết bị nào.
|
||||
/// </remarks>
|
||||
public class ClaimsUserIdProvider : IUserIdProvider
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
{
|
||||
// EN: Try to get user ID from standard claim types
|
||||
// VI: Thử lấy user ID từ các claim types chuẩn
|
||||
return connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? connection.User?.FindFirst("sub")?.Value
|
||||
?? connection.User?.FindFirst("user_id")?.Value;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using Asp.Versioning;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ChatService.API.Application.Behaviors;
|
||||
using ChatService.API.Hubs;
|
||||
using ChatService.Infrastructure;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
|
||||
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -38,6 +41,38 @@ try
|
||||
// EN: Add FluentValidation / VI: Thêm FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
// EN: Add SignalR with Redis Backplane and MessagePack
|
||||
// VI: Thêm SignalR với Redis Backplane và MessagePack
|
||||
var redisConnection = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
|
||||
var signalRBuilder = builder.Services.AddSignalR(options =>
|
||||
{
|
||||
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
|
||||
options.KeepAliveInterval = TimeSpan.FromSeconds(
|
||||
builder.Configuration.GetValue("SignalR:KeepAliveInterval", 15));
|
||||
options.ClientTimeoutInterval = TimeSpan.FromSeconds(
|
||||
builder.Configuration.GetValue("SignalR:ClientTimeoutInterval", 30));
|
||||
|
||||
// EN: Enable Stateful Reconnect (.NET 8+) / VI: Bật Stateful Reconnect
|
||||
options.StatefulReconnectBufferSize =
|
||||
builder.Configuration.GetValue("SignalR:StatefulReconnectBufferSize", 32768);
|
||||
});
|
||||
|
||||
// EN: Add Redis Backplane for scaling / VI: Thêm Redis Backplane để scale
|
||||
signalRBuilder.AddStackExchangeRedis(redisConnection, options =>
|
||||
{
|
||||
options.Configuration.ChannelPrefix = RedisChannel.Literal("ChatService");
|
||||
options.Configuration.AbortOnConnectFail = false;
|
||||
});
|
||||
|
||||
// EN: Add MessagePack protocol for better performance / VI: Thêm MessagePack cho hiệu năng tốt hơn
|
||||
if (builder.Configuration.GetValue("SignalR:EnableMessagePack", true))
|
||||
{
|
||||
signalRBuilder.AddMessagePackProtocol();
|
||||
}
|
||||
|
||||
// EN: Add custom User ID provider / VI: Thêm custom User ID provider
|
||||
builder.Services.AddSingleton<IUserIdProvider, ClaimsUserIdProvider>();
|
||||
|
||||
// EN: Add API versioning / VI: Thêm API versioning
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
@@ -72,7 +107,7 @@ try
|
||||
{
|
||||
Title = "ChatService API",
|
||||
Version = "v1",
|
||||
Description = "ChatService microservice API / API microservice ChatService"
|
||||
Description = "Real-time Chat Service with SignalR, AI Integration / Chat Service thời gian thực với SignalR, tích hợp AI"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,16 +118,23 @@ try
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? "",
|
||||
name: "postgresql",
|
||||
tags: ["db", "postgresql"]);
|
||||
tags: ["db", "postgresql"])
|
||||
.AddRedis(
|
||||
redisConnection,
|
||||
name: "redis",
|
||||
tags: ["cache", "redis"]);
|
||||
|
||||
// EN: Add CORS / VI: Thêm CORS
|
||||
// EN: Add CORS for WebSocket / VI: Thêm CORS cho WebSocket
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
policy.WithOrigins(
|
||||
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
|
||||
?? ["http://localhost:3000", "http://localhost:5173"])
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials(); // EN: Required for SignalR / VI: Bắt buộc cho SignalR
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +168,12 @@ try
|
||||
// EN: Map controllers / VI: Map controllers
|
||||
app.MapControllers();
|
||||
|
||||
// EN: Map SignalR Hub / VI: Map SignalR Hub
|
||||
app.MapHub<ChatHub>("/hubs/chat", options =>
|
||||
{
|
||||
options.AllowStatefulReconnects = true;
|
||||
});
|
||||
|
||||
// EN: Run the application / VI: Chạy ứng dụng
|
||||
app.Run();
|
||||
}
|
||||
@@ -142,3 +190,4 @@ finally
|
||||
// EN: Make Program class accessible for integration tests
|
||||
// VI: Làm cho class Program có thể truy cập cho integration tests
|
||||
public partial class Program { }
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"applicationUrl": "http://localhost:5010",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -30,10 +30,22 @@
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=chat_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require",
|
||||
"Redis": "localhost:6379"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379"
|
||||
"SignalR": {
|
||||
"EnableMessagePack": true,
|
||||
"StatefulReconnectBufferSize": 32768,
|
||||
"KeepAliveInterval": 15,
|
||||
"ClientTimeoutInterval": 30
|
||||
},
|
||||
"AI": {
|
||||
"Provider": "OpenAI",
|
||||
"Model": "gpt-4",
|
||||
"MaxHistoryMessages": 20,
|
||||
"MaxTokens": 1000,
|
||||
"Temperature": 0.7,
|
||||
"SystemPrompt": "You are a helpful assistant in a chat application. Respond concisely and helpfully."
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace ChatService.Domain.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for AI service integration (OpenAI, Azure OpenAI, etc.).
|
||||
/// VI: Interface cho tích hợp AI service (OpenAI, Azure OpenAI, v.v.).
|
||||
/// </summary>
|
||||
public interface IAIService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Stream AI response for a given prompt with conversation context.
|
||||
/// VI: Stream AI response cho prompt với context cuộc hội thoại.
|
||||
/// </summary>
|
||||
/// <param name="prompt">User prompt / Prompt từ user</param>
|
||||
/// <param name="conversationHistory">Previous messages for context / Tin nhắn trước đó cho context</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Async stream of response chunks / Stream async các phần response</returns>
|
||||
IAsyncEnumerable<string> StreamResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a complete AI response (non-streaming).
|
||||
/// VI: Lấy AI response hoàn chỉnh (không streaming).
|
||||
/// </summary>
|
||||
Task<string> GetResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if AI service is available.
|
||||
/// VI: Kiểm tra AI service có khả dụng không.
|
||||
/// </summary>
|
||||
Task<bool> IsAvailableAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Chat message DTO for AI context.
|
||||
/// VI: DTO tin nhắn chat cho AI context.
|
||||
/// </summary>
|
||||
public record ChatMessage(
|
||||
string Role, // "user", "assistant", "system"
|
||||
string Content,
|
||||
DateTime Timestamp
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: AI service configuration options.
|
||||
/// VI: Cấu hình cho AI service.
|
||||
/// </summary>
|
||||
public class AIServiceOptions
|
||||
{
|
||||
public const string SectionName = "AI";
|
||||
|
||||
/// <summary>
|
||||
/// EN: AI provider (OpenAI, AzureOpenAI, Anthropic, etc.)
|
||||
/// VI: Nhà cung cấp AI (OpenAI, AzureOpenAI, Anthropic, v.v.)
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = "OpenAI";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Model name to use.
|
||||
/// VI: Tên model sử dụng.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = "gpt-4";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Maximum number of history messages to include in context.
|
||||
/// VI: Số tin nhắn lịch sử tối đa đưa vào context.
|
||||
/// </summary>
|
||||
public int MaxHistoryMessages { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// EN: System prompt for the AI assistant.
|
||||
/// VI: System prompt cho AI assistant.
|
||||
/// </summary>
|
||||
public string SystemPrompt { get; set; } =
|
||||
"You are a helpful assistant in a chat application. " +
|
||||
"Respond concisely and helpfully. " +
|
||||
"If asked about topics you don't know, admit it.";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Maximum tokens for response.
|
||||
/// VI: Số token tối đa cho response.
|
||||
/// </summary>
|
||||
public int MaxTokens { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Temperature for response creativity (0.0 - 2.0).
|
||||
/// VI: Temperature cho độ sáng tạo của response (0.0 - 2.0).
|
||||
/// </summary>
|
||||
public float Temperature { get; set; } = 0.7f;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace ChatService.Domain.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Strongly-typed SignalR client interface for type-safe hub method calls.
|
||||
/// VI: Interface SignalR client với strongly-typed cho việc gọi hub methods an toàn về kiểu.
|
||||
/// </summary>
|
||||
public interface IChatHubClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Receive a new message in a conversation.
|
||||
/// VI: Nhận tin nhắn mới trong cuộc hội thoại.
|
||||
/// </summary>
|
||||
Task ReceiveMessage(MessageNotification message);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notification when a user joins a room.
|
||||
/// VI: Thông báo khi user tham gia phòng.
|
||||
/// </summary>
|
||||
Task UserJoined(string userId, string userName, Guid roomId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notification when a user leaves a room.
|
||||
/// VI: Thông báo khi user rời phòng.
|
||||
/// </summary>
|
||||
Task UserLeft(string userId, Guid roomId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Receive a chunk of AI streaming response.
|
||||
/// VI: Nhận một phần của AI streaming response.
|
||||
/// </summary>
|
||||
Task ReceiveAIChunk(string chunk, Guid messageId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Notification when AI response is complete.
|
||||
/// VI: Thông báo khi AI response hoàn thành.
|
||||
/// </summary>
|
||||
Task AIResponseComplete(Guid messageId, string fullResponse);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Typing indicator notification.
|
||||
/// VI: Thông báo đang gõ tin nhắn.
|
||||
/// </summary>
|
||||
Task TypingIndicator(string userId, string userName, Guid roomId, bool isTyping);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Message read receipt notification.
|
||||
/// VI: Thông báo đã đọc tin nhắn.
|
||||
/// </summary>
|
||||
Task MessageRead(string userId, Guid messageId, Guid roomId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: User online/offline status change.
|
||||
/// VI: Thay đổi trạng thái online/offline của user.
|
||||
/// </summary>
|
||||
Task UserStatusChanged(string userId, bool isOnline, DateTime? lastSeen);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Message notification DTO for SignalR.
|
||||
/// VI: DTO thông báo tin nhắn cho SignalR.
|
||||
/// </summary>
|
||||
public record MessageNotification(
|
||||
Guid Id,
|
||||
Guid ConversationId,
|
||||
string SenderId,
|
||||
string SenderName,
|
||||
string Content,
|
||||
string MessageType,
|
||||
DateTime SentAt,
|
||||
Guid? ReplyToMessageId = null
|
||||
);
|
||||
@@ -72,3 +72,62 @@ public class MessageReadDomainEvent : INotification
|
||||
ReadAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a user joins a conversation room.
|
||||
/// VI: Domain event được phát ra khi user tham gia phòng hội thoại.
|
||||
/// </summary>
|
||||
public class UserJoinedRoomDomainEvent : INotification
|
||||
{
|
||||
public Guid UserId { get; }
|
||||
public Guid ConversationId { get; }
|
||||
public string UserName { get; }
|
||||
public DateTime JoinedAt { get; }
|
||||
|
||||
public UserJoinedRoomDomainEvent(Guid userId, Guid conversationId, string userName)
|
||||
{
|
||||
UserId = userId;
|
||||
ConversationId = conversationId;
|
||||
UserName = userName;
|
||||
JoinedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a user leaves a conversation room.
|
||||
/// VI: Domain event được phát ra khi user rời phòng hội thoại.
|
||||
/// </summary>
|
||||
public class UserLeftRoomDomainEvent : INotification
|
||||
{
|
||||
public Guid UserId { get; }
|
||||
public Guid ConversationId { get; }
|
||||
public DateTime LeftAt { get; }
|
||||
|
||||
public UserLeftRoomDomainEvent(Guid userId, Guid conversationId)
|
||||
{
|
||||
UserId = userId;
|
||||
ConversationId = conversationId;
|
||||
LeftAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a user is typing in a conversation.
|
||||
/// VI: Domain event được phát ra khi user đang gõ tin nhắn.
|
||||
/// </summary>
|
||||
public class TypingDomainEvent : INotification
|
||||
{
|
||||
public Guid UserId { get; }
|
||||
public Guid ConversationId { get; }
|
||||
public string UserName { get; }
|
||||
public bool IsTyping { get; }
|
||||
|
||||
public TypingDomainEvent(Guid userId, Guid conversationId, string userName, bool isTyping)
|
||||
{
|
||||
UserId = userId;
|
||||
ConversationId = conversationId;
|
||||
UserName = userName;
|
||||
IsTyping = isTyping;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ChatService.Domain.AggregatesModel.UserAggregate;
|
||||
using ChatService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using ChatService.Domain.Contracts;
|
||||
using ChatService.Infrastructure.Idempotency;
|
||||
using ChatService.Infrastructure.Repositories;
|
||||
using ChatService.Infrastructure.Services;
|
||||
|
||||
namespace ChatService.Infrastructure;
|
||||
|
||||
@@ -54,6 +56,27 @@ public static class DependencyInjection
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
// EN: Register AI service / VI: Đăng ký AI service
|
||||
services.Configure<AIServiceOptions>(configuration.GetSection(AIServiceOptions.SectionName));
|
||||
|
||||
var openAiApiKey = configuration["OPENAI_API_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
|
||||
if (!string.IsNullOrEmpty(openAiApiKey))
|
||||
{
|
||||
services.AddHttpClient<IAIService, AIService>(client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {openAiApiKey}");
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// EN: Use null implementation when API key is not configured
|
||||
// VI: Sử dụng null implementation khi API key chưa được cấu hình
|
||||
services.AddSingleton<IAIService, NullAIService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using ChatService.Domain.Contracts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ChatService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: AI service implementation using OpenAI API.
|
||||
/// VI: Implementation AI service sử dụng OpenAI API.
|
||||
/// </summary>
|
||||
public class AIService : IAIService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AIServiceOptions _options;
|
||||
private readonly ILogger<AIService> _logger;
|
||||
|
||||
public AIService(
|
||||
HttpClient httpClient,
|
||||
IOptions<AIServiceOptions> options,
|
||||
ILogger<AIService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
// EN: Configure base address for OpenAI API
|
||||
// VI: Cấu hình base address cho OpenAI API
|
||||
_httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept", "text/event-stream");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<string> StreamResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Starting AI stream response for prompt: {PromptPreview}...",
|
||||
prompt.Length > 50 ? prompt[..50] : prompt);
|
||||
|
||||
var messages = BuildMessagesPayload(prompt, conversationHistory);
|
||||
var requestBody = new
|
||||
{
|
||||
model = _options.Model,
|
||||
messages = messages,
|
||||
max_tokens = _options.MaxTokens,
|
||||
temperature = _options.Temperature,
|
||||
stream = true
|
||||
};
|
||||
|
||||
var jsonContent = System.Text.Json.JsonSerializer.Serialize(requestBody);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||
{
|
||||
Content = new StringContent(jsonContent, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
ct);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) != null && !ct.IsCancellationRequested)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
if (!line.StartsWith("data: ")) continue;
|
||||
|
||||
var data = line[6..];
|
||||
if (data == "[DONE]") break;
|
||||
|
||||
// EN: Parse SSE data and extract content delta
|
||||
// VI: Parse SSE data và extract content delta
|
||||
var chunk = ParseStreamChunk(data);
|
||||
if (!string.IsNullOrEmpty(chunk))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("AI stream response completed");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> GetResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
await foreach (var chunk in StreamResponseAsync(prompt, conversationHistory, ct))
|
||||
{
|
||||
sb.Append(chunk);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "models");
|
||||
using var response = await _httpClient.SendAsync(request, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI service availability check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Build the messages array for OpenAI API.
|
||||
/// VI: Xây dựng mảng messages cho OpenAI API.
|
||||
/// </summary>
|
||||
private List<object> BuildMessagesPayload(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory)
|
||||
{
|
||||
var messages = new List<object>
|
||||
{
|
||||
new { role = "system", content = _options.SystemPrompt }
|
||||
};
|
||||
|
||||
// EN: Add conversation history (limit to MaxHistoryMessages)
|
||||
// VI: Thêm lịch sử hội thoại (giới hạn MaxHistoryMessages)
|
||||
var history = conversationHistory
|
||||
.OrderByDescending(m => m.Timestamp)
|
||||
.Take(_options.MaxHistoryMessages)
|
||||
.Reverse()
|
||||
.ToList();
|
||||
|
||||
foreach (var msg in history)
|
||||
{
|
||||
messages.Add(new { role = msg.Role, content = msg.Content });
|
||||
}
|
||||
|
||||
// EN: Add current user prompt
|
||||
// VI: Thêm prompt hiện tại của user
|
||||
messages.Add(new { role = "user", content = prompt });
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse a streaming chunk from OpenAI SSE format.
|
||||
/// VI: Parse một chunk từ định dạng SSE của OpenAI.
|
||||
/// </summary>
|
||||
private static string? ParseStreamChunk(string data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(data);
|
||||
var choices = doc.RootElement.GetProperty("choices");
|
||||
if (choices.GetArrayLength() == 0) return null;
|
||||
|
||||
var delta = choices[0].GetProperty("delta");
|
||||
if (delta.TryGetProperty("content", out var content))
|
||||
{
|
||||
return content.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// EN: Silently ignore parsing errors for malformed chunks
|
||||
// VI: Bỏ qua lỗi parsing cho các chunk không hợp lệ
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Null implementation of AI service for when AI is disabled.
|
||||
/// VI: Null implementation của AI service khi AI bị tắt.
|
||||
/// </summary>
|
||||
public class NullAIService : IAIService
|
||||
{
|
||||
public async IAsyncEnumerable<string> StreamResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
yield return "AI service is not configured. Please set up OPENAI_API_KEY.";
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GetResponseAsync(
|
||||
string prompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult("AI service is not configured. Please set up OPENAI_API_KEY.");
|
||||
|
||||
public Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# EN: Docker Compose for local development
|
||||
# VI: Docker Compose cho phát triển local
|
||||
|
||||
services:
|
||||
socialservice-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: socialservice-api
|
||||
ports:
|
||||
- "5000:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DATABASE_URL=Host=postgres;Port=5432;Database=socialservice_db;Username=postgres;Password=postgres
|
||||
- REDIS_URL=redis:6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- socialservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: socialservice-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: socialservice_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- socialservice-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: socialservice-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- socialservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
socialservice-network:
|
||||
driver: bridge
|
||||
Reference in New Issue
Block a user