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:
Ho Ngoc Hai
2026-01-15 23:45:09 +07:00
parent 28c9dd85d0
commit 2f7d695773
61 changed files with 3627 additions and 946 deletions

4
.gitignore vendored
View File

@@ -83,3 +83,7 @@ infra/traefik/certs/*
*storybook.log
storybook-static
# MAUI
obj
bin

View 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>

View 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());
}
}

View 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>

View 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>

View File

@@ -0,0 +1,9 @@
namespace AppClientBase;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}

View 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>

View 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);
}
}

View 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();
}
}

View File

@@ -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>

View 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
{
}

View 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();
}

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace AppClientBase;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -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>

View 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>

View 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));
}
}

View 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>

View 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();
}

View 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>

View 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>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace AppClientBase;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View 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>

View 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));
}
}

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View 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`

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View 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();
}

View 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

View 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>

View 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>

View 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>

View 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>

View 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();
}

View 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();
}

View 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("..");
}
}

View 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();
}
}

View 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();
}
}

View 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"
};
}
}

View File

@@ -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

View File

@@ -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/)

View File

@@ -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

View File

@@ -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:
-**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/)

View File

@@ -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

View File

@@ -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>

View 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
}

View File

@@ -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;
}
}

View File

@@ -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 { }

View File

@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5000",
"applicationUrl": "http://localhost:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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