From 928a22fe3eaca69a50cf7665b40f4fc542f0d6c7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 12 Jan 2026 23:07:53 +0700 Subject: [PATCH] feat(authentication): Implement email verification, two-factor authentication, and social login features - Added endpoints for sending and confirming email verification, enhancing user account security. - Integrated two-factor authentication (2FA) with TOTP support, including enabling, verifying, and disabling 2FA. - Implemented social login functionality for Google and Facebook, allowing users to authenticate using their existing accounts. - Updated dependency injection to include services for email, 2FA, and social login. - Enhanced documentation to reflect new features and usage examples for email verification and 2FA. --- NOTE.MD | 11 +- deployments/local/docker-compose.yml | 67 +++ docs/en/architecture/security-architecture.md | 22 +- docs/en/guides/iam-authentication.md | 116 ++++- docs/vi/architecture/security-architecture.md | 44 +- docs/vi/guides/iam-authentication.md | 116 ++++- .../iam-service-net/docs/en/ARCHITECTURE.md | 101 ++++ services/iam-service-net/docs/en/README.md | 114 +++++ .../iam-service-net/docs/vi/ARCHITECTURE.md | 101 ++++ services/iam-service-net/docs/vi/README.md | 114 +++++ .../Commands/Auth/ConfirmEmailCommand.cs | 24 + .../Auth/ConfirmEmailCommandHandler.cs | 66 +++ .../Commands/Auth/Disable2FACommand.cs | 24 + .../Commands/Auth/Disable2FACommandHandler.cs | 98 ++++ .../Commands/Auth/Enable2FACommand.cs | 26 + .../Commands/Auth/Enable2FACommandHandler.cs | 92 ++++ .../Commands/Auth/ExternalLoginCommand.cs | 70 +++ .../Auth/ExternalLoginCommandHandler.cs | 97 ++++ .../Auth/SendVerificationEmailCommand.cs | 23 + .../SendVerificationEmailCommandHandler.cs | 69 +++ .../Commands/Auth/Verify2FACommand.cs | 24 + .../Commands/Auth/Verify2FACommandHandler.cs | 100 ++++ .../Controllers/AuthController.cs | 457 ++++++++++++++++++ .../src/IamService.API/appsettings.json | 25 + .../DependencyInjection.cs | 43 ++ .../Email/EmailSettings.cs | 55 +++ .../Email/IEmailService.cs | 35 ++ .../Email/SmtpEmailService.cs | 201 ++++++++ .../IamService.Infrastructure.csproj | 11 + .../SocialLogin/ISocialLoginService.cs | 117 +++++ .../SocialLogin/SocialLoginService.cs | 232 +++++++++ .../SocialLogin/SocialLoginSettings.cs | 66 +++ .../TwoFactor/ITwoFactorService.cs | 41 ++ .../TwoFactor/TotpTwoFactorService.cs | 122 +++++ .../TwoFactor/TwoFactorSettings.cs | 31 ++ services/storage-service-net/.env.example | 40 ++ services/storage-service-net/.gitignore | 75 +++ .../storage-service-net/Directory.Build.props | 22 + services/storage-service-net/Dockerfile | 66 +++ services/storage-service-net/README.md | 95 ++++ .../storage-service-net/StorageService.slnx | 11 + .../storage-service-net/docker-compose.yml | 102 ++++ .../docs/en/ARCHITECTURE.md | 271 +++++++++++ .../storage-service-net/docs/en/README.md | 265 ++++++++++ .../docs/vi/ARCHITECTURE.md | 271 +++++++++++ .../storage-service-net/docs/vi/README.md | 265 ++++++++++ services/storage-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Commands/ChangeSampleStatusCommand.cs | 14 + .../ChangeSampleStatusCommandHandler.cs | 70 +++ .../Commands/CreateSampleCommand.cs | 21 + .../Commands/CreateSampleCommandHandler.cs | 46 ++ .../Application/Commands/DeleteFileCommand.cs | 20 + .../Commands/DeleteFileCommandHandler.cs | 84 ++++ .../Commands/DeleteSampleCommand.cs | 10 + .../Commands/DeleteSampleCommandHandler.cs | 54 +++ .../Commands/UpdateSampleCommand.cs | 16 + .../Commands/UpdateSampleCommandHandler.cs | 54 +++ .../Application/Commands/UploadFileCommand.cs | 28 ++ .../Commands/UploadFileCommandHandler.cs | 121 +++++ .../Application/Queries/FileDtos.cs | 67 +++ .../Application/Queries/FileQueries.cs | 36 ++ .../Application/Queries/FileQueryHandlers.cs | 159 ++++++ .../Application/Queries/GetSampleQuery.cs | 23 + .../Queries/GetSampleQueryHandler.cs | 39 ++ .../Application/Queries/GetSamplesQuery.cs | 9 + .../Queries/GetSamplesQueryHandler.cs | 34 ++ .../CreateSampleCommandValidator.cs | 25 + .../UpdateSampleCommandValidator.cs | 29 ++ .../Controllers/FilesController.cs | 192 ++++++++ .../Controllers/QuotaController.cs | 49 ++ .../Controllers/SamplesController.cs | 200 ++++++++ .../src/StorageService.API/Program.cs | 144 ++++++ .../Properties/launchSettings.json | 15 + .../StorageService.API.csproj | 44 ++ .../appsettings.Development.json | 37 ++ .../src/StorageService.API/appsettings.json | 46 ++ .../FileAggregate/FileDomainEvents.cs | 34 ++ .../FileAggregate/IFileRepository.cs | 67 +++ .../FileAggregate/StorageEnums.cs | 30 ++ .../FileAggregate/StorageFile.cs | 140 ++++++ .../QuotaAggregate/IQuotaRepository.cs | 40 ++ .../QuotaAggregate/UserStorageQuota.cs | 151 ++++++ .../SampleAggregate/ISampleRepository.cs | 61 +++ .../AggregatesModel/SampleAggregate/Sample.cs | 158 ++++++ .../SampleAggregate/SampleStatus.cs | 77 +++ .../Events/SampleCreatedDomainEvent.cs | 22 + .../Events/SampleStatusChangedDomainEvent.cs | 39 ++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/SampleDomainException.cs | 21 + .../StorageService.Domain/SeedWork/Entity.cs | 102 ++++ .../SeedWork/Enumeration.cs | 95 ++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 ++ .../StorageService.Domain.csproj | 14 + .../Configuration/StorageSettings.cs | 69 +++ .../DependencyInjection.cs | 107 ++++ .../SampleEntityTypeConfiguration.cs | 61 +++ .../SampleStatusEntityTypeConfiguration.cs | 39 ++ .../ExternalServices/HttpIamServiceClient.cs | 94 ++++ .../ExternalServices/IamServiceClient.cs | 49 ++ .../Idempotency/ClientRequest.cs | 26 + .../Idempotency/IRequestManager.cs | 24 + .../Idempotency/RequestManager.cs | 45 ++ .../Repositories/FileRepository.cs | 101 ++++ .../Repositories/QuotaRepository.cs | 64 +++ .../Persistence/StorageServiceContext.cs | 171 +++++++ .../Repositories/SampleRepository.cs | 72 +++ .../Storage/AliyunOssStorageProvider.cs | 164 +++++++ .../Storage/IStorageProvider.cs | 99 ++++ .../Storage/MinioStorageProvider.cs | 186 +++++++ .../Storage/StorageProviderFactory.cs | 74 +++ .../StorageService.Infrastructure.csproj | 42 ++ .../StorageServiceContext.cs | 160 ++++++ .../Controllers/SamplesControllerTests.cs | 80 +++ .../CustomWebApplicationFactory.cs | 56 +++ .../StorageService.FunctionalTests.csproj | 38 ++ .../CreateSampleCommandHandlerTests.cs | 65 +++ .../Domain/SampleAggregateTests.cs | 151 ++++++ .../StorageService.UnitTests.csproj | 35 ++ 124 files changed, 9569 insertions(+), 24 deletions(-) create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommand.cs create mode 100644 services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommandHandler.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Email/EmailSettings.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Email/IEmailService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Email/SmtpEmailService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/SocialLogin/ISocialLoginService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginSettings.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/TwoFactor/ITwoFactorService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TotpTwoFactorService.cs create mode 100644 services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TwoFactorSettings.cs create mode 100644 services/storage-service-net/.env.example create mode 100644 services/storage-service-net/.gitignore create mode 100644 services/storage-service-net/Directory.Build.props create mode 100644 services/storage-service-net/Dockerfile create mode 100644 services/storage-service-net/README.md create mode 100644 services/storage-service-net/StorageService.slnx create mode 100644 services/storage-service-net/docker-compose.yml create mode 100644 services/storage-service-net/docs/en/ARCHITECTURE.md create mode 100644 services/storage-service-net/docs/en/README.md create mode 100644 services/storage-service-net/docs/vi/ARCHITECTURE.md create mode 100644 services/storage-service-net/docs/vi/README.md create mode 100644 services/storage-service-net/global.json create mode 100644 services/storage-service-net/src/StorageService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommand.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommandHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/FileQueries.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQuery.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQueryHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQuery.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQueryHandler.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Validations/CreateSampleCommandValidator.cs create mode 100644 services/storage-service-net/src/StorageService.API/Application/Validations/UpdateSampleCommandValidator.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/QuotaController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Controllers/SamplesController.cs create mode 100644 services/storage-service-net/src/StorageService.API/Program.cs create mode 100644 services/storage-service-net/src/StorageService.API/Properties/launchSettings.json create mode 100644 services/storage-service-net/src/StorageService.API/StorageService.API.csproj create mode 100644 services/storage-service-net/src/StorageService.API/appsettings.Development.json create mode 100644 services/storage-service-net/src/StorageService.API/appsettings.json create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/FileDomainEvents.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/IFileRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageEnums.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/IQuotaRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/UserStorageQuota.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/Sample.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/Events/SampleCreatedDomainEvent.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/Events/SampleStatusChangedDomainEvent.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/Exceptions/DomainException.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/Exceptions/SampleDomainException.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/Entity.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/Enumeration.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/IRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/SeedWork/ValueObject.cs create mode 100644 services/storage-service-net/src/StorageService.Domain/StorageService.Domain.csproj create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Configuration/StorageSettings.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/QuotaRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Repositories/SampleRepository.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Storage/AliyunOssStorageProvider.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Storage/IStorageProvider.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Storage/MinioStorageProvider.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/Storage/StorageProviderFactory.cs create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/StorageService.Infrastructure.csproj create mode 100644 services/storage-service-net/src/StorageService.Infrastructure/StorageServiceContext.cs create mode 100644 services/storage-service-net/tests/StorageService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/storage-service-net/tests/StorageService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/storage-service-net/tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj create mode 100644 services/storage-service-net/tests/StorageService.UnitTests/Application/CreateSampleCommandHandlerTests.cs create mode 100644 services/storage-service-net/tests/StorageService.UnitTests/Domain/SampleAggregateTests.cs create mode 100644 services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj diff --git a/NOTE.MD b/NOTE.MD index 4e04fb94..5fcc655c 100644 --- a/NOTE.MD +++ b/NOTE.MD @@ -3,13 +3,4 @@ - Role/Permission Management APIs - CRUD roles - Email Verification - Confirm email - 2FA/MFA - Two-factor authentication -- Social Login - Google, Facebook, etc. - - -Có Cached chưa - -Đề xuất cần implement: -Redis Connection - Đăng ký IConnectionMultiplexer trong DI -Distributed Caching Service - Sử dụng IDistributedCache -Token Caching - Cache refresh tokens, blacklist tokens -Session Caching - User sessions và permissions +- Social Login - Google, Facebook, etc. \ No newline at end of file diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 470836b8..88f5225d 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -134,6 +134,71 @@ services: - "traefik.http.services.iam-service.loadbalancer.healthcheck.path=/health" - "traefik.http.services.iam-service.loadbalancer.healthcheck.interval=10s" + # Storage Service .NET - File Storage Management + storage-service: + build: + context: ../.. + dockerfile: services/storage-service-net/Dockerfile + container_name: storage-service-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL:-Host=localhost;Port=5432;Database=storage_db;Username=postgres;Password=postgres} + - Storage__Provider=${STORAGE_PROVIDER:-minio} + - Storage__DefaultBucket=${STORAGE_DEFAULT_BUCKET:-storage} + - Storage__MinIO__Endpoint=minio:9000 + - Storage__MinIO__AccessKey=${MINIO_ACCESS_KEY:-minioadmin} + - Storage__MinIO__SecretKey=${MINIO_SECRET_KEY:-minioadmin} + - Storage__MinIO__UseSSL=false + - IamService__BaseUrl=http://iam-service:5001 + - IamService__ServiceName=storage-service + ports: + - "5002:8080" + depends_on: + redis: + condition: service_healthy + minio: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.storage-service.rule=PathPrefix(`/api/v1/files`) || PathPrefix(`/api/v1/quota`)" + - "traefik.http.routers.storage-service.entrypoints=web" + - "traefik.http.services.storage-service.loadbalancer.server.port=8080" + - "traefik.http.services.storage-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.storage-service.loadbalancer.healthcheck.interval=10s" + + # MinIO - S3-compatible Object Storage + minio: + image: minio/minio:latest + container_name: minio-local + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} + ports: + - "9000:9000" # API port + - "9001:9001" # Console port + volumes: + - minio_data:/data + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + # =========================================================================== # FRONTEND APPLICATIONS (Temporarily disabled) # =========================================================================== @@ -237,6 +302,8 @@ services: volumes: redis_data: driver: local + minio_data: + driver: local # prometheus_data: # driver: local # grafana_data: diff --git a/docs/en/architecture/security-architecture.md b/docs/en/architecture/security-architecture.md index 6f137d72..38f7e7a3 100644 --- a/docs/en/architecture/security-architecture.md +++ b/docs/en/architecture/security-architecture.md @@ -89,10 +89,24 @@ sequenceDiagram - Refresh: Database SHA-256 hash - Rotation: New refresh token on each use -**4. MFA Support**: -- TOTP (Time-based One-Time Password) -- Backup codes (10 single-use) -- Recovery email verification +**4. MFA Support (Two-Factor Authentication)**: +- TOTP (Time-based One-Time Password) using RFC 6238 +- QR code generation for authenticator apps (Google Authenticator, Authy) +- Recovery codes (10 single-use codes) +- Secret key storage: UserManager.SetAuthenticationTokenAsync + +**5. Email Verification**: +- SMTP-based verification emails via MailKit +- Token generation using UserManager.GenerateEmailConfirmationTokenAsync +- Verification link with token and userId +- EmailConfirmed flag set true upon confirmation + +**6. Social Login (OAuth2 Providers)**: +- Google OAuth 2.0 integration +- Facebook OAuth integration +- Account linking for existing users (by email match) +- Auto email confirmation for social logins +- Provider info stored via UserManager.AddLoginAsync ## Authorization Model diff --git a/docs/en/guides/iam-authentication.md b/docs/en/guides/iam-authentication.md index e0f1a98c..70f32c14 100644 --- a/docs/en/guides/iam-authentication.md +++ b/docs/en/guides/iam-authentication.md @@ -4,10 +4,13 @@ ## Overview -The IAM Service provides OAuth2/OIDC authentication using OpenIddict: +The IAM Service provides OAuth2/OIDC authentication using Duende IdentityServer: - **Password Grant** - User login with email/password - **Refresh Token** - Token renewal without re-authentication - **Client Credentials** - Service-to-service authentication +- **Email Verification** - SMTP-based email confirmation +- **Two-Factor Authentication (2FA)** - TOTP with QR code and recovery codes +- **Social Login** - Google and Facebook OAuth integration ## Quick Start @@ -142,6 +145,29 @@ curl -X POST http://localhost:5001/connect/token \ | `POST` | `/api/v1/auth/change-password` | Change password (auth required) | | `POST` | `/api/v1/auth/logout` | Revoke tokens (auth required) | +### Email Verification + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/auth/send-verification-email` | Send email verification link (auth required) | +| `POST` | `/api/v1/auth/confirm-email` | Confirm email with token | + +### Two-Factor Authentication (2FA) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/auth/2fa/enable` | Enable 2FA (get QR code) (auth required) | +| `POST` | `/api/v1/auth/2fa/verify` | Verify TOTP code & activate (auth required) | +| `POST` | `/api/v1/auth/2fa/disable` | Disable 2FA (auth required) | + +### Social Login + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/auth/external-login/{provider}` | Initiate OAuth flow (Google/Facebook) | +| `GET` | `/api/v1/auth/external-callback` | Handle OAuth callback | +| `GET` | `/api/v1/auth/linked-accounts` | Get linked OAuth providers (auth required) | + ### User Management | Method | Endpoint | Description | @@ -197,6 +223,94 @@ client.DefaultRequestHeaders.Authorization = var user = await client.GetFromJsonAsync("/api/v1/users/me"); ``` +## Email Verification + +### Send Verification Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' +``` + +### Confirm Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/confirm-email \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-guid", "token": "confirmation-token"}' +``` + +## Two-Factor Authentication (2FA) + +### Enable 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "secretKey": "JBSWY3DPEHPK3PXP", + "qrCodeBase64": "data:image/png;base64,...", + "recoveryCodes": ["code1", "code2", "code3"] + } +} +``` + +### Verify 2FA Code + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +### Disable 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +## Social Login + +### Initiate OAuth Flow + +Redirect user to: +``` +GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback +GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback +``` + +### Get Linked Accounts + +```bash +curl http://localhost:5001/api/v1/auth/linked-accounts \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "linkedProviders": [ + {"provider": "Google", "providerDisplayName": "Google"}, + {"provider": "Facebook", "providerDisplayName": "Facebook"} + ] + } +} +``` + ## Error Responses ### Common Errors diff --git a/docs/vi/architecture/security-architecture.md b/docs/vi/architecture/security-architecture.md index 74a17dde..ec6f7459 100644 --- a/docs/vi/architecture/security-architecture.md +++ b/docs/vi/architecture/security-architecture.md @@ -112,10 +112,24 @@ sequenceDiagram - Refresh: Database SHA-256 hash - Rotation: Refresh token mới mỗi lần sử dụng -**4. MFA Support**: -- TOTP (Time-based One-Time Password) -- Backup codes (10 single-use) -- Recovery email verification +**4. MFA Support (Xác thực Hai yếu tố)**: +- TOTP (RFC 6238) cho authenticator apps +- QR code để thiết lập (Google Authenticator, Authy) +- Recovery codes (10 mã dùng một lần) +- Secret key lưu qua UserManager.SetAuthenticationTokenAsync + +**5. Email Verification (Xác minh Email)**: +- Gửi email xác minh qua SMTP (MailKit) +- Token generation: UserManager.GenerateEmailConfirmationTokenAsync +- Link xác minh với token và userId +- Đặt EmailConfirmed = true khi xác nhận + +**6. Social Login (Đăng nhập Mạng xã hội)**: +- Tích hợp Google OAuth 2.0 +- Tích hợp Facebook OAuth +- Liên kết tài khoản cho users hiện có (theo email) +- Tự động xác nhận email cho social logins +- Lưu provider info qua UserManager.AddLoginAsync ### EN: Authentication Details @@ -135,10 +149,24 @@ sequenceDiagram - Refresh: Database SHA-256 hash - Rotation: New refresh token on each use -**4. MFA Support**: -- TOTP (Time-based One-Time Password) -- Backup codes (10 single-use) -- Recovery email verification +**4. MFA Support (Two-Factor Authentication)**: +- TOTP (Time-based One-Time Password) using RFC 6238 +- QR code generation for authenticator apps (Google Authenticator, Authy) +- Recovery codes (10 single-use codes) +- Secret key storage: UserManager.SetAuthenticationTokenAsync + +**5. Email Verification**: +- SMTP-based verification emails via MailKit +- Token generation using UserManager.GenerateEmailConfirmationTokenAsync +- Verification link with token and userId +- EmailConfirmed flag set true upon confirmation + +**6. Social Login (OAuth2 Providers)**: +- Google OAuth 2.0 integration +- Facebook OAuth integration +- Account linking for existing users (by email match) +- Auto email confirmation for social logins +- Provider info stored via UserManager.AddLoginAsync ## Mô hình Phân quyền / Authorization Model diff --git a/docs/vi/guides/iam-authentication.md b/docs/vi/guides/iam-authentication.md index 07b46371..6fec33cc 100644 --- a/docs/vi/guides/iam-authentication.md +++ b/docs/vi/guides/iam-authentication.md @@ -4,10 +4,13 @@ ## Tổng Quan -IAM Service cung cấp xác thực OAuth2/OIDC sử dụng OpenIddict: +IAM Service cung cấp xác thực OAuth2/OIDC sử dụng Duende IdentityServer: - **Password Grant** - Đăng nhập user với email/password - **Refresh Token** - Làm mới token mà không cần xác thực lại - **Client Credentials** - Xác thực service-to-service +- **Email Verification** - Xác minh email qua SMTP +- **Two-Factor Authentication (2FA)** - TOTP với QR code và recovery codes +- **Social Login** - Tích hợp OAuth Google và Facebook ## Bắt Đầu Nhanh @@ -142,6 +145,29 @@ curl -X POST http://localhost:5001/connect/token \ | `POST` | `/api/v1/auth/change-password` | Đổi mật khẩu (cần auth) | | `POST` | `/api/v1/auth/logout` | Thu hồi tokens (cần auth) | +### Xác Minh Email + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `POST` | `/api/v1/auth/send-verification-email` | Gửi link xác minh email (cần auth) | +| `POST` | `/api/v1/auth/confirm-email` | Xác nhận email với token | + +### Xác Thực Hai Yếu Tố (2FA) + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `POST` | `/api/v1/auth/2fa/enable` | Bật 2FA (lấy QR code) (cần auth) | +| `POST` | `/api/v1/auth/2fa/verify` | Xác minh mã TOTP & kích hoạt (cần auth) | +| `POST` | `/api/v1/auth/2fa/disable` | Tắt 2FA (cần auth) | + +### Đăng Nhập Mạng Xã Hội + +| Method | Endpoint | Mô tả | +|--------|----------|-------| +| `GET` | `/api/v1/auth/external-login/{provider}` | Khởi tạo OAuth flow (Google/Facebook) | +| `GET` | `/api/v1/auth/external-callback` | Xử lý OAuth callback | +| `GET` | `/api/v1/auth/linked-accounts` | Lấy danh sách OAuth providers đã liên kết (cần auth) | + ### Quản Lý User | Method | Endpoint | Mô tả | @@ -197,6 +223,94 @@ client.DefaultRequestHeaders.Authorization = var user = await client.GetFromJsonAsync("/api/v1/users/me"); ``` +## Xác Minh Email + +### Gửi Email Xác Minh + +```bash +curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' +``` + +### Xác Nhận Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/confirm-email \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-guid", "token": "confirmation-token"}' +``` + +## Xác Thực Hai Yếu Tố (2FA) + +### Bật 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "secretKey": "JBSWY3DPEHPK3PXP", + "qrCodeBase64": "data:image/png;base64,...", + "recoveryCodes": ["code1", "code2", "code3"] + } +} +``` + +### Xác Minh Mã 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +### Tắt 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +## Đăng Nhập Mạng Xã Hội + +### Khởi Tạo OAuth Flow + +Chuyển hướng user đến: +``` +GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback +GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback +``` + +### Lấy Danh Sách Tài Khoản Liên Kết + +```bash +curl http://localhost:5001/api/v1/auth/linked-accounts \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "linkedProviders": [ + {"provider": "Google", "providerDisplayName": "Google"}, + {"provider": "Facebook", "providerDisplayName": "Facebook"} + ] + } +} +``` + ## Xử Lý Lỗi ### Các Lỗi Thường Gặp diff --git a/services/iam-service-net/docs/en/ARCHITECTURE.md b/services/iam-service-net/docs/en/ARCHITECTURE.md index f6be85ec..d83445e2 100644 --- a/services/iam-service-net/docs/en/ARCHITECTURE.md +++ b/services/iam-service-net/docs/en/ARCHITECTURE.md @@ -10,6 +10,7 @@ graph TB WEB[Web App] MOB[Mobile App] SVC[Other Services] + SOCIAL[Google/Facebook] end subgraph "API Layer" @@ -35,16 +36,21 @@ graph TB CTX[Identity DbContext] REPO[Repositories] IDSERVER[Duende IdentityServer] + EMAIL[Email Service] + TOTP[2FA Service] + OAUTH[Social Login Service] end subgraph "External" DB[(PostgreSQL)] REDIS[(Redis)] + SMTP[SMTP Server] end WEB --> AUTH MOB --> AUTH SVC --> TOK + SOCIAL --> AUTH AUTH --> CMD AUTH --> QRY USR --> CMD @@ -59,11 +65,16 @@ graph TB IDSERVER --> CTX CTX --> DB CTX --> REDIS + EMAIL --> SMTP + OAUTH --> SOCIAL style AUTH fill:#4a90d9,stroke:#2d5986,color:#fff style USER fill:#50c878,stroke:#2d8659,color:#fff style DB fill:#ff6b6b,stroke:#c0392b,color:#fff style IDSERVER fill:#9b59b6,stroke:#7d3c98,color:#fff + style EMAIL fill:#e67e22,stroke:#d35400,color:#fff + style TOTP fill:#1abc9c,stroke:#16a085,color:#fff + style OAUTH fill:#3498db,stroke:#2980b9,color:#fff ``` ## OAuth2 Authentication Flow @@ -252,6 +263,8 @@ graph TD JWT[JWT Bearer Tokens] RS256[RS256 Signing] OIDC[IdentityServer] + MFA[2FA/TOTP] + SOCIAL[Social OAuth] end subgraph "Authorization" @@ -264,16 +277,104 @@ graph TD HASH[bcrypt Password Hash] HTTPS[HTTPS/TLS] CORS[CORS Policy] + EMAIL[Email Verification] end JWT --> RS256 RS256 --> OIDC RBAC --> CLAIMS CLAIMS --> POLICY + MFA --> JWT + SOCIAL --> JWT style JWT fill:#3498db,stroke:#2980b9,color:#fff style RBAC fill:#e74c3c,stroke:#c0392b,color:#fff style HASH fill:#2ecc71,stroke:#27ae60,color:#fff + style MFA fill:#9b59b6,stroke:#7d3c98,color:#fff + style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff +``` + +## Email Verification Flow + +```mermaid +sequenceDiagram + participant User + participant AuthController + participant EmailService + participant SMTP + participant Database + + Note over User,Database: Send Verification Email + + User->>AuthController: POST /send-verification-email + AuthController->>Database: Generate Token + Database-->>AuthController: Confirmation Token + AuthController->>EmailService: SendVerificationEmail() + EmailService->>SMTP: Send Email with Link + SMTP-->>User: Email with Verification Link + + Note over User,Database: Confirm Email + + User->>AuthController: POST /confirm-email
(userId, token) + AuthController->>Database: Validate Token + Database-->>AuthController: Token Valid + AuthController->>Database: Set EmailConfirmed = true + AuthController-->>User: Email Confirmed +``` + +## Two-Factor Authentication Flow + +```mermaid +sequenceDiagram + participant User + participant AuthController + participant TwoFactorService + participant Database + participant AuthenticatorApp + + Note over User,AuthenticatorApp: Enable 2FA + + User->>AuthController: POST /2fa/enable + AuthController->>TwoFactorService: GenerateSecretKey() + TwoFactorService-->>AuthController: Secret Key + AuthController->>TwoFactorService: GenerateQrCode() + TwoFactorService-->>AuthController: QR Code (Base64) + AuthController-->>User: Secret + QR Code + Recovery Codes + User->>AuthenticatorApp: Scan QR Code + + Note over User,AuthenticatorApp: Verify & Activate + + User->>AuthController: POST /2fa/verify (code) + AuthController->>TwoFactorService: ValidateCode(secret, code) + TwoFactorService-->>AuthController: Valid + AuthController->>Database: Store Secret & Enable 2FA + AuthController-->>User: 2FA Enabled +``` + +## Social Login Flow + +```mermaid +sequenceDiagram + participant User + participant Client + participant AuthController + participant OAuthProvider + participant SocialLoginService + participant Database + + Note over User,Database: OAuth Flow + + User->>Client: Click "Login with Google" + Client->>AuthController: GET /external-login/Google + AuthController->>OAuthProvider: Redirect to OAuth + OAuthProvider->>User: Login & Consent + User->>OAuthProvider: Approve + OAuthProvider->>AuthController: GET /external-callback (code) + AuthController->>SocialLoginService: ProcessExternalLogin() + SocialLoginService->>Database: Find/Create User + Database-->>SocialLoginService: User + SocialLoginService-->>AuthController: User + Tokens + AuthController-->>Client: Redirect with tokens ``` ## Health Checks diff --git a/services/iam-service-net/docs/en/README.md b/services/iam-service-net/docs/en/README.md index cadc666f..b9c4b237 100644 --- a/services/iam-service-net/docs/en/README.md +++ b/services/iam-service-net/docs/en/README.md @@ -10,6 +10,9 @@ This service provides OAuth2/OpenID Connect authentication and authorization: - **User Management** - Registration, profile, soft-delete - **Role-Based Access Control** - User roles and permissions - **Token Management** - Access (15 min), Refresh (7 days) tokens +- **Email Verification** - SMTP-based email confirmation +- **Two-Factor Authentication (2FA)** - TOTP with QR code setup +- **Social Login** - Google and Facebook OAuth integration - **CQRS Pattern** - MediatR for Commands/Queries - **Clean Architecture** - Domain, Infrastructure, API layers @@ -98,6 +101,29 @@ dotnet ef database update \ | `POST` | `/api/v1/auth/change-password` | Change password | ✅ | | `POST` | `/api/v1/auth/logout` | Logout (revoke tokens) | ✅ | +### Email Verification (`/api/v1/auth`) + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| `POST` | `/api/v1/auth/send-verification-email` | Send email verification link | ✅ | +| `POST` | `/api/v1/auth/confirm-email` | Confirm email with token | ❌ | + +### Two-Factor Authentication (`/api/v1/auth/2fa`) + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| `POST` | `/api/v1/auth/2fa/enable` | Enable 2FA (get QR code) | ✅ | +| `POST` | `/api/v1/auth/2fa/verify` | Verify TOTP code & activate | ✅ | +| `POST` | `/api/v1/auth/2fa/disable` | Disable 2FA | ✅ | + +### Social Login (`/api/v1/auth`) + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| `GET` | `/api/v1/auth/external-login/{provider}` | Initiate OAuth flow (Google/Facebook) | ❌ | +| `GET` | `/api/v1/auth/external-callback` | Handle OAuth callback | ❌ | +| `GET` | `/api/v1/auth/linked-accounts` | Get linked OAuth providers | ✅ | + ### User Management (`/api/v1/users`) | Method | Endpoint | Description | Auth | @@ -199,6 +225,94 @@ curl -X POST http://localhost:5001/connect/token \ | `refresh_token` | Token renewal | No (uses refresh token) | | `client_credentials` | Service-to-service | No | +## Email Verification + +### Send Verification Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' +``` + +### Confirm Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/confirm-email \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-guid", "token": "confirmation-token"}' +``` + +## Two-Factor Authentication (2FA) + +### Enable 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "secretKey": "JBSWY3DPEHPK3PXP", + "qrCodeBase64": "data:image/png;base64,...", + "recoveryCodes": ["code1", "code2", "code3", ...] + } +} +``` + +### Verify 2FA Code + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +### Disable 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +## Social Login + +### Initiate OAuth Flow + +Redirect user to: +``` +GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback +GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback +``` + +### Get Linked Accounts + +```bash +curl http://localhost:5001/api/v1/auth/linked-accounts \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "linkedProviders": [ + {"provider": "Google", "providerDisplayName": "Google"}, + {"provider": "Facebook", "providerDisplayName": "Facebook"} + ] + } +} +``` + ## Configuration ### Environment Variables diff --git a/services/iam-service-net/docs/vi/ARCHITECTURE.md b/services/iam-service-net/docs/vi/ARCHITECTURE.md index ef373b61..773be8d7 100644 --- a/services/iam-service-net/docs/vi/ARCHITECTURE.md +++ b/services/iam-service-net/docs/vi/ARCHITECTURE.md @@ -10,6 +10,7 @@ graph TB WEB[Web App] MOB[Mobile App] SVC[Các Services khác] + SOCIAL[Google/Facebook] end subgraph "Lớp API" @@ -35,16 +36,21 @@ graph TB CTX[Identity DbContext] REPO[Repositories] IDSERVER[Duende IdentityServer] + EMAIL[Email Service] + TOTP[2FA Service] + OAUTH[Social Login Service] end subgraph "External" DB[(PostgreSQL)] REDIS[(Redis)] + SMTP[SMTP Server] end WEB --> AUTH MOB --> AUTH SVC --> TOK + SOCIAL --> AUTH AUTH --> CMD AUTH --> QRY USR --> CMD @@ -59,11 +65,16 @@ graph TB IDSERVER --> CTX CTX --> DB CTX --> REDIS + EMAIL --> SMTP + OAUTH --> SOCIAL style AUTH fill:#4a90d9,stroke:#2d5986,color:#fff style USER fill:#50c878,stroke:#2d8659,color:#fff style DB fill:#ff6b6b,stroke:#c0392b,color:#fff style IDSERVER fill:#9b59b6,stroke:#7d3c98,color:#fff + style EMAIL fill:#e67e22,stroke:#d35400,color:#fff + style TOTP fill:#1abc9c,stroke:#16a085,color:#fff + style OAUTH fill:#3498db,stroke:#2980b9,color:#fff ``` ## Luồng Xác Thực OAuth2 @@ -252,6 +263,8 @@ graph TD JWT[JWT Bearer Tokens] RS256[RS256 Signing] OIDC[IdentityServer] + MFA[2FA/TOTP] + SOCIAL[Social OAuth] end subgraph "Authorization" @@ -264,16 +277,104 @@ graph TD HASH[bcrypt Password Hash] HTTPS[HTTPS/TLS] CORS[CORS Policy] + EMAIL[Email Verification] end JWT --> RS256 RS256 --> OIDC RBAC --> CLAIMS CLAIMS --> POLICY + MFA --> JWT + SOCIAL --> JWT style JWT fill:#3498db,stroke:#2980b9,color:#fff style RBAC fill:#e74c3c,stroke:#c0392b,color:#fff style HASH fill:#2ecc71,stroke:#27ae60,color:#fff + style MFA fill:#9b59b6,stroke:#7d3c98,color:#fff + style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff +``` + +## Luồng Xác Thực Email + +```mermaid +sequenceDiagram + participant User + participant AuthController + participant EmailService + participant SMTP + participant Database + + Note over User,Database: Gửi Email Xác Thực + + User->>AuthController: POST /send-verification-email + AuthController->>Database: Tạo Token + Database-->>AuthController: Confirmation Token + AuthController->>EmailService: SendVerificationEmail() + EmailService->>SMTP: Gửi Email với Link + SMTP-->>User: Email với Link Xác Thực + + Note over User,Database: Xác Nhận Email + + User->>AuthController: POST /confirm-email
(userId, token) + AuthController->>Database: Kiểm tra Token + Database-->>AuthController: Token hợp lệ + AuthController->>Database: Set EmailConfirmed = true + AuthController-->>User: Email đã xác nhận +``` + +## Luồng Xác Thực Hai Yếu Tố (2FA) + +```mermaid +sequenceDiagram + participant User + participant AuthController + participant TwoFactorService + participant Database + participant AuthenticatorApp + + Note over User,AuthenticatorApp: Bật 2FA + + User->>AuthController: POST /2fa/enable + AuthController->>TwoFactorService: GenerateSecretKey() + TwoFactorService-->>AuthController: Secret Key + AuthController->>TwoFactorService: GenerateQrCode() + TwoFactorService-->>AuthController: QR Code (Base64) + AuthController-->>User: Secret + QR Code + Recovery Codes + User->>AuthenticatorApp: Quét QR Code + + Note over User,AuthenticatorApp: Xác Minh & Kích Hoạt + + User->>AuthController: POST /2fa/verify (code) + AuthController->>TwoFactorService: ValidateCode(secret, code) + TwoFactorService-->>AuthController: Hợp lệ + AuthController->>Database: Lưu Secret & Bật 2FA + AuthController-->>User: 2FA đã được bật +``` + +## Luồng Đăng Nhập Mạng Xã Hội + +```mermaid +sequenceDiagram + participant User + participant Client + participant AuthController + participant OAuthProvider + participant SocialLoginService + participant Database + + Note over User,Database: OAuth Flow + + User->>Client: Click "Đăng nhập với Google" + Client->>AuthController: GET /external-login/Google + AuthController->>OAuthProvider: Redirect đến OAuth + OAuthProvider->>User: Login & Đồng ý + User->>OAuthProvider: Chấp thuận + OAuthProvider->>AuthController: GET /external-callback (code) + AuthController->>SocialLoginService: ProcessExternalLogin() + SocialLoginService->>Database: Tìm/Tạo User + Database-->>SocialLoginService: User + SocialLoginService-->>AuthController: User + Tokens + AuthController-->>Client: Redirect với tokens ``` ## Health Checks diff --git a/services/iam-service-net/docs/vi/README.md b/services/iam-service-net/docs/vi/README.md index 8f2928f5..1decc2c5 100644 --- a/services/iam-service-net/docs/vi/README.md +++ b/services/iam-service-net/docs/vi/README.md @@ -10,6 +10,9 @@ IAM Service cung cấp các chức năng quản lý danh tính và truy cập: - **User Management** - CRUD operations cho users - **Password Management** - Đổi mật khẩu - **Token Management** - Issue, refresh, revoke tokens +- **Email Verification** - Xác thực email qua SMTP +- **2FA/MFA** - Xác thực hai yếu tố với TOTP +- **Social Login** - Đăng nhập qua Google và Facebook - **CQRS Pattern** - MediatR cho Commands/Queries - **Clean Architecture** - Domain, Infrastructure, API layers @@ -111,6 +114,29 @@ dotnet ef migrations list \ | `POST` | `/api/v1/auth/change-password` | Đổi mật khẩu | ✅ | | `POST` | `/api/v1/auth/logout` | Đăng xuất (revoke tokens) | ✅ | +### Email Verification (`/api/v1/auth`) + +| Method | Endpoint | Mô Tả | Auth | +|--------|----------|-------|------| +| `POST` | `/api/v1/auth/send-verification-email` | Gửi email xác thực | ✅ | +| `POST` | `/api/v1/auth/confirm-email` | Xác nhận email với token | ❌ | + +### Xác Thực Hai Yếu Tố (`/api/v1/auth/2fa`) + +| Method | Endpoint | Mô Tả | Auth | +|--------|----------|-------|------| +| `POST` | `/api/v1/auth/2fa/enable` | Bật 2FA (lấy QR code) | ✅ | +| `POST` | `/api/v1/auth/2fa/verify` | Xác minh mã TOTP & kích hoạt | ✅ | +| `POST` | `/api/v1/auth/2fa/disable` | Tắt 2FA | ✅ | + +### Đăng Nhập Mạng Xã Hội (`/api/v1/auth`) + +| Method | Endpoint | Mô Tả | Auth | +|--------|----------|-------|------| +| `GET` | `/api/v1/auth/external-login/{provider}` | Bắt đầu OAuth flow (Google/Facebook) | ❌ | +| `GET` | `/api/v1/auth/external-callback` | Xử lý OAuth callback | ❌ | +| `GET` | `/api/v1/auth/linked-accounts` | Lấy danh sách providers đã liên kết | ✅ | + ### User Management (`/api/v1/users`) | Method | Endpoint | Mô Tả | Auth | @@ -215,6 +241,94 @@ curl -X POST http://localhost:5001/connect/token \ -d "scope=api" ``` +## Xác Thực Email + +### Gửi Email Xác Thực + +```bash +curl -X POST http://localhost:5001/api/v1/auth/send-verification-email \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' +``` + +### Xác Nhận Email + +```bash +curl -X POST http://localhost:5001/api/v1/auth/confirm-email \ + -H "Content-Type: application/json" \ + -d '{"userId": "user-guid", "token": "confirmation-token"}' +``` + +## Xác Thực Hai Yếu Tố (2FA) + +### Bật 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/enable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "secretKey": "JBSWY3DPEHPK3PXP", + "qrCodeBase64": "data:image/png;base64,...", + "recoveryCodes": ["code1", "code2", "code3", ...] + } +} +``` + +### Xác Minh Mã 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/verify \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +### Tắt 2FA + +```bash +curl -X POST http://localhost:5001/api/v1/auth/2fa/disable \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "123456"}' +``` + +## Đăng Nhập Mạng Xã Hội + +### Bắt Đầu OAuth Flow + +Chuyển hướng user tới: +``` +GET http://localhost:5001/api/v1/auth/external-login/Google?returnUrl=http://your-app/callback +GET http://localhost:5001/api/v1/auth/external-login/Facebook?returnUrl=http://your-app/callback +``` + +### Lấy Tài Khoản Đã Liên Kết + +```bash +curl http://localhost:5001/api/v1/auth/linked-accounts \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** +```json +{ + "success": true, + "data": { + "linkedProviders": [ + {"provider": "Google", "providerDisplayName": "Google"}, + {"provider": "Facebook", "providerDisplayName": "Facebook"} + ] + } +} +``` + ## Swagger UI Sau khi chạy service, truy cập Swagger UI tại: diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommand.cs new file mode 100644 index 00000000..d9603d1a --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommand.cs @@ -0,0 +1,24 @@ +// EN: Command to confirm email +// VI: Command để xác nhận email + +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to confirm email with token. +/// VI: Command để xác nhận email với token. +/// +public record ConfirmEmailCommand( + string Email, + string Token +) : IRequest; + +/// +/// EN: Result of email confirmation. +/// VI: Kết quả xác nhận email. +/// +public record ConfirmEmailResult( + bool Success, + string Message +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommandHandler.cs new file mode 100644 index 00000000..866db25d --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ConfirmEmailCommandHandler.cs @@ -0,0 +1,66 @@ +// EN: Handler for ConfirmEmailCommand +// VI: Handler cho ConfirmEmailCommand + +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for ConfirmEmailCommand. +/// VI: Handler cho ConfirmEmailCommand. +/// +public class ConfirmEmailCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public ConfirmEmailCommandHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task Handle( + ConfirmEmailCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Confirming email for {Email}", request.Email); + + // EN: Find user by email + // VI: Tìm user theo email + var user = await _userManager.FindByEmailAsync(request.Email); + if (user == null) + { + _logger.LogWarning("User not found with email {Email}", request.Email); + return new ConfirmEmailResult(false, "Invalid email or token."); + } + + // EN: Check if email is already confirmed + // VI: Kiểm tra xem email đã được xác nhận chưa + if (user.EmailConfirmed) + { + _logger.LogInformation("Email {Email} is already confirmed", request.Email); + return new ConfirmEmailResult(true, "Email is already verified."); + } + + // EN: Confirm email with token + // VI: Xác nhận email với token + var result = await _userManager.ConfirmEmailAsync(user, request.Token); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogWarning("Failed to confirm email for {Email}: {Errors}", request.Email, errors); + return new ConfirmEmailResult(false, "Invalid or expired token."); + } + + _logger.LogInformation("Email confirmed successfully for {Email}", request.Email); + + return new ConfirmEmailResult(true, "Email confirmed successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommand.cs new file mode 100644 index 00000000..00bbe1eb --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommand.cs @@ -0,0 +1,24 @@ +// EN: Command to disable 2FA +// VI: Command để tắt 2FA + +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to disable 2FA for user. +/// VI: Command để tắt 2FA cho user. +/// +public record Disable2FACommand( + Guid UserId, + string Code +) : IRequest; + +/// +/// EN: Result of disabling 2FA. +/// VI: Kết quả tắt 2FA. +/// +public record Disable2FAResult( + bool Success, + string Message +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommandHandler.cs new file mode 100644 index 00000000..ff7933dd --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Disable2FACommandHandler.cs @@ -0,0 +1,98 @@ +// EN: Handler for Disable2FACommand +// VI: Handler cho Disable2FACommand + +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure.TwoFactor; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for Disable2FACommand. +/// VI: Handler cho Disable2FACommand. +/// +public class Disable2FACommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ITwoFactorService _twoFactorService; + private readonly ILogger _logger; + + public Disable2FACommandHandler( + UserManager userManager, + ITwoFactorService twoFactorService, + ILogger logger) + { + _userManager = userManager; + _twoFactorService = twoFactorService; + _logger = logger; + } + + public async Task Handle( + Disable2FACommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Disabling 2FA for user {UserId}", request.UserId); + + // EN: Find user by ID + // VI: Tìm user theo ID + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogWarning("User not found with ID {UserId}", request.UserId); + return new Disable2FAResult(false, "User not found."); + } + + // EN: Check if 2FA is enabled + // VI: Kiểm tra xem 2FA có được bật không + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + { + _logger.LogInformation("2FA is not enabled for user {UserId}", request.UserId); + return new Disable2FAResult(false, "Two-factor authentication is not enabled."); + } + + // EN: Get secret key and validate code + // VI: Lấy secret key và xác minh mã + var secretKey = await _userManager.GetAuthenticationTokenAsync( + user, + "[TwoFactor]", + "SecretKey"); + + if (string.IsNullOrEmpty(secretKey)) + { + _logger.LogWarning("No 2FA secret key found for user {UserId}", request.UserId); + return new Disable2FAResult(false, "2FA configuration not found."); + } + + // EN: Validate the code before disabling + // VI: Xác minh mã trước khi tắt + var isValid = _twoFactorService.ValidateCode(secretKey, request.Code); + + if (!isValid) + { + _logger.LogWarning("Invalid 2FA code for user {UserId}", request.UserId); + return new Disable2FAResult(false, "Invalid verification code."); + } + + // EN: Disable 2FA for user + // VI: Tắt 2FA cho user + var result = await _userManager.SetTwoFactorEnabledAsync(user, false); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogWarning("Failed to disable 2FA for user {UserId}: {Errors}", request.UserId, errors); + return new Disable2FAResult(false, $"Failed to disable 2FA: {errors}"); + } + + // EN: Remove secret key and recovery codes + // VI: Xóa secret key và mã khôi phục + await _userManager.RemoveAuthenticationTokenAsync(user, "[TwoFactor]", "SecretKey"); + await _userManager.RemoveAuthenticationTokenAsync(user, "[TwoFactor]", "RecoveryCodes"); + + _logger.LogInformation("2FA disabled successfully for user {UserId}", request.UserId); + + return new Disable2FAResult(true, "Two-factor authentication disabled successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommand.cs new file mode 100644 index 00000000..d54f0443 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommand.cs @@ -0,0 +1,26 @@ +// EN: Command to enable 2FA +// VI: Command để bật 2FA + +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to enable 2FA for user. +/// VI: Command để bật 2FA cho user. +/// +public record Enable2FACommand( + Guid UserId +) : IRequest; + +/// +/// EN: Result of enabling 2FA. +/// VI: Kết quả bật 2FA. +/// +public record Enable2FAResult( + bool Success, + string SecretKey, + string QrCodeBase64, + string ManualEntryKey, + string[] RecoveryCodes +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommandHandler.cs new file mode 100644 index 00000000..5ee4d9a0 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Enable2FACommandHandler.cs @@ -0,0 +1,92 @@ +// EN: Handler for Enable2FACommand +// VI: Handler cho Enable2FACommand + +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure.TwoFactor; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for Enable2FACommand. +/// VI: Handler cho Enable2FACommand. +/// +public class Enable2FACommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ITwoFactorService _twoFactorService; + private readonly ILogger _logger; + + public Enable2FACommandHandler( + UserManager userManager, + ITwoFactorService twoFactorService, + ILogger logger) + { + _userManager = userManager; + _twoFactorService = twoFactorService; + _logger = logger; + } + + public async Task Handle( + Enable2FACommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Enabling 2FA for user {UserId}", request.UserId); + + // EN: Find user by ID + // VI: Tìm user theo ID + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogWarning("User not found with ID {UserId}", request.UserId); + throw new InvalidOperationException("User not found"); + } + + // EN: Check if 2FA is already enabled + // VI: Kiểm tra xem 2FA đã được bật chưa + if (await _userManager.GetTwoFactorEnabledAsync(user)) + { + _logger.LogInformation("2FA is already enabled for user {UserId}", request.UserId); + throw new InvalidOperationException("Two-factor authentication is already enabled."); + } + + // EN: Generate new secret key + // VI: Tạo secret key mới + var secretKey = _twoFactorService.GenerateSecretKey(); + + // EN: Store the key temporarily (will be confirmed after verification) + // VI: Lưu key tạm thời (sẽ được xác nhận sau khi xác minh) + await _userManager.SetAuthenticationTokenAsync( + user, + "[TwoFactor]", + "PendingSecretKey", + secretKey); + + // EN: Generate QR code + // VI: Tạo QR code + var qrCodeBase64 = _twoFactorService.GenerateQrCodeBase64(user.Email!, secretKey); + + // EN: Generate recovery codes + // VI: Tạo mã khôi phục + var recoveryCodes = _twoFactorService.GenerateRecoveryCodes(10).ToArray(); + + // EN: Store recovery codes (hashed) + // VI: Lưu mã khôi phục (đã hash) + await _userManager.SetAuthenticationTokenAsync( + user, + "[TwoFactor]", + "RecoveryCodes", + string.Join(";", recoveryCodes)); + + _logger.LogInformation("2FA setup initiated for user {UserId}", request.UserId); + + return new Enable2FAResult( + Success: true, + SecretKey: secretKey, + QrCodeBase64: qrCodeBase64, + ManualEntryKey: secretKey, + RecoveryCodes: recoveryCodes); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommand.cs new file mode 100644 index 00000000..68b663ff --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommand.cs @@ -0,0 +1,70 @@ +// EN: Command to handle external login callback +// VI: Command xử lý callback external login +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to process external login from OAuth providers. +/// VI: Command xử lý external login từ OAuth providers. +/// +/// OAuth provider name (Google, Facebook) +/// User ID from the provider +/// User email from provider +/// User name from provider +/// Profile picture URL +public record ExternalLoginCommand( + string Provider, + string ProviderUserId, + string Email, + string? Name, + string? PictureUrl) : IRequest; + +/// +/// EN: Result of external login command. +/// VI: Kết quả của command external login. +/// +public record ExternalLoginResult +{ + /// + /// EN: Whether login was successful. + /// VI: Đăng nhập có thành công không. + /// + public bool Success { get; init; } + + /// + /// EN: Error message if login failed. + /// VI: Thông báo lỗi nếu đăng nhập thất bại. + /// + public string Message { get; init; } = string.Empty; + + /// + /// EN: User ID if successful. + /// VI: User ID nếu thành công. + /// + public Guid? UserId { get; init; } + + /// + /// EN: User email. + /// VI: Email của user. + /// + public string? Email { get; init; } + + /// + /// EN: Whether this is a new user registration. + /// VI: Đây có phải đăng ký user mới không. + /// + public bool IsNewUser { get; init; } + + /// + /// EN: Access token for the user. + /// VI: Access token cho user. + /// + public string? AccessToken { get; init; } + + /// + /// EN: Refresh token for the user. + /// VI: Refresh token cho user. + /// + public string? RefreshToken { get; init; } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommandHandler.cs new file mode 100644 index 00000000..71f76139 --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/ExternalLoginCommandHandler.cs @@ -0,0 +1,97 @@ +// EN: Handler for external login command +// VI: Handler cho command external login +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure.SocialLogin; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for processing external login from OAuth providers. +/// VI: Handler xử lý external login từ OAuth providers. +/// +public class ExternalLoginCommandHandler : IRequestHandler +{ + private readonly ISocialLoginService _socialLoginService; + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public ExternalLoginCommandHandler( + ISocialLoginService socialLoginService, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _socialLoginService = socialLoginService; + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + public async Task Handle(ExternalLoginCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation( + "EN: Processing external login for {Provider} / VI: Xử lý external login cho {Provider}", + request.Provider); + + try + { + // EN: Process external login + // VI: Xử lý external login + var result = await _socialLoginService.ProcessExternalLoginAsync( + request.Provider, + request.ProviderUserId, + request.Email, + request.Name, + request.PictureUrl); + + if (!result.Success || result.User == null) + { + _logger.LogWarning( + "EN: External login failed: {Error} / VI: External login thất bại: {Error}", + result.ErrorMessage); + + return new ExternalLoginResult + { + Success = false, + Message = result.ErrorMessage ?? "External login failed" + }; + } + + // EN: Sign in the user + // VI: Đăng nhập user + await _signInManager.SignInAsync(result.User, isPersistent: false); + + _logger.LogInformation( + "EN: External login successful for user {UserId} / VI: External login thành công cho user {UserId}", + result.User.Id); + + // EN: Note: Token generation should be handled by the controller + // VI: Lưu ý: Tạo token nên được xử lý bởi controller + return new ExternalLoginResult + { + Success = true, + Message = result.IsNewUser + ? "Account created and logged in successfully" + : "Logged in successfully", + UserId = result.User.Id, + Email = result.User.Email, + IsNewUser = result.IsNewUser + }; + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error during external login / VI: Lỗi trong quá trình external login"); + + return new ExternalLoginResult + { + Success = false, + Message = "An error occurred during external authentication" + }; + } + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommand.cs new file mode 100644 index 00000000..1d546e5e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommand.cs @@ -0,0 +1,23 @@ +// EN: Command to send verification email +// VI: Command để gửi email xác thực + +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to send email verification link. +/// VI: Command để gửi link xác thực email. +/// +public record SendVerificationEmailCommand( + string Email +) : IRequest; + +/// +/// EN: Result of sending verification email. +/// VI: Kết quả gửi email xác thực. +/// +public record SendVerificationEmailResult( + bool Success, + string Message +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommandHandler.cs new file mode 100644 index 00000000..1860929e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/SendVerificationEmailCommandHandler.cs @@ -0,0 +1,69 @@ +// EN: Handler for SendVerificationEmailCommand +// VI: Handler cho SendVerificationEmailCommand + +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure.Email; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for SendVerificationEmailCommand. +/// VI: Handler cho SendVerificationEmailCommand. +/// +public class SendVerificationEmailCommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailService _emailService; + private readonly ILogger _logger; + + public SendVerificationEmailCommandHandler( + UserManager userManager, + IEmailService emailService, + ILogger logger) + { + _userManager = userManager; + _emailService = emailService; + _logger = logger; + } + + public async Task Handle( + SendVerificationEmailCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Sending verification email to {Email}", request.Email); + + // EN: Find user by email + // VI: Tìm user theo email + var user = await _userManager.FindByEmailAsync(request.Email); + if (user == null) + { + _logger.LogWarning("User not found with email {Email}", request.Email); + // EN: Return success to prevent email enumeration attacks + // VI: Trả về success để ngăn tấn công liệt kê email + return new SendVerificationEmailResult(true, "If the email exists, a verification link has been sent."); + } + + // EN: Check if email is already confirmed + // VI: Kiểm tra xem email đã được xác nhận chưa + if (user.EmailConfirmed) + { + _logger.LogInformation("Email {Email} is already confirmed", request.Email); + return new SendVerificationEmailResult(true, "Email is already verified."); + } + + // EN: Generate email confirmation token + // VI: Tạo token xác nhận email + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + + // EN: Send verification email + // VI: Gửi email xác thực + await _emailService.SendVerificationEmailAsync(request.Email, token, cancellationToken); + + _logger.LogInformation("Verification email sent to {Email}", request.Email); + + return new SendVerificationEmailResult(true, "Verification email sent successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommand.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommand.cs new file mode 100644 index 00000000..00db7d7d --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommand.cs @@ -0,0 +1,24 @@ +// EN: Command to verify 2FA code and complete setup +// VI: Command để xác minh mã 2FA và hoàn tất cài đặt + +using MediatR; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Command to verify 2FA code and complete 2FA setup. +/// VI: Command để xác minh mã 2FA và hoàn tất cài đặt 2FA. +/// +public record Verify2FACommand( + Guid UserId, + string Code +) : IRequest; + +/// +/// EN: Result of 2FA verification. +/// VI: Kết quả xác minh 2FA. +/// +public record Verify2FAResult( + bool Success, + string Message +); diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommandHandler.cs new file mode 100644 index 00000000..d14c7b8c --- /dev/null +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/Verify2FACommandHandler.cs @@ -0,0 +1,100 @@ +// EN: Handler for Verify2FACommand +// VI: Handler cho Verify2FACommand + +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.UserAggregate; +using IamService.Infrastructure.TwoFactor; + +namespace IamService.API.Application.Commands.Auth; + +/// +/// EN: Handler for Verify2FACommand. +/// VI: Handler cho Verify2FACommand. +/// +public class Verify2FACommandHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly ITwoFactorService _twoFactorService; + private readonly ILogger _logger; + + public Verify2FACommandHandler( + UserManager userManager, + ITwoFactorService twoFactorService, + ILogger logger) + { + _userManager = userManager; + _twoFactorService = twoFactorService; + _logger = logger; + } + + public async Task Handle( + Verify2FACommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Verifying 2FA code for user {UserId}", request.UserId); + + // EN: Find user by ID + // VI: Tìm user theo ID + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogWarning("User not found with ID {UserId}", request.UserId); + return new Verify2FAResult(false, "User not found."); + } + + // EN: Get pending secret key + // VI: Lấy secret key đang chờ + var pendingSecretKey = await _userManager.GetAuthenticationTokenAsync( + user, + "[TwoFactor]", + "PendingSecretKey"); + + if (string.IsNullOrEmpty(pendingSecretKey)) + { + _logger.LogWarning("No pending 2FA setup found for user {UserId}", request.UserId); + return new Verify2FAResult(false, "No pending 2FA setup found. Please enable 2FA first."); + } + + // EN: Validate the code + // VI: Xác minh mã + var isValid = _twoFactorService.ValidateCode(pendingSecretKey, request.Code); + + if (!isValid) + { + _logger.LogWarning("Invalid 2FA code for user {UserId}", request.UserId); + return new Verify2FAResult(false, "Invalid verification code."); + } + + // EN: Enable 2FA for user + // VI: Bật 2FA cho user + var result = await _userManager.SetTwoFactorEnabledAsync(user, true); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + _logger.LogWarning("Failed to enable 2FA for user {UserId}: {Errors}", request.UserId, errors); + return new Verify2FAResult(false, $"Failed to enable 2FA: {errors}"); + } + + // EN: Store the confirmed secret key + // VI: Lưu secret key đã xác nhận + await _userManager.SetAuthenticationTokenAsync( + user, + "[TwoFactor]", + "SecretKey", + pendingSecretKey); + + // EN: Remove pending secret key + // VI: Xóa secret key đang chờ + await _userManager.RemoveAuthenticationTokenAsync( + user, + "[TwoFactor]", + "PendingSecretKey"); + + _logger.LogInformation("2FA enabled successfully for user {UserId}", request.UserId); + + return new Verify2FAResult(true, "Two-factor authentication enabled successfully."); + } +} diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs index 15d10ebb..40526306 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs @@ -217,6 +217,332 @@ public class AuthController : ControllerBase return Ok(new LogoutResponse { Success = result.Success, Message = result.Message }); } + + #region Email Verification Endpoints + + /// + /// EN: Send email verification link. + /// VI: Gửi link xác thực email. + /// + /// Email address to verify + /// Cancellation token + /// Result of sending verification email + [HttpPost("send-verification-email")] + [SwaggerOperation( + Summary = "Send verification email", + Description = "Sends an email verification link to the specified email address.", + OperationId = "SendVerificationEmail")] + [SwaggerResponse(StatusCodes.Status200OK, "Verification email sent")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid email")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SendVerificationEmail( + [FromBody, SwaggerRequestBody("Email address", Required = true)] SendVerificationEmailRequest request, + CancellationToken cancellationToken) + { + var command = new SendVerificationEmailCommand(request.Email); + var result = await _mediator.Send(command, cancellationToken); + return Ok(ApiResponse.Ok(result)); + } + + /// + /// EN: Confirm email with token. + /// VI: Xác nhận email với token. + /// + /// Email and token + /// Cancellation token + /// Result of email confirmation + [HttpPost("confirm-email")] + [SwaggerOperation( + Summary = "Confirm email", + Description = "Confirms user email with the provided token.", + OperationId = "ConfirmEmail")] + [SwaggerResponse(StatusCodes.Status200OK, "Email confirmed successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid or expired token")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ConfirmEmail( + [FromBody, SwaggerRequestBody("Email and token", Required = true)] ConfirmEmailRequest request, + CancellationToken cancellationToken) + { + var command = new ConfirmEmailCommand(request.Email, request.Token); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(ApiResponse.Fail("INVALID_TOKEN", result.Message)); + } + + return Ok(ApiResponse.Ok(result)); + } + + #endregion + + #region Two-Factor Authentication Endpoints + + /// + /// EN: Enable 2FA for current user. + /// VI: Bật 2FA cho user hiện tại. + /// + /// Cancellation token + /// QR code and recovery codes + [HttpPost("2fa/enable")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Enable 2FA", + Description = "Initiates 2FA setup. Returns QR code and recovery codes. Must be verified with /2fa/verify.", + OperationId = "Enable2FA")] + [SwaggerResponse(StatusCodes.Status200OK, "2FA setup initiated", typeof(Enable2FAResponse))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "2FA already enabled")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Enable2FA(CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + try + { + var command = new Enable2FACommand(userId); + var result = await _mediator.Send(command, cancellationToken); + + return Ok(ApiResponse.Ok(new Enable2FAResponse + { + QrCodeBase64 = result.QrCodeBase64, + ManualEntryKey = result.ManualEntryKey, + RecoveryCodes = result.RecoveryCodes + })); + } + catch (InvalidOperationException ex) + { + return BadRequest(ApiResponse.Fail("2FA_ALREADY_ENABLED", ex.Message)); + } + } + + /// + /// EN: Verify 2FA code and complete setup. + /// VI: Xác minh mã 2FA và hoàn tất cài đặt. + /// + /// 2FA verification code + /// Cancellation token + /// Result of 2FA verification + [HttpPost("2fa/verify")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Verify 2FA code", + Description = "Verifies the TOTP code and completes 2FA setup.", + OperationId = "Verify2FA")] + [SwaggerResponse(StatusCodes.Status200OK, "2FA enabled successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid code")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Verify2FA( + [FromBody, SwaggerRequestBody("2FA code", Required = true)] Verify2FARequest request, + CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + var command = new Verify2FACommand(userId, request.Code); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(ApiResponse.Fail("INVALID_CODE", result.Message)); + } + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// EN: Disable 2FA for current user. + /// VI: Tắt 2FA cho user hiện tại. + /// + /// Current 2FA code for verification + /// Cancellation token + /// Result of disabling 2FA + [HttpPost("2fa/disable")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Disable 2FA", + Description = "Disables 2FA for the current user. Requires verification with current 2FA code.", + OperationId = "Disable2FA")] + [SwaggerResponse(StatusCodes.Status200OK, "2FA disabled successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid code or 2FA not enabled")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Disable2FA( + [FromBody, SwaggerRequestBody("2FA code", Required = true)] Disable2FARequest request, + CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + var command = new Disable2FACommand(userId, request.Code); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(ApiResponse.Fail("DISABLE_FAILED", result.Message)); + } + + return Ok(ApiResponse.Ok(result)); + } + + #endregion + + #region Social Login Endpoints + + /// + /// EN: Initiate external login with Google or Facebook. + /// VI: Bắt đầu đăng nhập bên ngoài với Google hoặc Facebook. + /// + /// Provider name (Google, Facebook) + /// URL to redirect after login + /// Challenge result for external provider + [HttpGet("external-login/{provider}")] + [SwaggerOperation( + Summary = "Initiate external login", + Description = "Redirects user to external OAuth provider (Google or Facebook) for authentication.", + OperationId = "ExternalLogin")] + [SwaggerResponse(StatusCodes.Status302Found, "Redirecting to external provider")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid provider")] + public IActionResult ExternalLogin( + [FromRoute] string provider, + [FromQuery] string? returnUrl = null) + { + var validProviders = new[] { "Google", "Facebook" }; + if (!validProviders.Contains(provider, StringComparer.OrdinalIgnoreCase)) + { + return BadRequest(ApiResponse.Fail("INVALID_PROVIDER", $"Provider '{provider}' is not supported. Valid providers: Google, Facebook")); + } + + var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Auth", new { returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + + return Challenge(properties, provider); + } + + /// + /// EN: Handle callback from external OAuth provider. + /// VI: Xử lý callback từ OAuth provider bên ngoài. + /// + /// URL to redirect after login + /// Cancellation token + /// External login result with tokens + [HttpGet("external-callback")] + [SwaggerOperation( + Summary = "External login callback", + Description = "Handles callback from Google/Facebook OAuth. Creates or links user account.", + OperationId = "ExternalLoginCallback")] + [SwaggerResponse(StatusCodes.Status200OK, "Login successful", typeof(ExternalLoginResult))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "External login failed")] + public async Task ExternalLoginCallback( + [FromQuery] string? returnUrl = null, + CancellationToken cancellationToken = default) + { + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + _logger.LogWarning("EN: External login info is null / VI: External login info là null"); + return BadRequest(ApiResponse.Fail("EXTERNAL_LOGIN_FAILED", "Could not get external login information")); + } + + // EN: Extract user information from claims + // VI: Trích xuất thông tin user từ claims + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + var name = info.Principal.FindFirstValue(ClaimTypes.Name); + var pictureUrl = info.Principal.FindFirstValue("picture"); + + if (string.IsNullOrEmpty(email)) + { + return BadRequest(ApiResponse.Fail("EMAIL_REQUIRED", "Email is required for external login")); + } + + var command = new ExternalLoginCommand( + info.LoginProvider, + info.ProviderKey, + email, + name, + pictureUrl); + + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + { + return BadRequest(ApiResponse.Fail("EXTERNAL_LOGIN_FAILED", result.Message)); + } + + // EN: If returnUrl is specified, redirect to it with user info as query params + // VI: Nếu có returnUrl, redirect tới đó với thông tin user dưới dạng query params + if (!string.IsNullOrEmpty(returnUrl)) + { + var redirectUrl = $"{returnUrl}?userId={result.UserId}&email={result.Email}&isNewUser={result.IsNewUser}"; + return Redirect(redirectUrl); + } + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// EN: Get linked external accounts for current user. + /// VI: Lấy danh sách tài khoản bên ngoài đã liên kết của user hiện tại. + /// + /// Cancellation token + /// List of linked providers + [HttpGet("linked-accounts")] + [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [SwaggerOperation( + Summary = "Get linked accounts", + Description = "Returns list of external OAuth providers linked to current user's account.", + OperationId = "GetLinkedAccounts")] + [SwaggerResponse(StatusCodes.Status200OK, "Linked accounts retrieved")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetLinkedAccounts(CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(); + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return Unauthorized(); + } + + var logins = await _userManager.GetLoginsAsync(user); + var providers = logins.Select(l => new LinkedAccountInfo + { + Provider = l.LoginProvider, + ProviderDisplayName = l.ProviderDisplayName ?? l.LoginProvider + }).ToList(); + + return Ok(ApiResponse.Ok(new LinkedAccountsResponse + { + LinkedProviders = providers + })); + } + + #endregion } #region Request/Response Models @@ -339,3 +665,134 @@ public class LogoutResponse } #endregion + +#region Email Verification Request/Response Models + +/// +/// EN: Request body for sending verification email. +/// VI: Request body để gửi email xác thực. +/// +public class SendVerificationEmailRequest +{ + /// + /// EN: Email address to verify. + /// VI: Địa chỉ email cần xác thực. + /// + /// user@example.com + public string Email { get; set; } = string.Empty; +} + +/// +/// EN: Request body for confirming email. +/// VI: Request body để xác nhận email. +/// +public class ConfirmEmailRequest +{ + /// + /// EN: Email address. + /// VI: Địa chỉ email. + /// + /// user@example.com + public string Email { get; set; } = string.Empty; + + /// + /// EN: Verification token from email. + /// VI: Token xác thực từ email. + /// + public string Token { get; set; } = string.Empty; +} + +#endregion + +#region 2FA Request/Response Models + +/// +/// EN: Response for enabling 2FA. +/// VI: Response cho việc bật 2FA. +/// +public class Enable2FAResponse +{ + /// + /// EN: QR code as base64 image. + /// VI: QR code dạng ảnh base64. + /// + public string QrCodeBase64 { get; set; } = string.Empty; + + /// + /// EN: Manual entry key for authenticator apps. + /// VI: Key nhập thủ công cho ứng dụng authenticator. + /// + public string ManualEntryKey { get; set; } = string.Empty; + + /// + /// EN: Recovery codes for account recovery. + /// VI: Mã khôi phục cho tài khoản. + /// + public string[] RecoveryCodes { get; set; } = Array.Empty(); +} + +/// +/// EN: Request body for 2FA verification. +/// VI: Request body cho xác minh 2FA. +/// +public class Verify2FARequest +{ + /// + /// EN: TOTP code from authenticator app. + /// VI: Mã TOTP từ ứng dụng authenticator. + /// + /// 123456 + public string Code { get; set; } = string.Empty; +} + +/// +/// EN: Request body for disabling 2FA. +/// VI: Request body cho việc tắt 2FA. +/// +public class Disable2FARequest +{ + /// + /// EN: Current TOTP code for verification. + /// VI: Mã TOTP hiện tại để xác minh. + /// + /// 123456 + public string Code { get; set; } = string.Empty; +} + +#endregion + +#region Social Login Request/Response Models + +/// +/// EN: Response for linked accounts. +/// VI: Response cho các tài khoản đã liên kết. +/// +public class LinkedAccountsResponse +{ + /// + /// EN: List of linked OAuth providers. + /// VI: Danh sách các OAuth providers đã liên kết. + /// + public List LinkedProviders { get; set; } = new(); +} + +/// +/// EN: Information about a linked account. +/// VI: Thông tin về tài khoản đã liên kết. +/// +public class LinkedAccountInfo +{ + /// + /// EN: Provider name (Google, Facebook). + /// VI: Tên provider (Google, Facebook). + /// + public string Provider { get; set; } = string.Empty; + + /// + /// EN: Display name of the provider. + /// VI: Tên hiển thị của provider. + /// + public string ProviderDisplayName { get; set; } = string.Empty; +} + +#endregion diff --git a/services/iam-service-net/src/IamService.API/appsettings.json b/services/iam-service-net/src/IamService.API/appsettings.json index a001a7de..75024558 100644 --- a/services/iam-service-net/src/IamService.API/appsettings.json +++ b/services/iam-service-net/src/IamService.API/appsettings.json @@ -47,5 +47,30 @@ "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 }, + "Email": { + "SmtpServer": "smtp.mailgun.org", + "SmtpPort": 587, + "SmtpLogin": "your-mailgun-smtp-login", + "SmtpPassword": "your-mailgun-smtp-password", + "SenderEmail": "noreply@yourdomain.com", + "SenderName": "IAM Service", + "BaseUrl": "http://localhost:5001" + }, + "TwoFactor": { + "Issuer": "IAM Service", + "CodeLength": 6, + "ValidityPeriodSeconds": 30 + }, + "SocialLogin": { + "Google": { + "ClientId": "", + "ClientSecret": "" + }, + "Facebook": { + "AppId": "", + "AppSecret": "" + }, + "CallbackUrl": "/api/auth/external-callback" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index e09ce044..83831aac 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -8,8 +8,11 @@ using Microsoft.Extensions.DependencyInjection; using IamService.Domain.AggregatesModel.UserAggregate; using IamService.Domain.AggregatesModel.RoleAggregate; using IamService.Domain.SeedWork; +using IamService.Infrastructure.Email; using IamService.Infrastructure.IdentityServer; using IamService.Infrastructure.Repositories; +using IamService.Infrastructure.SocialLogin; +using IamService.Infrastructure.TwoFactor; namespace IamService.Infrastructure; @@ -167,6 +170,46 @@ public static class DependencyInjection services.AddSingleton(); } + // EN: Configure Email service + // VI: Cấu hình Email service + services.Configure(configuration.GetSection(EmailSettings.SectionName)); + services.AddScoped(); + + // EN: Configure 2FA service + // VI: Cấu hình 2FA service + services.Configure(configuration.GetSection(TwoFactorSettings.SectionName)); + services.AddScoped(); + + // EN: Configure Social Login service + // VI: Cấu hình Social Login service + services.Configure(configuration.GetSection("SocialLogin")); + services.AddScoped(); + + // EN: Configure Google and Facebook authentication + // VI: Cấu hình Google và Facebook authentication + var socialLoginSettings = new SocialLoginSettings(); + configuration.GetSection("SocialLogin").Bind(socialLoginSettings); + + if (!string.IsNullOrEmpty(socialLoginSettings.Google?.ClientId)) + { + services.AddAuthentication() + .AddGoogle(options => + { + options.ClientId = socialLoginSettings.Google.ClientId; + options.ClientSecret = socialLoginSettings.Google.ClientSecret; + }); + } + + if (!string.IsNullOrEmpty(socialLoginSettings.Facebook?.AppId)) + { + services.AddAuthentication() + .AddFacebook(options => + { + options.AppId = socialLoginSettings.Facebook.AppId; + options.AppSecret = socialLoginSettings.Facebook.AppSecret; + }); + } + return services; } } diff --git a/services/iam-service-net/src/IamService.Infrastructure/Email/EmailSettings.cs b/services/iam-service-net/src/IamService.Infrastructure/Email/EmailSettings.cs new file mode 100644 index 00000000..f3b43c19 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Email/EmailSettings.cs @@ -0,0 +1,55 @@ +// EN: Email settings configuration for SMTP +// VI: Cấu hình email settings cho SMTP + +namespace IamService.Infrastructure.Email; + +/// +/// EN: Email settings for SMTP configuration. +/// VI: Cấu hình SMTP cho email. +/// +public class EmailSettings +{ + public const string SectionName = "Email"; + + /// + /// EN: SMTP server address (e.g., smtp.mailgun.org). + /// VI: Địa chỉ SMTP server. + /// + public string SmtpServer { get; set; } = string.Empty; + + /// + /// EN: SMTP port (typically 587 for TLS). + /// VI: Port SMTP (thường là 587 cho TLS). + /// + public int SmtpPort { get; set; } = 587; + + /// + /// EN: SMTP login username. + /// VI: Tên đăng nhập SMTP. + /// + public string SmtpLogin { get; set; } = string.Empty; + + /// + /// EN: SMTP password. + /// VI: Mật khẩu SMTP. + /// + public string SmtpPassword { get; set; } = string.Empty; + + /// + /// EN: Sender email address. + /// VI: Địa chỉ email người gửi. + /// + public string SenderEmail { get; set; } = string.Empty; + + /// + /// EN: Sender display name. + /// VI: Tên hiển thị người gửi. + /// + public string SenderName { get; set; } = "IAM Service"; + + /// + /// EN: Base URL for verification links. + /// VI: URL cơ sở cho các link xác thực. + /// + public string BaseUrl { get; set; } = "http://localhost:5001"; +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Email/IEmailService.cs b/services/iam-service-net/src/IamService.Infrastructure/Email/IEmailService.cs new file mode 100644 index 00000000..f4395379 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Email/IEmailService.cs @@ -0,0 +1,35 @@ +// EN: Email service interface for sending emails +// VI: Interface email service để gửi email + +namespace IamService.Infrastructure.Email; + +/// +/// EN: Email service interface for sending various types of emails. +/// VI: Interface email service để gửi các loại email khác nhau. +/// +public interface IEmailService +{ + /// + /// EN: Send a generic email. + /// VI: Gửi email chung. + /// + Task SendEmailAsync(string to, string subject, string htmlBody, CancellationToken cancellationToken = default); + + /// + /// EN: Send email verification email with token. + /// VI: Gửi email xác thực với token. + /// + Task SendVerificationEmailAsync(string email, string token, CancellationToken cancellationToken = default); + + /// + /// EN: Send 2FA code via email. + /// VI: Gửi mã 2FA qua email. + /// + Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default); + + /// + /// EN: Send password reset email. + /// VI: Gửi email đặt lại mật khẩu. + /// + Task SendPasswordResetEmailAsync(string email, string token, CancellationToken cancellationToken = default); +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Email/SmtpEmailService.cs b/services/iam-service-net/src/IamService.Infrastructure/Email/SmtpEmailService.cs new file mode 100644 index 00000000..b5afc4a1 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Email/SmtpEmailService.cs @@ -0,0 +1,201 @@ +// EN: SMTP email service implementation using MailKit +// VI: Implementation email service sử dụng MailKit + +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace IamService.Infrastructure.Email; + +/// +/// EN: SMTP-based email service implementation using MailKit. +/// VI: Implementation email service dựa trên SMTP sử dụng MailKit. +/// +public class SmtpEmailService : IEmailService +{ + private readonly EmailSettings _settings; + private readonly ILogger _logger; + + public SmtpEmailService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + /// + public async Task SendEmailAsync(string to, string subject, string htmlBody, CancellationToken cancellationToken = default) + { + try + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_settings.SenderName, _settings.SenderEmail)); + message.To.Add(MailboxAddress.Parse(to)); + message.Subject = subject; + + var bodyBuilder = new BodyBuilder + { + HtmlBody = htmlBody + }; + message.Body = bodyBuilder.ToMessageBody(); + + using var client = new SmtpClient(); + + await client.ConnectAsync( + _settings.SmtpServer, + _settings.SmtpPort, + SecureSocketOptions.StartTls, + cancellationToken); + + await client.AuthenticateAsync( + _settings.SmtpLogin, + _settings.SmtpPassword, + cancellationToken); + + await client.SendAsync(message, cancellationToken); + await client.DisconnectAsync(true, cancellationToken); + + _logger.LogInformation("Email sent successfully to {To}, Subject: {Subject}", to, subject); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send email to {To}, Subject: {Subject}", to, subject); + throw; + } + } + + /// + public async Task SendVerificationEmailAsync(string email, string token, CancellationToken cancellationToken = default) + { + var verificationUrl = $"{_settings.BaseUrl}/api/v1/auth/confirm-email?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; + + var htmlBody = $@" + + + + + Email Verification + + + +
+
+

Email Verification

+
+
+

Hello,

+

Thank you for registering. Please click the button below to verify your email address:

+

+ Verify Email +

+

Or copy and paste this link into your browser:

+

{verificationUrl}

+

This link will expire in 24 hours.

+

If you did not create an account, please ignore this email.

+
+
+

© {DateTime.UtcNow.Year} IAM Service. All rights reserved.

+
+
+ +"; + + await SendEmailAsync(email, "Verify Your Email Address", htmlBody, cancellationToken); + } + + /// + public async Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default) + { + var htmlBody = $@" + + + + + 2FA Verification Code + + + +
+
+

Two-Factor Authentication

+
+
+

Hello,

+

Your verification code is:

+
{code}
+

This code will expire in 5 minutes.

+

If you did not request this code, please secure your account immediately.

+
+
+

© {DateTime.UtcNow.Year} IAM Service. All rights reserved.

+
+
+ +"; + + await SendEmailAsync(email, "Your 2FA Verification Code", htmlBody, cancellationToken); + } + + /// + public async Task SendPasswordResetEmailAsync(string email, string token, CancellationToken cancellationToken = default) + { + var resetUrl = $"{_settings.BaseUrl}/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; + + var htmlBody = $@" + + + + + Password Reset + + + +
+
+

Password Reset

+
+
+

Hello,

+

We received a request to reset your password. Click the button below to proceed:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+

{resetUrl}

+

This link will expire in 1 hour.

+

If you did not request a password reset, please ignore this email.

+
+
+

© {DateTime.UtcNow.Year} IAM Service. All rights reserved.

+
+
+ +"; + + await SendEmailAsync(email, "Reset Your Password", htmlBody, cancellationToken); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj index b8af21ed..906d9ad8 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj +++ b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj @@ -28,6 +28,13 @@ + + + + + + + @@ -38,6 +45,10 @@ + + + + diff --git a/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/ISocialLoginService.cs b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/ISocialLoginService.cs new file mode 100644 index 00000000..0f6eeba5 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/ISocialLoginService.cs @@ -0,0 +1,117 @@ +// EN: Interface for social login service +// VI: Interface cho social login service +using IamService.Domain.AggregatesModel.UserAggregate; + +namespace IamService.Infrastructure.SocialLogin; + +/// +/// EN: Result of external authentication. +/// VI: Kết quả xác thực bên ngoài. +/// +public record ExternalAuthResult +{ + /// + /// EN: Whether authentication was successful. + /// VI: Xác thực có thành công không. + /// + public bool Success { get; init; } + + /// + /// EN: Error message if authentication failed. + /// VI: Thông báo lỗi nếu xác thực thất bại. + /// + public string? ErrorMessage { get; init; } + + /// + /// EN: External provider name (Google, Facebook). + /// VI: Tên provider bên ngoài (Google, Facebook). + /// + public string? Provider { get; init; } + + /// + /// EN: User ID from external provider. + /// VI: User ID từ provider bên ngoài. + /// + public string? ProviderUserId { get; init; } + + /// + /// EN: User email from external provider. + /// VI: Email người dùng từ provider bên ngoài. + /// + public string? Email { get; init; } + + /// + /// EN: User name from external provider. + /// VI: Tên người dùng từ provider bên ngoài. + /// + public string? Name { get; init; } + + /// + /// EN: Profile picture URL. + /// VI: URL ảnh đại diện. + /// + public string? PictureUrl { get; init; } + + /// + /// EN: The user if already exists or was created. + /// VI: User nếu đã tồn tại hoặc được tạo mới. + /// + public ApplicationUser? User { get; init; } + + /// + /// EN: Whether this is a new user registration. + /// VI: Đây có phải là đăng ký người dùng mới không. + /// + public bool IsNewUser { get; init; } +} + +/// +/// EN: Interface for handling social login operations. +/// VI: Interface xử lý các thao tác social login. +/// +public interface ISocialLoginService +{ + /// + /// EN: Process external login callback and create/link user. + /// VI: Xử lý callback đăng nhập bên ngoài và tạo/liên kết user. + /// + /// Provider name (Google, Facebook) + /// User ID from provider + /// User email + /// User name + /// Profile picture URL + /// External authentication result + Task ProcessExternalLoginAsync( + string provider, + string providerUserId, + string email, + string? name, + string? pictureUrl); + + /// + /// EN: Link external account to existing user. + /// VI: Liên kết tài khoản bên ngoài với user hiện tại. + /// + /// Existing user ID + /// Provider name + /// User ID from provider + /// Success result + Task LinkExternalAccountAsync(Guid userId, string provider, string providerUserId); + + /// + /// EN: Unlink external account from user. + /// VI: Hủy liên kết tài khoản bên ngoài khỏi user. + /// + /// User ID + /// Provider name + /// Success result + Task UnlinkExternalAccountAsync(Guid userId, string provider); + + /// + /// EN: Get linked external accounts for user. + /// VI: Lấy danh sách tài khoản bên ngoài đã liên kết của user. + /// + /// User ID + /// List of provider names + Task> GetLinkedProvidersAsync(Guid userId); +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginService.cs b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginService.cs new file mode 100644 index 00000000..462b7589 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginService.cs @@ -0,0 +1,232 @@ +// EN: Implementation of social login service +// VI: Implementation của social login service +using IamService.Domain.AggregatesModel.UserAggregate; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace IamService.Infrastructure.SocialLogin; + +/// +/// EN: Service for handling social login operations. +/// VI: Service xử lý các thao tác social login. +/// +public class SocialLoginService : ISocialLoginService +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public SocialLoginService( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + /// + public async Task ProcessExternalLoginAsync( + string provider, + string providerUserId, + string email, + string? name, + string? pictureUrl) + { + _logger.LogInformation( + "EN: Processing external login for provider {Provider}, email {Email} / VI: Xử lý đăng nhập bên ngoài cho provider {Provider}, email {Email}", + provider, email); + + try + { + // EN: Try to find user by external login + // VI: Thử tìm user bằng external login + var user = await _userManager.FindByLoginAsync(provider, providerUserId); + + if (user != null) + { + _logger.LogInformation( + "EN: Found existing user by external login / VI: Tìm thấy user hiện có bằng external login"); + + return new ExternalAuthResult + { + Success = true, + Provider = provider, + ProviderUserId = providerUserId, + Email = email, + Name = name, + PictureUrl = pictureUrl, + User = user, + IsNewUser = false + }; + } + + // EN: Try to find user by email + // VI: Thử tìm user bằng email + user = await _userManager.FindByEmailAsync(email); + + if (user != null) + { + // EN: Link external login to existing user + // VI: Liên kết external login với user hiện có + var loginInfo = new UserLoginInfo(provider, providerUserId, provider); + var linkResult = await _userManager.AddLoginAsync(user, loginInfo); + + if (!linkResult.Succeeded) + { + var errors = string.Join(", ", linkResult.Errors.Select(e => e.Description)); + _logger.LogWarning( + "EN: Failed to link external login: {Errors} / VI: Không thể liên kết external login: {Errors}", + errors); + + return new ExternalAuthResult + { + Success = false, + ErrorMessage = $"Failed to link external account: {errors}" + }; + } + + _logger.LogInformation( + "EN: Linked external login to existing user / VI: Đã liên kết external login với user hiện có"); + + return new ExternalAuthResult + { + Success = true, + Provider = provider, + ProviderUserId = providerUserId, + Email = email, + Name = name, + PictureUrl = pictureUrl, + User = user, + IsNewUser = false + }; + } + + // EN: Create new user + // VI: Tạo user mới + var firstName = name?.Split(' ').FirstOrDefault() ?? "User"; + var lastName = name?.Split(' ').Skip(1).FirstOrDefault() ?? email.Split('@').First(); + + user = new ApplicationUser(email, firstName, lastName) + { + EmailConfirmed = true // EN: Email verified by provider / VI: Email đã được provider xác minh + }; + + var createResult = await _userManager.CreateAsync(user); + if (!createResult.Succeeded) + { + var errors = string.Join(", ", createResult.Errors.Select(e => e.Description)); + _logger.LogWarning( + "EN: Failed to create user: {Errors} / VI: Không thể tạo user: {Errors}", + errors); + + return new ExternalAuthResult + { + Success = false, + ErrorMessage = $"Failed to create user: {errors}" + }; + } + + // EN: Add external login to new user + // VI: Thêm external login cho user mới + var addLoginInfo = new UserLoginInfo(provider, providerUserId, provider); + var addLoginResult = await _userManager.AddLoginAsync(user, addLoginInfo); + + if (!addLoginResult.Succeeded) + { + _logger.LogWarning( + "EN: Failed to add external login for new user / VI: Không thể thêm external login cho user mới"); + } + + // EN: Add default role + // VI: Thêm role mặc định + await _userManager.AddToRoleAsync(user, "User"); + + _logger.LogInformation( + "EN: Created new user from external login / VI: Đã tạo user mới từ external login"); + + return new ExternalAuthResult + { + Success = true, + Provider = provider, + ProviderUserId = providerUserId, + Email = email, + Name = name, + PictureUrl = pictureUrl, + User = user, + IsNewUser = true + }; + } + catch (Exception ex) + { + _logger.LogError(ex, + "EN: Error processing external login / VI: Lỗi xử lý external login"); + + return new ExternalAuthResult + { + Success = false, + ErrorMessage = "An error occurred during external authentication" + }; + } + } + + /// + public async Task LinkExternalAccountAsync(Guid userId, string provider, string providerUserId) + { + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + _logger.LogWarning("EN: User not found / VI: Không tìm thấy user"); + return false; + } + + var loginInfo = new UserLoginInfo(provider, providerUserId, provider); + var result = await _userManager.AddLoginAsync(user, loginInfo); + + if (!result.Succeeded) + { + _logger.LogWarning( + "EN: Failed to link external account / VI: Không thể liên kết tài khoản bên ngoài"); + return false; + } + + return true; + } + + /// + public async Task UnlinkExternalAccountAsync(Guid userId, string provider) + { + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + _logger.LogWarning("EN: User not found / VI: Không tìm thấy user"); + return false; + } + + // EN: Get the provider user ID + // VI: Lấy provider user ID + var logins = await _userManager.GetLoginsAsync(user); + var login = logins.FirstOrDefault(l => l.LoginProvider == provider); + + if (login == null) + { + _logger.LogWarning( + "EN: External login not found / VI: Không tìm thấy external login"); + return false; + } + + var result = await _userManager.RemoveLoginAsync(user, provider, login.ProviderKey); + return result.Succeeded; + } + + /// + public async Task> GetLinkedProvidersAsync(Guid userId) + { + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return Enumerable.Empty(); + } + + var logins = await _userManager.GetLoginsAsync(user); + return logins.Select(l => l.LoginProvider); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginSettings.cs b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginSettings.cs new file mode 100644 index 00000000..30cd31ef --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/SocialLogin/SocialLoginSettings.cs @@ -0,0 +1,66 @@ +// EN: Social login settings for external OAuth providers +// VI: Cấu hình social login cho các OAuth providers bên ngoài +namespace IamService.Infrastructure.SocialLogin; + +/// +/// EN: Configuration settings for Google OAuth. +/// VI: Cấu hình cho Google OAuth. +/// +public class GoogleAuthSettings +{ + /// + /// EN: Google OAuth Client ID. + /// VI: Client ID của Google OAuth. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// EN: Google OAuth Client Secret. + /// VI: Client Secret của Google OAuth. + /// + public string ClientSecret { get; set; } = string.Empty; +} + +/// +/// EN: Configuration settings for Facebook OAuth. +/// VI: Cấu hình cho Facebook OAuth. +/// +public class FacebookAuthSettings +{ + /// + /// EN: Facebook App ID. + /// VI: App ID của Facebook. + /// + public string AppId { get; set; } = string.Empty; + + /// + /// EN: Facebook App Secret. + /// VI: App Secret của Facebook. + /// + public string AppSecret { get; set; } = string.Empty; +} + +/// +/// EN: Combined social login settings. +/// VI: Cấu hình tổng hợp social login. +/// +public class SocialLoginSettings +{ + /// + /// EN: Google OAuth settings. + /// VI: Cấu hình Google OAuth. + /// + public GoogleAuthSettings Google { get; set; } = new(); + + /// + /// EN: Facebook OAuth settings. + /// VI: Cấu hình Facebook OAuth. + /// + public FacebookAuthSettings Facebook { get; set; } = new(); + + /// + /// EN: Callback URL after external authentication. + /// VI: URL callback sau khi xác thực bên ngoài. + /// + public string CallbackUrl { get; set; } = "/api/auth/external-callback"; +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/ITwoFactorService.cs b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/ITwoFactorService.cs new file mode 100644 index 00000000..4c58a49b --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/ITwoFactorService.cs @@ -0,0 +1,41 @@ +// EN: Two-factor authentication service interface +// VI: Interface service xác thực hai yếu tố + +namespace IamService.Infrastructure.TwoFactor; + +/// +/// EN: Two-factor authentication service interface. +/// VI: Interface service xác thực hai yếu tố. +/// +public interface ITwoFactorService +{ + /// + /// EN: Generate a new secret key for 2FA. + /// VI: Tạo secret key mới cho 2FA. + /// + string GenerateSecretKey(); + + /// + /// EN: Generate QR code URI for authenticator apps. + /// VI: Tạo URI QR code cho ứng dụng authenticator. + /// + string GenerateQrCodeUri(string email, string secretKey); + + /// + /// EN: Generate QR code as base64 image. + /// VI: Tạo QR code dưới dạng ảnh base64. + /// + string GenerateQrCodeBase64(string email, string secretKey); + + /// + /// EN: Validate TOTP code against secret key. + /// VI: Xác thực mã TOTP với secret key. + /// + bool ValidateCode(string secretKey, string code); + + /// + /// EN: Generate recovery codes. + /// VI: Tạo mã khôi phục. + /// + IEnumerable GenerateRecoveryCodes(int count = 10); +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TotpTwoFactorService.cs b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TotpTwoFactorService.cs new file mode 100644 index 00000000..26b5adff --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TotpTwoFactorService.cs @@ -0,0 +1,122 @@ +// EN: TOTP-based two-factor authentication service +// VI: Service xác thực hai yếu tố dựa trên TOTP + +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OtpNet; +using QRCoder; + +namespace IamService.Infrastructure.TwoFactor; + +/// +/// EN: TOTP-based two-factor authentication service implementation. +/// VI: Implementation service 2FA dựa trên TOTP. +/// +public class TotpTwoFactorService : ITwoFactorService +{ + private readonly TwoFactorSettings _settings; + private readonly ILogger _logger; + + public TotpTwoFactorService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _logger = logger; + } + + /// + public string GenerateSecretKey() + { + // EN: Generate 20 bytes (160 bits) secret key for TOTP + // VI: Tạo secret key 20 bytes (160 bits) cho TOTP + var key = KeyGeneration.GenerateRandomKey(20); + return Base32Encoding.ToString(key); + } + + /// + public string GenerateQrCodeUri(string email, string secretKey) + { + // EN: Format: otpauth://totp/{issuer}:{email}?secret={secret}&issuer={issuer} + // VI: Format: otpauth://totp/{issuer}:{email}?secret={secret}&issuer={issuer} + var issuer = Uri.EscapeDataString(_settings.Issuer); + var accountName = Uri.EscapeDataString(email); + + return $"otpauth://totp/{issuer}:{accountName}?secret={secretKey}&issuer={issuer}&digits={_settings.CodeLength}&period={_settings.ValidityPeriodSeconds}"; + } + + /// + public string GenerateQrCodeBase64(string email, string secretKey) + { + try + { + var uri = GenerateQrCodeUri(email, secretKey); + + using var qrGenerator = new QRCodeGenerator(); + var qrCodeData = qrGenerator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.Q); + + using var qrCode = new PngByteQRCode(qrCodeData); + var pngBytes = qrCode.GetGraphic(10); + + return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate QR code for {Email}", email); + throw; + } + } + + /// + public bool ValidateCode(string secretKey, string code) + { + try + { + if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(code)) + { + return false; + } + + var secretBytes = Base32Encoding.ToBytes(secretKey); + var totp = new Totp(secretBytes, step: _settings.ValidityPeriodSeconds); + + // EN: Allow 1 step tolerance (previous and next period) + // VI: Cho phép dung sai 1 bước (kỳ trước và sau) + var isValid = totp.VerifyTotp(code, out _, new VerificationWindow(previous: 1, future: 1)); + + _logger.LogDebug("TOTP validation result: {IsValid}", isValid); + return isValid; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to validate TOTP code"); + return false; + } + } + + /// + public IEnumerable GenerateRecoveryCodes(int count = 10) + { + var codes = new List(); + + for (int i = 0; i < count; i++) + { + // EN: Generate 8 character alphanumeric recovery code + // VI: Tạo mã khôi phục 8 ký tự chữ và số + var bytes = RandomNumberGenerator.GetBytes(6); + var code = Convert.ToBase64String(bytes) + .Replace("+", "") + .Replace("/", "") + .Replace("=", "") + .Substring(0, 8) + .ToUpperInvariant(); + + // EN: Format as XXXX-XXXX for readability + // VI: Format thành XXXX-XXXX để dễ đọc + codes.Add($"{code.Substring(0, 4)}-{code.Substring(4, 4)}"); + } + + return codes; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TwoFactorSettings.cs b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TwoFactorSettings.cs new file mode 100644 index 00000000..3dd8a212 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/TwoFactor/TwoFactorSettings.cs @@ -0,0 +1,31 @@ +// EN: Two-factor authentication settings +// VI: Cấu hình xác thực hai yếu tố + +namespace IamService.Infrastructure.TwoFactor; + +/// +/// EN: Settings for two-factor authentication. +/// VI: Cấu hình cho xác thực hai yếu tố. +/// +public class TwoFactorSettings +{ + public const string SectionName = "TwoFactor"; + + /// + /// EN: Issuer name displayed in authenticator apps. + /// VI: Tên issuer hiển thị trong ứng dụng authenticator. + /// + public string Issuer { get; set; } = "IAM Service"; + + /// + /// EN: Code length (default 6 digits). + /// VI: Độ dài mã (mặc định 6 chữ số). + /// + public int CodeLength { get; set; } = 6; + + /// + /// EN: Validity period in seconds (default 30s for TOTP). + /// VI: Thời gian hiệu lực tính bằng giây (mặc định 30s cho TOTP). + /// + public int ValidityPeriodSeconds { get; set; } = 30; +} diff --git a/services/storage-service-net/.env.example b/services/storage-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/storage-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/storage-service-net/.gitignore b/services/storage-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/storage-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/storage-service-net/Directory.Build.props b/services/storage-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/storage-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/storage-service-net/Dockerfile b/services/storage-service-net/Dockerfile new file mode 100644 index 00000000..9ae8f43d --- /dev/null +++ b/services/storage-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/StorageService.API/StorageService.API.csproj", "src/StorageService.API/"] +COPY ["src/StorageService.Domain/StorageService.Domain.csproj", "src/StorageService.Domain/"] +COPY ["src/StorageService.Infrastructure/StorageService.Infrastructure.csproj", "src/StorageService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/StorageService.API/StorageService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/StorageService.API" +RUN dotnet build "StorageService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "StorageService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "StorageService.API.dll"] diff --git a/services/storage-service-net/README.md b/services/storage-service-net/README.md new file mode 100644 index 00000000..5f8770c1 --- /dev/null +++ b/services/storage-service-net/README.md @@ -0,0 +1,95 @@ +# Storage Service .NET / Dịch vụ Lưu trữ .NET + +EN: Microservice for file storage management supporting MinIO and Aliyun OSS. +VI: Microservice quản lý lưu trữ file hỗ trợ MinIO và Aliyun OSS. + +## Features / Tính năng + +- **Multi-provider storage**: MinIO (S3-compatible) and Aliyun OSS +- **Provider switching**: Switch providers via environment variable +- **File CRUD operations**: Upload, download, delete, list files +- **Pre-signed URLs**: Secure time-limited download/upload URLs +- **User quotas**: Storage capacity and file count limits +- **Inter-service auth**: JWT validation via IAM Service + +## Architecture / Kiến trúc + +``` +├── src/ +│ ├── StorageService.API/ # Controllers, Commands, Queries +│ ├── StorageService.Domain/ # Entities, Repository interfaces +│ └── StorageService.Infrastructure/# Providers, DbContext, Repositories +└── tests/ + ├── StorageService.UnitTests/ + └── StorageService.FunctionalTests/ +``` + +## Quick Start / Bắt đầu nhanh + +### Prerequisites / Yêu cầu +- .NET 10 SDK +- Docker & Docker Compose +- PostgreSQL (or Neon) +- MinIO (or Aliyun OSS) + +### Run with Docker / Chạy với Docker + +```bash +cd services/storage-service-net +docker-compose up -d +``` + +Access at: http://localhost:5002/swagger + +### Run locally / Chạy local + +```bash +cd services/storage-service-net +dotnet run --project src/StorageService.API +``` + +## Configuration / Cấu hình + +| Variable | Description | Default | +|----------|-------------|---------| +| `Storage__Provider` | Provider: `minio` or `aliyun` | `minio` | +| `Storage__DefaultBucket` | Default bucket name | `storage` | +| `Storage__MinIO__Endpoint` | MinIO endpoint | `localhost:9000` | +| `Storage__MinIO__AccessKey` | MinIO access key | - | +| `Storage__MinIO__SecretKey` | MinIO secret key | - | +| `Storage__AliyunOSS__Endpoint` | OSS endpoint | - | +| `Storage__AliyunOSS__AccessKeyId` | OSS access key | - | +| `Storage__AliyunOSS__AccessKeySecret` | OSS secret key | - | +| `IamService__BaseUrl` | IAM Service URL | `http://localhost:5001` | + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/files/upload` | Upload file | +| `GET` | `/api/v1/files` | List user files | +| `GET` | `/api/v1/files/{id}` | Get file by ID | +| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL | +| `DELETE` | `/api/v1/files/{id}` | Delete file | +| `GET` | `/api/v1/quota` | Get user quota | + +## Database Migrations / Migration Database + +```bash +cd services/storage-service-net +dotnet ef migrations add InitialCreate --project src/StorageService.Infrastructure --startup-project src/StorageService.API +dotnet ef database update --project src/StorageService.Infrastructure --startup-project src/StorageService.API +``` + +## Testing / Kiểm thử + +```bash +# Unit tests +dotnet test tests/StorageService.UnitTests + +# All tests +dotnet test +``` + +## License +MIT diff --git a/services/storage-service-net/StorageService.slnx b/services/storage-service-net/StorageService.slnx new file mode 100644 index 00000000..8d77435a --- /dev/null +++ b/services/storage-service-net/StorageService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/storage-service-net/docker-compose.yml b/services/storage-service-net/docker-compose.yml new file mode 100644 index 00000000..c25b296b --- /dev/null +++ b/services/storage-service-net/docker-compose.yml @@ -0,0 +1,102 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + storage-api: + build: + context: . + dockerfile: Dockerfile + container_name: storage-service-api + ports: + - "5002:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=storage_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + - Storage__Provider=minio + - Storage__DefaultBucket=storage + - Storage__MinIO__Endpoint=minio:9000 + - Storage__MinIO__AccessKey=minioadmin + - Storage__MinIO__SecretKey=minioadmin + - Storage__MinIO__UseSSL=false + - IamService__BaseUrl=http://iam-service:5001 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + networks: + - storage-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: storage-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: storage_db + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - storage-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: storage-redis + ports: + - "6380:6379" + volumes: + - redis_data:/data + networks: + - storage-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: storage-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" # API port + - "9001:9001" # Console port + volumes: + - minio_data:/data + networks: + - storage-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + minio_data: + +networks: + storage-network: + driver: bridge diff --git a/services/storage-service-net/docs/en/ARCHITECTURE.md b/services/storage-service-net/docs/en/ARCHITECTURE.md new file mode 100644 index 00000000..9d80ba57 --- /dev/null +++ b/services/storage-service-net/docs/en/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture Documentation + +> Detailed architecture documentation for the .NET 10 Microservice Template. + +## Architecture Overview + +```mermaid +graph TB + subgraph "API Layer" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Domain Layer" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Infrastructure Layer" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Layer Responsibilities + +### 1. Domain Layer (MyService.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 (MyService.Infrastructure) + +Technical implementations and external concerns: +- Database access (EF Core) +- Repository implementations +- External service integrations + +### 3. API Layer (MyService.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 + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + 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] + + 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 + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## MediatR Pipeline + +``` +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 +{ + "type": "https://tools.ietf.org/html/rfc7807", + "title": "Validation Error", + "status": 400, + "detail": "One or more validation errors occurred.", + "errors": { + "Name": ["Name is required"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## 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) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Security Considerations + +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 + +## Performance Optimization + +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 + +## 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) diff --git a/services/storage-service-net/docs/en/README.md b/services/storage-service-net/docs/en/README.md new file mode 100644 index 00000000..4cb53d44 --- /dev/null +++ b/services/storage-service-net/docs/en/README.md @@ -0,0 +1,265 @@ +# .NET 10 Microservice Template + +> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns. + +## Overview + +This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture 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 + +## Prerequisites + +| 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 +``` + +## 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 "MyService" to "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.example .env + +# Edit with your configuration +nano .env +``` + +### 3. Run with Docker + +```bash +# Start all services (API + PostgreSQL + Redis) +docker-compose up -d + +# View logs +docker-compose logs -f myservice-api +``` + +### 4. Run Locally + +```bash +# Restore dependencies +dotnet restore + +# Build all projects +dotnet build + +# Run the API +dotnet run --project src/MyService.API +``` + +## Project Structure + +``` +_template_dot_net/ +├── src/ +│ ├── MyService.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 +│ │ +│ ├── MyService.Domain/ # Domain Layer (Pure business logic) +│ │ ├── AggregatesModel/ # Aggregate roots and entities +│ │ ├── Events/ # Domain events +│ │ ├── Exceptions/ # Domain exceptions +│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.) +│ │ +│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access) +│ ├── EntityConfigurations/ # EF Core Fluent API configurations +│ ├── Repositories/ # Repository implementations +│ ├── Idempotency/ # Request idempotency handling +│ └── MyServiceContext.cs # DbContext with Unit of Work +│ +├── tests/ +│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) +│ └── MyService.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 +``` + +## API Endpoints + +| 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 | + +### Health Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health 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; + +// Handle command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task 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; +``` + +## 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/MyService.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) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Deployment + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Run container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests. + +## What's New in .NET 10 + +- **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 + +## License + +Proprietary - GoodGo Platform diff --git a/services/storage-service-net/docs/vi/ARCHITECTURE.md b/services/storage-service-net/docs/vi/ARCHITECTURE.md new file mode 100644 index 00000000..55a5d13b --- /dev/null +++ b/services/storage-service-net/docs/vi/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Tài Liệu Kiến Trúc + +> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10. + +## Tổng Quan Kiến Trúc + +```mermaid +graph TB + subgraph "Lớp API" + C[Controllers] + CMD[Commands] + Q[Queries] + B[Behaviors] + V[Validations] + end + + subgraph "Lớp Domain" + AR[Aggregate Roots] + E[Entities] + VO[Value Objects] + DE[Domain Events] + DX[Domain Exceptions] + end + + subgraph "Lớp Infrastructure" + DB[(PostgreSQL)] + R[Repositories] + CTX[DbContext] + ID[Idempotency] + end + + C --> CMD + C --> Q + CMD --> B --> V + CMD --> AR + Q --> R + R --> CTX --> DB + AR --> DE + R --> AR + + style C fill:#4a90d9,stroke:#2d5986,color:#fff + style AR fill:#50c878,stroke:#2d8659,color:#fff + style DB fill:#ff6b6b,stroke:#c0392b,color:#fff +``` + +## Trách Nhiệm Các Lớp + +### 1. Lớp Domain (MyService.Domain) + +Trái tim của ứng dụng chứa business logic thuần túy. Lớp này: +- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events) +- Chỉ chứa các class POCO +- Triển khai các tactical patterns của DDD + +#### Thành Phần + +| Thành phần | Mục Đích | +|------------|----------| +| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot | +| **AggregatesModel** | Aggregate roots với entities và value objects | +| **Events** | Domain events cho giao tiếp cross-aggregate | +| **Exceptions** | Domain exceptions cho vi phạm business rules | + +### 2. Lớp Infrastructure (MyService.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 (MyService.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 + +```mermaid +sequenceDiagram + participant Client + participant Controller + participant MediatR + participant LoggingBehavior + participant ValidatorBehavior + participant TransactionBehavior + participant CommandHandler + participant Repository + participant DbContext + + 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] + + 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 + } + + sample_statuses { + int id PK + varchar(50) name + } + + samples ||--o{ sample_statuses : has +``` + +## Pipeline MediatR + +``` +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 +{ + "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"] + } +} +``` + +## Health Checks + +```mermaid +graph TD + HC[Health Check Endpoint] + HC --> |/health/live| L[Liveness] + HC --> |/health/ready| R[Readiness] + HC --> |/health| F[Full Status] + + R --> PG[(PostgreSQL)] + R --> RD[(Redis)] + + style HC fill:#3498db,stroke:#2980b9,color:#fff + style L fill:#2ecc71,stroke:#27ae60,color:#fff + style R fill:#f39c12,stroke:#d68910,color:#fff +``` + +## Kiến Trúc Deployment + +### 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) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: myservice-api +spec: + replicas: 3 + template: + spec: + containers: + - name: api + image: myservice:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + readinessProbe: + httpGet: + path: /health/ready + port: 8080 +``` + +## Cân Nhắc Bảo Mật + +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 + +## Tối Ưu Hiệu Năng + +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 + +## 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) diff --git a/services/storage-service-net/docs/vi/README.md b/services/storage-service-net/docs/vi/README.md new file mode 100644 index 00000000..7d7e48b6 --- /dev/null +++ b/services/storage-service-net/docs/vi/README.md @@ -0,0 +1,265 @@ +# Template Microservice .NET 10 + +> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture. + +## 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: + +- **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 + +## Yêu Cầu + +| Yêu cầu | Phiên bả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 +``` + +## 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ả "MyService" thành "YourService" +find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} + +find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} + +``` + +### 2. 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 +nano .env +``` + +### 3. Chạy với Docker + +```bash +# Khởi động tất cả services (API + PostgreSQL + Redis) +docker-compose up -d + +# Xem logs +docker-compose logs -f myservice-api +``` + +### 4. 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/MyService.API +``` + +## Cấu Trúc Dự Án + +``` +_template_dot_net/ +├── src/ +│ ├── MyService.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 +│ │ +│ ├── MyService.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.) +│ │ +│ └── MyService.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 +│ └── MyServiceContext.cs # DbContext với Unit of Work +│ +├── tests/ +│ ├── MyService.UnitTests/ # Unit tests (Domain, Application) +│ └── MyService.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 +``` + +## Các Endpoint API + +| 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 | + +### Health Endpoints + +| 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; + +// Xử lý command +public class CreateSampleCommandHandler : IRequestHandler +{ + public async Task 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; +``` + +## 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/MyService.UnitTests +``` + +## Cấu Hình + +### Biến Môi Trường + +| 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ự) | - | + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} +``` + +## Triển Khai + +### Docker Build + +```bash +# Build Docker image +docker build -t myservice:latest . + +# Chạy container +docker run -p 5000:8080 --env-file .env myservice:latest +``` + +### Kubernetes + +Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes. + +## Có Gì Mới Trong .NET 10 + +- 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 + +## Giấy Phép + +Độc quyền - GoodGo Platform diff --git a/services/storage-service-net/global.json b/services/storage-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/storage-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.API/Application/Behaviors/LoggingBehavior.cs b/services/storage-service-net/src/StorageService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..021a7ffe --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace StorageService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Behaviors/TransactionBehavior.cs b/services/storage-service-net/src/StorageService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..94104a48 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using StorageService.Infrastructure; + +namespace StorageService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly StorageServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + StorageServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Behaviors/ValidatorBehavior.cs b/services/storage-service-net/src/StorageService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..ec0f4684 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace StorageService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommand.cs new file mode 100644 index 00000000..03c2f389 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to change status of a Sample. +/// VI: Command để thay đổi trạng thái của Sample. +/// +/// EN: Sample ID / VI: ID sample +/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel) +public record ChangeSampleStatusCommand( + Guid SampleId, + string NewStatus +) : IRequest; diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs new file mode 100644 index 00000000..680a8189 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs @@ -0,0 +1,70 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for ChangeSampleStatusCommand. +/// VI: Handler cho ChangeSampleStatusCommand. +/// +public class ChangeSampleStatusCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public ChangeSampleStatusCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + ChangeSampleStatusCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}", + request.SampleId, request.NewStatus); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action + switch (request.NewStatus.ToLowerInvariant()) + { + case "activate": + sample.Activate(); + break; + case "complete": + sample.Complete(); + break; + case "cancel": + sample.Cancel(); + break; + default: + _logger.LogWarning( + "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}", + request.NewStatus); + return false; + } + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}", + request.SampleId, request.NewStatus); + + return true; + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommand.cs new file mode 100644 index 00000000..f7e00009 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommand.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to create a new Sample. +/// VI: Command để tạo một Sample mới. +/// +/// EN: Sample name / VI: Tên sample +/// EN: Optional description / VI: Mô tả tùy chọn +public record CreateSampleCommand( + string Name, + string? Description +) : IRequest; + +/// +/// EN: Result of CreateSampleCommand. +/// VI: Kết quả của CreateSampleCommand. +/// +/// EN: Created sample ID / VI: ID sample đã tạo +public record CreateSampleCommandResult(Guid Id); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommandHandler.cs new file mode 100644 index 00000000..4ae711df --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/CreateSampleCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for CreateSampleCommand. +/// VI: Handler cho CreateSampleCommand. +/// +public class CreateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public CreateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CreateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}", + request.Name); + + // EN: Create domain entity / VI: Tạo domain entity + var sample = new Sample(request.Name, request.Description); + + // EN: Add to repository / VI: Thêm vào repository + _sampleRepository.Add(sample); + + // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events) + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}", + sample.Id); + + return new CreateSampleCommandResult(sample.Id); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommand.cs new file mode 100644 index 00000000..a2cdb6d2 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommand.cs @@ -0,0 +1,20 @@ +using MediatR; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to delete a file. +/// VI: Command để xóa file. +/// +public record DeleteFileCommand( + Guid FileId, + string UserId +) : IRequest; + +/// +/// EN: Result of file deletion. +/// VI: Kết quả xóa file. +/// +public record DeleteFileResult( + bool Success, + string? Error); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommandHandler.cs new file mode 100644 index 00000000..2359b72f --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteFileCommandHandler.cs @@ -0,0 +1,84 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Storage; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for DeleteFileCommand. +/// VI: Handler cho DeleteFileCommand. +/// +public class DeleteFileCommandHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + private readonly IQuotaRepository _quotaRepository; + private readonly IStorageProviderFactory _storageProviderFactory; + private readonly ILogger _logger; + + public DeleteFileCommandHandler( + IFileRepository fileRepository, + IQuotaRepository quotaRepository, + IStorageProviderFactory storageProviderFactory, + ILogger logger) + { + _fileRepository = fileRepository; + _quotaRepository = quotaRepository; + _storageProviderFactory = storageProviderFactory; + _logger = logger; + } + + public async Task Handle(DeleteFileCommand request, CancellationToken cancellationToken) + { + try + { + // EN: Get file metadata / VI: Lấy metadata file + var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken); + + if (file == null) + { + return new DeleteFileResult(false, "File not found."); + } + + // EN: Check ownership / VI: Kiểm tra quyền sở hữu + if (file.UserId != request.UserId) + { + return new DeleteFileResult(false, "You don't have permission to delete this file."); + } + + // EN: Delete from storage provider / VI: Xóa khỏi storage provider + var provider = _storageProviderFactory.GetProvider(file.Provider); + var deleted = await provider.DeleteAsync(file.BucketName, file.ObjectKey, cancellationToken); + + if (!deleted) + { + _logger.LogWarning("Failed to delete file from storage: {FileId}", request.FileId); + // EN: Continue with soft delete even if storage delete fails + // VI: Tiếp tục soft delete ngay cả khi xóa storage thất bại + } + + // EN: Soft delete file record / VI: Soft delete record file + file.Delete(); + _fileRepository.Update(file); + + // EN: Update quota / VI: Cập nhật quota + var quota = await _quotaRepository.GetByUserIdAsync(request.UserId, cancellationToken); + if (quota != null) + { + quota.RemoveUsage(file.FileSizeBytes); + _quotaRepository.Update(quota); + } + + // EN: Save changes / VI: Lưu thay đổi + await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("File deleted successfully: {FileId}", request.FileId); + return new DeleteFileResult(true, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file {FileId}", request.FileId); + return new DeleteFileResult(false, "An error occurred while deleting the file."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommand.cs new file mode 100644 index 00000000..596af0ec --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to delete a Sample. +/// VI: Command để xóa một Sample. +/// +/// EN: Sample ID to delete / VI: ID sample cần xóa +public record DeleteSampleCommand(Guid SampleId) : IRequest; diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommandHandler.cs new file mode 100644 index 00000000..a1ad748b --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/DeleteSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for DeleteSampleCommand. +/// VI: Handler cho DeleteSampleCommand. +/// +public class DeleteSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public DeleteSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + DeleteSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Deleting sample {SampleId} / Xóa sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Delete sample / VI: Xóa sample + _sampleRepository.Delete(sample); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công", + request.SampleId); + + return true; + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommand.cs new file mode 100644 index 00000000..4ee36183 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommand.cs @@ -0,0 +1,16 @@ +using MediatR; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to update an existing Sample. +/// VI: Command để cập nhật một Sample đã tồn tại. +/// +/// EN: Sample ID to update / VI: ID sample cần cập nhật +/// EN: New name / VI: Tên mới +/// EN: New description / VI: Mô tả mới +public record UpdateSampleCommand( + Guid SampleId, + string Name, + string? Description +) : IRequest; diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommandHandler.cs new file mode 100644 index 00000000..427822da --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/UpdateSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for UpdateSampleCommand. +/// VI: Handler cho UpdateSampleCommand. +/// +public class UpdateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public UpdateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + UpdateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating sample {SampleId} / Cập nhật sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method + sample.Update(request.Name, request.Description); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công", + request.SampleId); + + return true; + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommand.cs new file mode 100644 index 00000000..0261c6c6 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommand.cs @@ -0,0 +1,28 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileAggregate; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Command to upload a file. +/// VI: Command để upload file. +/// +public record UploadFileCommand( + Stream FileStream, + string FileName, + string ContentType, + long FileSizeBytes, + string UserId, + string? TenantId = null, + FileAccessLevel AccessLevel = FileAccessLevel.Private +) : IRequest; + +/// +/// EN: Result of file upload. +/// VI: Kết quả upload file. +/// +public record UploadFileResult( + bool Success, + Guid? FileId, + string? ObjectKey, + string? Error); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommandHandler.cs new file mode 100644 index 00000000..1866a703 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/UploadFileCommandHandler.cs @@ -0,0 +1,121 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Configuration; +using StorageService.Infrastructure.Storage; +using Microsoft.Extensions.Options; + +namespace StorageService.API.Application.Commands; + +/// +/// EN: Handler for UploadFileCommand. +/// VI: Handler cho UploadFileCommand. +/// +public class UploadFileCommandHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + private readonly IQuotaRepository _quotaRepository; + private readonly IStorageProviderFactory _storageProviderFactory; + private readonly StorageSettings _settings; + private readonly ILogger _logger; + + public UploadFileCommandHandler( + IFileRepository fileRepository, + IQuotaRepository quotaRepository, + IStorageProviderFactory storageProviderFactory, + IOptions settings, + ILogger logger) + { + _fileRepository = fileRepository; + _quotaRepository = quotaRepository; + _storageProviderFactory = storageProviderFactory; + _settings = settings.Value; + _logger = logger; + } + + public async Task Handle(UploadFileCommand request, CancellationToken cancellationToken) + { + try + { + // EN: Check file size limit / VI: Kiểm tra giới hạn kích thước file + if (request.FileSizeBytes > _settings.MaxFileSizeBytes) + { + return new UploadFileResult(false, null, null, + $"File size exceeds maximum allowed ({_settings.MaxFileSizeBytes} bytes)"); + } + + // EN: Check user quota / VI: Kiểm tra quota user + var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken); + if (!quota.CanUpload(request.FileSizeBytes)) + { + return new UploadFileResult(false, null, null, + "Quota exceeded. Please upgrade your plan or delete some files."); + } + + // EN: Generate object key / VI: Tạo object key + var objectKey = GenerateObjectKey(request.UserId, request.FileName); + var bucketName = _settings.DefaultBucket; + + // EN: Upload to storage provider / VI: Upload lên storage provider + var provider = _storageProviderFactory.GetProvider(); + var uploadResult = await provider.UploadAsync( + bucketName, + objectKey, + request.FileStream, + request.ContentType, + cancellationToken); + + if (!uploadResult.Success) + { + _logger.LogError("Failed to upload file to storage: {Error}", uploadResult.Error); + return new UploadFileResult(false, null, null, uploadResult.Error); + } + + // EN: Save file metadata / VI: Lưu metadata file + var storageFile = new StorageFile( + request.FileName, + bucketName, + objectKey, + request.ContentType, + request.FileSizeBytes, + request.UserId, + provider.ProviderType, + request.AccessLevel, + request.TenantId, + uploadResult.Checksum); + + await _fileRepository.AddAsync(storageFile, cancellationToken); + + // EN: Update quota / VI: Cập nhật quota + quota.AddUsage(request.FileSizeBytes); + _quotaRepository.Update(quota); + + // EN: Save changes / VI: Lưu thay đổi + await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation("File uploaded successfully: {FileId}, {ObjectKey}", + storageFile.Id, objectKey); + + return new UploadFileResult(true, storageFile.Id, objectKey, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading file for user {UserId}", request.UserId); + return new UploadFileResult(false, null, null, "An error occurred while uploading the file."); + } + } + + private static string GenerateObjectKey(string userId, string fileName) + { + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd"); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var safeFileName = SanitizeFileName(fileName); + return $"{userId}/{timestamp}/{uniqueId}_{safeFileName}"; + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + return string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs new file mode 100644 index 00000000..5172a567 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileDtos.cs @@ -0,0 +1,67 @@ +using StorageService.Domain.AggregatesModel.FileAggregate; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: DTO for file information. +/// VI: DTO cho thông tin file. +/// +public record FileDto( + Guid Id, + string FileName, + string ContentType, + long FileSizeBytes, + string Provider, + string AccessLevel, + DateTime UploadedAt, + DateTime? LastAccessedAt); + +/// +/// EN: Result for user files query. +/// VI: Kết quả query files của user. +/// +public record UserFilesResult( + IReadOnlyList Files, + int TotalCount); + +/// +/// EN: DTO for user quota. +/// VI: DTO cho quota user. +/// +public record QuotaDto( + string UserId, + long MaxStorageBytes, + long UsedStorageBytes, + long RemainingStorageBytes, + int MaxFileCount, + int CurrentFileCount, + int RemainingFileCount, + double UsagePercentage, + string? QuotaTier); + +/// +/// EN: Result for download URL query. +/// VI: Kết quả query download URL. +/// +public record DownloadUrlResult( + bool Success, + string? Url, + int? ExpiresInSeconds, + string? Error); + +/// +/// EN: Mapper from domain entities to DTOs. +/// VI: Mapper từ domain entities sang DTOs. +/// +public static class FileDtoMapper +{ + public static FileDto ToDto(this StorageFile file) => new( + file.Id, + file.FileName, + file.ContentType, + file.FileSizeBytes, + file.Provider.ToString(), + file.AccessLevel.ToString(), + file.UploadedAt, + file.LastAccessedAt); +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueries.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueries.cs new file mode 100644 index 00000000..041bfa35 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueries.cs @@ -0,0 +1,36 @@ +using MediatR; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Query to get file by ID. +/// VI: Query để lấy file theo ID. +/// +public record GetFileQuery(Guid FileId, string UserId) : IRequest; + +/// +/// EN: Query to get user files. +/// VI: Query để lấy files của user. +/// +public record GetUserFilesQuery( + string UserId, + int Skip = 0, + int Take = 20, + string? SearchTerm = null +) : IRequest; + +/// +/// EN: Query to get user quota. +/// VI: Query để lấy quota user. +/// +public record GetUserQuotaQuery(string UserId) : IRequest; + +/// +/// EN: Query to get pre-signed download URL. +/// VI: Query để lấy pre-signed download URL. +/// +public record GetDownloadUrlQuery( + Guid FileId, + string UserId, + int ExpirationSeconds = 3600 +) : IRequest; diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs new file mode 100644 index 00000000..25373fa9 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/FileQueryHandlers.cs @@ -0,0 +1,159 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Storage; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Handler for GetFileQuery. +/// VI: Handler cho GetFileQuery. +/// +public class GetFileQueryHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + + public GetFileQueryHandler(IFileRepository fileRepository) + { + _fileRepository = fileRepository; + } + + public async Task Handle(GetFileQuery request, CancellationToken cancellationToken) + { + var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken); + + if (file == null || file.UserId != request.UserId) + return null; + + return file.ToDto(); + } +} + +/// +/// EN: Handler for GetUserFilesQuery. +/// VI: Handler cho GetUserFilesQuery. +/// +public class GetUserFilesQueryHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + + public GetUserFilesQueryHandler(IFileRepository fileRepository) + { + _fileRepository = fileRepository; + } + + public async Task Handle(GetUserFilesQuery request, CancellationToken cancellationToken) + { + IEnumerable files; + + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + { + files = await _fileRepository.SearchAsync( + request.UserId, + request.SearchTerm, + request.Skip, + request.Take, + cancellationToken); + } + else + { + files = await _fileRepository.GetByUserIdAsync( + request.UserId, + request.Skip, + request.Take, + cancellationToken); + } + + var totalCount = await _fileRepository.GetFileCountByUserIdAsync(request.UserId, cancellationToken); + + return new UserFilesResult( + files.Select(f => f.ToDto()).ToList(), + totalCount); + } +} + +/// +/// EN: Handler for GetUserQuotaQuery. +/// VI: Handler cho GetUserQuotaQuery. +/// +public class GetUserQuotaQueryHandler : IRequestHandler +{ + private readonly IQuotaRepository _quotaRepository; + + public GetUserQuotaQueryHandler(IQuotaRepository quotaRepository) + { + _quotaRepository = quotaRepository; + } + + public async Task Handle(GetUserQuotaQuery request, CancellationToken cancellationToken) + { + var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken); + + return new QuotaDto( + quota.UserId, + quota.MaxStorageBytes, + quota.UsedStorageBytes, + quota.RemainingStorageBytes, + quota.MaxFileCount, + quota.CurrentFileCount, + quota.RemainingFileCount, + quota.UsagePercentage, + quota.QuotaTier); + } +} + +/// +/// EN: Handler for GetDownloadUrlQuery. +/// VI: Handler cho GetDownloadUrlQuery. +/// +public class GetDownloadUrlQueryHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + private readonly IStorageProviderFactory _storageProviderFactory; + private readonly ILogger _logger; + + public GetDownloadUrlQueryHandler( + IFileRepository fileRepository, + IStorageProviderFactory storageProviderFactory, + ILogger logger) + { + _fileRepository = fileRepository; + _storageProviderFactory = storageProviderFactory; + _logger = logger; + } + + public async Task Handle(GetDownloadUrlQuery request, CancellationToken cancellationToken) + { + try + { + var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken); + + if (file == null) + return new DownloadUrlResult(false, null, null, "File not found."); + + // EN: Check access / VI: Kiểm tra quyền truy cập + if (file.AccessLevel == FileAccessLevel.Private && file.UserId != request.UserId) + return new DownloadUrlResult(false, null, null, "Access denied."); + + // EN: Mark as accessed / VI: Đánh dấu đã truy cập + file.MarkAccessed(); + _fileRepository.Update(file); + await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: Generate pre-signed URL / VI: Tạo pre-signed URL + var provider = _storageProviderFactory.GetProvider(file.Provider); + var url = await provider.GetPreSignedDownloadUrlAsync( + file.BucketName, + file.ObjectKey, + request.ExpirationSeconds, + cancellationToken); + + return new DownloadUrlResult(true, url, request.ExpirationSeconds, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating download URL for file {FileId}", request.FileId); + return new DownloadUrlResult(false, null, null, "An error occurred."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQuery.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQuery.cs new file mode 100644 index 00000000..1309ae83 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQuery.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Query to get a Sample by ID. +/// VI: Query để lấy một Sample theo ID. +/// +/// EN: Sample ID / VI: ID sample +public record GetSampleQuery(Guid SampleId) : IRequest; + +/// +/// EN: Sample view model for API responses. +/// VI: Sample view model cho API responses. +/// +public record SampleViewModel( + Guid Id, + string Name, + string? Description, + string Status, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQueryHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQueryHandler.cs new file mode 100644 index 00000000..59b103c8 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSampleQueryHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Handler for GetSampleQuery. +/// VI: Handler cho GetSampleQuery. +/// +public class GetSampleQueryHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + + public GetSampleQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task Handle( + GetSampleQuery request, + CancellationToken cancellationToken) + { + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + return null; + } + + return new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + ); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQuery.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQuery.cs new file mode 100644 index 00000000..4ee22be9 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQuery.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Query to get all Samples. +/// VI: Query để lấy tất cả Samples. +/// +public record GetSamplesQuery : IRequest>; diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQueryHandler.cs new file mode 100644 index 00000000..e75de647 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/GetSamplesQueryHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.API.Application.Queries; + +/// +/// EN: Handler for GetSamplesQuery. +/// VI: Handler cho GetSamplesQuery. +/// +public class GetSamplesQueryHandler : IRequestHandler> +{ + private readonly ISampleRepository _sampleRepository; + + public GetSamplesQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task> Handle( + GetSamplesQuery request, + CancellationToken cancellationToken) + { + var samples = await _sampleRepository.GetAllAsync(); + + return samples.Select(sample => new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + )); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/storage-service-net/src/StorageService.API/Application/Validations/CreateSampleCommandValidator.cs new file mode 100644 index 00000000..b81d0f61 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Validations/CreateSampleCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using StorageService.API.Application.Commands; + +namespace StorageService.API.Application.Validations; + +/// +/// EN: Validator for CreateSampleCommand. +/// VI: Validator cho CreateSampleCommand. +/// +public class CreateSampleCommandValidator : AbstractValidator +{ + public CreateSampleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/storage-service-net/src/StorageService.API/Application/Validations/UpdateSampleCommandValidator.cs new file mode 100644 index 00000000..b578418d --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Validations/UpdateSampleCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using StorageService.API.Application.Commands; + +namespace StorageService.API.Application.Validations; + +/// +/// EN: Validator for UpdateSampleCommand. +/// VI: Validator cho UpdateSampleCommand. +/// +public class UpdateSampleCommandValidator : AbstractValidator +{ + public UpdateSampleCommandValidator() + { + RuleFor(x => x.SampleId) + .NotEmpty() + .WithMessage("Sample ID is required / ID sample là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs b/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs new file mode 100644 index 00000000..7d9099d5 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/FilesController.cs @@ -0,0 +1,192 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Commands; +using StorageService.API.Application.Queries; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; + +namespace StorageService.API.Controllers; + +/// +/// EN: Controller for file operations. +/// VI: Controller cho các thao tác file. +/// +[ApiController] +[Route("api/v1/files")] +[SwaggerTag("File Management - Upload, download, and manage files")] +public class FilesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public FilesController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Upload a file. + /// VI: Upload file. + /// + [HttpPost("upload")] + [Authorize] + [RequestSizeLimit(104857600)] // 100MB + [SwaggerOperation(Summary = "Upload a file", Description = "Upload a file to storage")] + [SwaggerResponse(200, "File uploaded successfully")] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(401, "Unauthorized")] + public async Task>> Upload( + IFormFile file, + [FromQuery] string? accessLevel = "private", + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + if (file == null || file.Length == 0) + return BadRequest(new ApiResponse { Success = false, Error = "No file provided" }); + + using var stream = file.OpenReadStream(); + var fileAccessLevel = accessLevel?.ToLowerInvariant() switch + { + "public" => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Public, + "shared" => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Shared, + _ => Domain.AggregatesModel.FileAggregate.FileAccessLevel.Private + }; + + var command = new UploadFileCommand( + stream, + file.FileName, + file.ContentType, + file.Length, + userId, + null, + fileAccessLevel); + + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + return BadRequest(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Get user files. + /// VI: Lấy danh sách files của user. + /// + [HttpGet] + [Authorize] + [SwaggerOperation(Summary = "Get user files", Description = "Get list of files uploaded by the current user")] + [SwaggerResponse(200, "Files retrieved successfully")] + public async Task>> GetFiles( + [FromQuery] int skip = 0, + [FromQuery] int take = 20, + [FromQuery] string? search = null, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + var query = new GetUserFilesQuery(userId, skip, take, search); + var result = await _mediator.Send(query, cancellationToken); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Get file by ID. + /// VI: Lấy thông tin file theo ID. + /// + [HttpGet("{fileId:guid}")] + [Authorize] + [SwaggerOperation(Summary = "Get file by ID", Description = "Get file metadata by ID")] + [SwaggerResponse(200, "File retrieved successfully")] + [SwaggerResponse(404, "File not found")] + public async Task>> GetFile( + Guid fileId, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + var query = new GetFileQuery(fileId, userId); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + return NotFound(new ApiResponse { Success = false, Error = "File not found" }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Get pre-signed download URL. + /// VI: Lấy URL download có chữ ký. + /// + [HttpGet("{fileId:guid}/download-url")] + [Authorize] + [SwaggerOperation(Summary = "Get download URL", Description = "Get pre-signed URL for file download")] + [SwaggerResponse(200, "URL generated successfully")] + [SwaggerResponse(404, "File not found")] + public async Task>> GetDownloadUrl( + Guid fileId, + [FromQuery] int expirationSeconds = 3600, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + var query = new GetDownloadUrlQuery(fileId, userId, expirationSeconds); + var result = await _mediator.Send(query, cancellationToken); + + if (!result.Success) + return NotFound(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + /// + /// EN: Delete a file. + /// VI: Xóa file. + /// + [HttpDelete("{fileId:guid}")] + [Authorize] + [SwaggerOperation(Summary = "Delete file", Description = "Delete a file by ID")] + [SwaggerResponse(200, "File deleted successfully")] + [SwaggerResponse(404, "File not found")] + public async Task>> Delete( + Guid fileId, + CancellationToken cancellationToken = default) + { + var userId = GetUserId(); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + var command = new DeleteFileCommand(fileId, userId); + var result = await _mediator.Send(command, cancellationToken); + + if (!result.Success) + return NotFound(new ApiResponse { Success = false, Error = result.Error }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} + +/// +/// EN: Standard API response wrapper. +/// VI: Wrapper response API chuẩn. +/// +public class ApiResponse +{ + public bool Success { get; set; } + public T? Data { get; set; } + public string? Error { get; set; } +} diff --git a/services/storage-service-net/src/StorageService.API/Controllers/QuotaController.cs b/services/storage-service-net/src/StorageService.API/Controllers/QuotaController.cs new file mode 100644 index 00000000..92800b14 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/QuotaController.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Queries; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; + +namespace StorageService.API.Controllers; + +/// +/// EN: Controller for storage quota operations. +/// VI: Controller cho các thao tác quota storage. +/// +[ApiController] +[Route("api/v1/quota")] +[SwaggerTag("Quota Management - View and manage storage quotas")] +public class QuotaController : ControllerBase +{ + private readonly IMediator _mediator; + + public QuotaController(IMediator mediator) + { + _mediator = mediator; + } + + /// + /// EN: Get current user's storage quota. + /// VI: Lấy quota storage của user hiện tại. + /// + [HttpGet] + [Authorize] + [SwaggerOperation(Summary = "Get user quota", Description = "Get storage quota for current user")] + [SwaggerResponse(200, "Quota retrieved successfully")] + [SwaggerResponse(401, "Unauthorized")] + public async Task>> GetQuota(CancellationToken cancellationToken = default) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(new ApiResponse { Success = false, Error = "User ID not found" }); + + var query = new GetUserQuotaQuery(userId); + var result = await _mediator.Send(query, cancellationToken); + + if (result == null) + return NotFound(new ApiResponse { Success = false, Error = "Quota not found" }); + + return Ok(new ApiResponse { Success = true, Data = result }); + } +} diff --git a/services/storage-service-net/src/StorageService.API/Controllers/SamplesController.cs b/services/storage-service-net/src/StorageService.API/Controllers/SamplesController.cs new file mode 100644 index 00000000..4ce5d79b --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Controllers/SamplesController.cs @@ -0,0 +1,200 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using StorageService.API.Application.Commands; +using StorageService.API.Application.Queries; + +namespace StorageService.API.Controllers; + +/// +/// EN: Controller for Sample CRUD operations using CQRS pattern. +/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +[Produces("application/json")] +public class SamplesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public SamplesController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetSamples() + { + var samples = await _mediator.Send(new GetSamplesQuery()); + return Ok(new { success = true, data = samples }); + } + + /// + /// EN: Get a sample by ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Sample details / VI: Chi tiết sample + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSample(Guid id) + { + var sample = await _mediator.Send(new GetSampleQuery(id)); + + if (sample is null) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, data = sample }); + } + + /// + /// EN: Create a new sample. + /// VI: Tạo một sample mới. + /// + /// EN: Create request / VI: Request tạo + /// EN: Created sample ID / VI: ID sample đã tạo + [HttpPost] + [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateSample([FromBody] CreateSampleRequest request) + { + var command = new CreateSampleCommand(request.Name, request.Description); + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetSample), + new { id = result.Id }, + new { success = true, data = result }); + } + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Update request / VI: Request cập nhật + /// EN: Success status / VI: Trạng thái thành công + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request) + { + var command = new UpdateSampleCommand(id, request.Name, request.Description); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" }); + } + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Success status / VI: Trạng thái thành công + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteSample(Guid id) + { + var command = new DeleteSampleCommand(id); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return NoContent(); + } + + /// + /// EN: Change sample status. + /// VI: Thay đổi trạng thái sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Status change request / VI: Request thay đổi trạng thái + /// EN: Success status / VI: Trạng thái thành công + [HttpPatch("{id:guid}/status")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request) + { + var command = new ChangeSampleStatusCommand(id, request.Status); + var result = await _mediator.Send(command); + + if (!result) + { + return BadRequest(new + { + success = false, + error = new + { + code = "STATUS_CHANGE_FAILED", + message = "Failed to change sample status / Thay đổi trạng thái sample thất bại" + } + }); + } + + return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" }); + } +} + +/// +/// EN: Request model for creating a sample. +/// VI: Model request để tạo sample. +/// +public record CreateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for updating a sample. +/// VI: Model request để cập nhật sample. +/// +public record UpdateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for changing sample status. +/// VI: Model request để thay đổi trạng thái sample. +/// +public record ChangeStatusRequest(string Status); diff --git a/services/storage-service-net/src/StorageService.API/Program.cs b/services/storage-service-net/src/StorageService.API/Program.cs new file mode 100644 index 00000000..2ed6ef0b --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using StorageService.API.Application.Behaviors; +using StorageService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting StorageService API / Khởi động StorageService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "StorageService API", + Version = "v1", + Description = "StorageService microservice API / API microservice StorageService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "StorageService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// 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 { } diff --git a/services/storage-service-net/src/StorageService.API/Properties/launchSettings.json b/services/storage-service-net/src/StorageService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.API/StorageService.API.csproj b/services/storage-service-net/src/StorageService.API/StorageService.API.csproj new file mode 100644 index 00000000..6a9f4d51 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/StorageService.API.csproj @@ -0,0 +1,44 @@ + + + + StorageService.API + StorageService.API + Web API layer with CQRS pattern for Storage Service + storageservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/storage-service-net/src/StorageService.API/appsettings.Development.json b/services/storage-service-net/src/StorageService.API/appsettings.Development.json new file mode 100644 index 00000000..e4538820 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/appsettings.Development.json @@ -0,0 +1,37 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5433;Database=storage_db;Username=postgres;Password=postgres" + }, + "Storage": { + "Provider": "minio", + "DefaultBucket": "storage", + "PreSignedUrlExpirationSeconds": 3600, + "MaxFileSizeBytes": 104857600, + "MinIO": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "UseSSL": false, + "Region": "" + }, + "AliyunOSS": { + "Endpoint": "", + "AccessKeyId": "", + "AccessKeySecret": "", + "Region": "" + } + }, + "IamService": { + "BaseUrl": "http://localhost:5001", + "ServiceName": "storage-service", + "TimeoutSeconds": 30 + } +} \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.API/appsettings.json b/services/storage-service-net/src/StorageService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/FileDomainEvents.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/FileDomainEvents.cs new file mode 100644 index 00000000..bf271136 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/FileDomainEvents.cs @@ -0,0 +1,34 @@ +using MediatR; + +namespace StorageService.Domain.AggregatesModel.FileAggregate; + +/// +/// EN: Domain event raised when a file is uploaded. +/// VI: Domain event được phát khi file được upload. +/// +public record FileUploadedDomainEvent( + Guid FileId, + string FileName, + string UserId, + long FileSizeBytes +) : INotification; + +/// +/// EN: Domain event raised when a file is deleted. +/// VI: Domain event được phát khi file bị xóa. +/// +public record FileDeletedDomainEvent( + Guid FileId, + string UserId, + long FileSizeBytes +) : INotification; + +/// +/// EN: Domain event raised when user quota is updated. +/// VI: Domain event được phát khi quota user được cập nhật. +/// +public record UserQuotaUpdatedDomainEvent( + string UserId, + long UsedStorageBytes, + int FileCount +) : INotification; diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/IFileRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/IFileRepository.cs new file mode 100644 index 00000000..785fbce9 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/IFileRepository.cs @@ -0,0 +1,67 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileAggregate; + +/// +/// EN: Repository interface for StorageFile aggregate. +/// VI: Interface repository cho StorageFile aggregate. +/// +public interface IFileRepository : IRepository +{ + /// + /// EN: Add a new file. + /// VI: Thêm file mới. + /// + Task AddAsync(StorageFile file, CancellationToken cancellationToken = default); + + /// + /// EN: Update an existing file. + /// VI: Cập nhật file. + /// + void Update(StorageFile file); + + /// + /// EN: Get file by ID. + /// VI: Lấy file theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get file by object key. + /// VI: Lấy file theo object key. + /// + Task GetByObjectKeyAsync(string objectKey, CancellationToken cancellationToken = default); + + /// + /// EN: Get files by user ID. + /// VI: Lấy files theo user ID. + /// + Task> GetByUserIdAsync( + string userId, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default); + + /// + /// EN: Get total size of files by user ID. + /// VI: Lấy tổng kích thước files theo user ID. + /// + Task GetTotalSizeByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get file count by user ID. + /// VI: Lấy số lượng files theo user ID. + /// + Task GetFileCountByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// EN: Search files by name. + /// VI: Tìm kiếm files theo tên. + /// + Task> SearchAsync( + string userId, + string? searchTerm, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageEnums.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageEnums.cs new file mode 100644 index 00000000..9cfc6b16 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageEnums.cs @@ -0,0 +1,30 @@ +namespace StorageService.Domain.AggregatesModel.FileAggregate; + +/// +/// EN: Storage provider enumeration. +/// VI: Enum định nghĩa các provider lưu trữ. +/// +public enum StorageProvider +{ + /// EN: MinIO S3-compatible storage / VI: MinIO lưu trữ tương thích S3 + MinIO = 1, + + /// EN: Alibaba Cloud OSS / VI: Alibaba Cloud OSS + AliyunOSS = 2 +} + +/// +/// EN: File access level enumeration. +/// VI: Enum định nghĩa mức độ truy cập file. +/// +public enum FileAccessLevel +{ + /// EN: Private - Only owner can access / VI: Riêng tư - Chỉ owner có thể truy cập + Private = 1, + + /// EN: Public - Anyone can access / VI: Công khai - Bất kỳ ai có thể truy cập + Public = 2, + + /// EN: Shared - Specific users can access / VI: Chia sẻ - Người dùng cụ thể có thể truy cập + Shared = 3 +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs new file mode 100644 index 00000000..458d4a12 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/FileAggregate/StorageFile.cs @@ -0,0 +1,140 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.FileAggregate; + +/// +/// EN: Represents a file stored in the storage system. +/// VI: Đại diện cho file được lưu trữ trong hệ thống. +/// +public class StorageFile : Entity, IAggregateRoot +{ + /// EN: Original file name / VI: Tên file gốc + public string FileName { get; private set; } = string.Empty; + + /// EN: Bucket/container name / VI: Tên bucket/container + public string BucketName { get; private set; } = string.Empty; + + /// EN: Unique key in storage / VI: Key duy nhất trong storage + public string ObjectKey { get; private set; } = string.Empty; + + /// EN: MIME content type / VI: MIME content type + public string ContentType { get; private set; } = string.Empty; + + /// EN: File size in bytes / VI: Kích thước file (bytes) + public long FileSizeBytes { get; private set; } + + /// EN: Owner user ID / VI: ID người dùng sở hữu + public string UserId { get; private set; } = string.Empty; + + /// EN: Tenant ID for multi-tenancy / VI: Tenant ID cho multi-tenancy + public string? TenantId { get; private set; } + + /// EN: Storage provider used / VI: Provider lưu trữ được sử dụng + public StorageProvider Provider { get; private set; } + + /// EN: Access level for the file / VI: Mức độ truy cập của file + public FileAccessLevel AccessLevel { get; private set; } + + /// EN: Upload timestamp / VI: Thời gian upload + public DateTime UploadedAt { get; private set; } + + /// EN: Last access timestamp / VI: Thời gian truy cập cuối + public DateTime? LastAccessedAt { get; private set; } + + /// EN: Expiration timestamp (for temporary files) / VI: Thời gian hết hạn (cho file tạm) + public DateTime? ExpiresAt { get; private set; } + + /// EN: File checksum (MD5/SHA256) / VI: Checksum file (MD5/SHA256) + public string? Checksum { get; private set; } + + /// EN: Soft delete flag / VI: Cờ xóa mềm + public bool IsDeleted { get; private set; } + + /// EN: Deleted timestamp / VI: Thời gian xóa + public DateTime? DeletedAt { get; private set; } + + // EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số + protected StorageFile() { } + + /// + /// EN: Create a new storage file. + /// VI: Tạo một storage file mới. + /// + public StorageFile( + string fileName, + string bucketName, + string objectKey, + string contentType, + long fileSizeBytes, + string userId, + StorageProvider provider, + FileAccessLevel accessLevel = FileAccessLevel.Private, + string? tenantId = null, + string? checksum = null) + { + Id = Guid.NewGuid(); + FileName = fileName ?? throw new ArgumentNullException(nameof(fileName)); + BucketName = bucketName ?? throw new ArgumentNullException(nameof(bucketName)); + ObjectKey = objectKey ?? throw new ArgumentNullException(nameof(objectKey)); + ContentType = contentType ?? "application/octet-stream"; + FileSizeBytes = fileSizeBytes; + UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + Provider = provider; + AccessLevel = accessLevel; + TenantId = tenantId; + Checksum = checksum; + UploadedAt = DateTime.UtcNow; + IsDeleted = false; + + // EN: Raise domain event / VI: Phát domain event + AddDomainEvent(new FileUploadedDomainEvent(Id, fileName, userId, fileSizeBytes)); + } + + /// + /// EN: Mark file as accessed. + /// VI: Đánh dấu file đã được truy cập. + /// + public void MarkAccessed() + { + LastAccessedAt = DateTime.UtcNow; + } + + /// + /// EN: Update access level. + /// VI: Cập nhật mức độ truy cập. + /// + public void UpdateAccessLevel(FileAccessLevel newLevel) + { + if (IsDeleted) + throw new InvalidOperationException("Cannot update deleted file"); + + AccessLevel = newLevel; + } + + /// + /// EN: Soft delete the file. + /// VI: Xóa mềm file. + /// + public void Delete() + { + if (IsDeleted) + return; + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + + AddDomainEvent(new FileDeletedDomainEvent(Id, UserId, FileSizeBytes)); + } + + /// + /// EN: Set expiration time. + /// VI: Đặt thời gian hết hạn. + /// + public void SetExpiration(DateTime expiresAt) + { + if (expiresAt <= DateTime.UtcNow) + throw new ArgumentException("Expiration must be in the future", nameof(expiresAt)); + + ExpiresAt = expiresAt; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/IQuotaRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/IQuotaRepository.cs new file mode 100644 index 00000000..b125083d --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/IQuotaRepository.cs @@ -0,0 +1,40 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.QuotaAggregate; + +/// +/// EN: Repository interface for UserStorageQuota aggregate. +/// VI: Interface repository cho UserStorageQuota aggregate. +/// +public interface IQuotaRepository : IRepository +{ + /// + /// EN: Add a new quota. + /// VI: Thêm quota mới. + /// + Task AddAsync(UserStorageQuota quota, CancellationToken cancellationToken = default); + + /// + /// EN: Update an existing quota. + /// VI: Cập nhật quota. + /// + void Update(UserStorageQuota quota); + + /// + /// EN: Get quota by ID. + /// VI: Lấy quota theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get quota by user ID. + /// VI: Lấy quota theo user ID. + /// + Task GetByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get or create quota for user. + /// VI: Lấy hoặc tạo quota cho user. + /// + Task GetOrCreateAsync(string userId, CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/UserStorageQuota.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/UserStorageQuota.cs new file mode 100644 index 00000000..fa8792e5 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/QuotaAggregate/UserStorageQuota.cs @@ -0,0 +1,151 @@ +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.QuotaAggregate; + +/// +/// EN: Represents a user's storage quota and usage. +/// VI: Đại diện cho quota và usage lưu trữ của user. +/// +public class UserStorageQuota : Entity, IAggregateRoot +{ + /// EN: User ID / VI: ID người dùng + public string UserId { get; private set; } = string.Empty; + + /// EN: Maximum storage in bytes / VI: Dung lượng tối đa (bytes) + public long MaxStorageBytes { get; private set; } + + /// EN: Currently used storage in bytes / VI: Dung lượng đã sử dụng (bytes) + public long UsedStorageBytes { get; private set; } + + /// EN: Maximum number of files / VI: Số file tối đa + public int MaxFileCount { get; private set; } + + /// EN: Current file count / VI: Số file hiện tại + public int CurrentFileCount { get; private set; } + + /// EN: Quota tier/plan name / VI: Tên tier/plan quota + public string? QuotaTier { get; private set; } + + /// EN: Last updated timestamp / VI: Thời gian cập nhật cuối + public DateTime LastUpdatedAt { get; private set; } + + /// EN: Created timestamp / VI: Thời gian tạo + public DateTime CreatedAt { get; private set; } + + // EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số + protected UserStorageQuota() { } + + /// + /// EN: Create a new user storage quota. + /// VI: Tạo quota lưu trữ mới cho user. + /// + public UserStorageQuota( + string userId, + long maxStorageBytes = 1073741824, // 1GB default + int maxFileCount = 1000, + string? quotaTier = "free") + { + Id = Guid.NewGuid(); + UserId = userId ?? throw new ArgumentNullException(nameof(userId)); + MaxStorageBytes = maxStorageBytes; + MaxFileCount = maxFileCount; + QuotaTier = quotaTier; + UsedStorageBytes = 0; + CurrentFileCount = 0; + CreatedAt = DateTime.UtcNow; + LastUpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Get remaining storage in bytes. + /// VI: Lấy dung lượng còn lại (bytes). + /// + public long RemainingStorageBytes => Math.Max(0, MaxStorageBytes - UsedStorageBytes); + + /// + /// EN: Get remaining file count. + /// VI: Lấy số file còn có thể upload. + /// + public int RemainingFileCount => Math.Max(0, MaxFileCount - CurrentFileCount); + + /// + /// EN: Get usage percentage. + /// VI: Lấy phần trăm sử dụng. + /// + public double UsagePercentage => MaxStorageBytes > 0 + ? Math.Round((double)UsedStorageBytes / MaxStorageBytes * 100, 2) + : 0; + + /// + /// EN: Check if user can upload file with given size. + /// VI: Kiểm tra user có thể upload file với kích thước cho trước. + /// + public bool CanUpload(long fileSizeBytes) + { + return UsedStorageBytes + fileSizeBytes <= MaxStorageBytes + && CurrentFileCount < MaxFileCount; + } + + /// + /// EN: Add storage usage after upload. + /// VI: Thêm usage sau khi upload. + /// + public void AddUsage(long fileSizeBytes, int fileCount = 1) + { + if (fileSizeBytes < 0) + throw new ArgumentException("File size cannot be negative", nameof(fileSizeBytes)); + + UsedStorageBytes += fileSizeBytes; + CurrentFileCount += fileCount; + LastUpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new UserQuotaUpdatedDomainEvent(UserId, UsedStorageBytes, CurrentFileCount)); + } + + /// + /// EN: Remove storage usage after delete. + /// VI: Giảm usage sau khi xóa. + /// + public void RemoveUsage(long fileSizeBytes, int fileCount = 1) + { + if (fileSizeBytes < 0) + throw new ArgumentException("File size cannot be negative", nameof(fileSizeBytes)); + + UsedStorageBytes = Math.Max(0, UsedStorageBytes - fileSizeBytes); + CurrentFileCount = Math.Max(0, CurrentFileCount - fileCount); + LastUpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new UserQuotaUpdatedDomainEvent(UserId, UsedStorageBytes, CurrentFileCount)); + } + + /// + /// EN: Update quota limits. + /// VI: Cập nhật giới hạn quota. + /// + public void UpdateLimits(long maxStorageBytes, int maxFileCount, string? quotaTier = null) + { + if (maxStorageBytes < UsedStorageBytes) + throw new InvalidOperationException($"Cannot set max storage below current usage ({UsedStorageBytes} bytes)"); + + if (maxFileCount < CurrentFileCount) + throw new InvalidOperationException($"Cannot set max file count below current count ({CurrentFileCount})"); + + MaxStorageBytes = maxStorageBytes; + MaxFileCount = maxFileCount; + if (quotaTier != null) + QuotaTier = quotaTier; + LastUpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Recalculate usage (for sync purposes). + /// VI: Tính lại usage (để sync). + /// + public void RecalculateUsage(long totalBytes, int totalFiles) + { + UsedStorageBytes = Math.Max(0, totalBytes); + CurrentFileCount = Math.Max(0, totalFiles); + LastUpdatedAt = DateTime.UtcNow; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs new file mode 100644 index 00000000..a95782b4 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs @@ -0,0 +1,61 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Repository interface for Sample aggregate. +/// VI: Interface repository cho Sample aggregate. +/// +/// +/// EN: Following repository pattern, this interface defines the contract +/// for data access operations on Sample aggregate. +/// VI: Theo pattern repository, interface này định nghĩa contract +/// cho các thao tác truy cập dữ liệu trên Sample aggregate. +/// +public interface ISampleRepository : IRepository +{ + /// + /// EN: Get a sample by its ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: The sample ID / VI: ID của sample + /// EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy + Task GetAsync(Guid sampleId); + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + Task> GetAllAsync(); + + /// + /// EN: Add a new sample. + /// VI: Thêm một sample mới. + /// + /// EN: The sample to add / VI: Sample cần thêm + /// EN: The added sample / VI: Sample đã thêm + Sample Add(Sample sample); + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: The sample to update / VI: Sample cần cập nhật + void Update(Sample sample); + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: The sample to delete / VI: Sample cần xóa + void Delete(Sample sample); + + /// + /// EN: Get samples by status. + /// VI: Lấy samples theo trạng thái. + /// + /// EN: The status ID / VI: ID trạng thái + /// EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước + Task> GetByStatusAsync(int statusId); +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/Sample.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/Sample.cs new file mode 100644 index 00000000..705e2ce7 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/Sample.cs @@ -0,0 +1,158 @@ +using StorageService.Domain.Events; +using StorageService.Domain.Exceptions; +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample aggregate root demonstrating DDD patterns. +/// VI: Sample aggregate root minh họa các pattern DDD. +/// +public class Sample : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _name = null!; + private string? _description; + private SampleStatus _status = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: Sample name (required). + /// VI: Tên sample (bắt buộc). + /// + public string Name => _name; + + /// + /// EN: Optional description. + /// VI: Mô tả tùy chọn. + /// + public string? Description => _description; + + /// + /// EN: Current status. + /// VI: Trạng thái hiện tại. + /// + public SampleStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Sample() + { + } + + /// + /// EN: Create a new Sample with required information. + /// VI: Tạo một Sample mới với thông tin bắt buộc. + /// + /// EN: Sample name / VI: Tên sample + /// EN: Optional description / VI: Mô tả tùy chọn + public Sample(string name, string? description = null) : this() + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + Id = Guid.NewGuid(); + _name = name; + _description = description; + _status = SampleStatus.Draft; + StatusId = SampleStatus.Draft.Id; + _createdAt = DateTime.UtcNow; + + // EN: Add domain event for creation + // VI: Thêm domain event cho việc tạo + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + /// + /// EN: Update sample information. + /// VI: Cập nhật thông tin sample. + /// + public void Update(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Cannot update a cancelled sample"); + + _name = name; + _description = description; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate the sample. + /// VI: Kích hoạt sample. + /// + public void Activate() + { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + + var previousStatus = _status; + _status = SampleStatus.Active; + StatusId = SampleStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Complete the sample. + /// VI: Hoàn thành sample. + /// + public void Complete() + { + if (_status != SampleStatus.Active) + throw new SampleDomainException("Only active samples can be completed"); + + var previousStatus = _status; + _status = SampleStatus.Completed; + StatusId = SampleStatus.Completed.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Cancel the sample. + /// VI: Hủy sample. + /// + public void Cancel() + { + if (_status == SampleStatus.Completed) + throw new SampleDomainException("Cannot cancel a completed sample"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Sample is already cancelled"); + + var previousStatus = _status; + _status = SampleStatus.Cancelled; + StatusId = SampleStatus.Cancelled.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs new file mode 100644 index 00000000..269be98d --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs @@ -0,0 +1,77 @@ +using StorageService.Domain.SeedWork; + +namespace StorageService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample status enumeration following type-safe enum pattern. +/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu. +/// +public class SampleStatus : Enumeration +{ + /// + /// EN: Draft status - initial state + /// VI: Trạng thái nháp - trạng thái ban đầu + /// + public static SampleStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Active status - ready for use + /// VI: Trạng thái hoạt động - sẵn sàng sử dụng + /// + public static SampleStatus Active = new(2, nameof(Active)); + + /// + /// EN: Completed status - finished processing + /// VI: Trạng thái hoàn thành - đã xử lý xong + /// + public static SampleStatus Completed = new(3, nameof(Completed)); + + /// + /// EN: Cancelled status - cancelled by user + /// VI: Trạng thái đã hủy - bị hủy bởi người dùng + /// + public static SampleStatus Cancelled = new(4, nameof(Cancelled)); + + public SampleStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all available statuses. + /// VI: Lấy tất cả các trạng thái có sẵn. + /// + public static IEnumerable List() => GetAll(); + + /// + /// EN: Parse status from name. + /// VI: Parse trạng thái từ tên. + /// + public static SampleStatus FromName(string name) + { + var status = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } + + /// + /// EN: Parse status from ID. + /// VI: Parse trạng thái từ ID. + /// + public static SampleStatus From(int id) + { + var status = List().SingleOrDefault(s => s.Id == id); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/Events/SampleCreatedDomainEvent.cs b/services/storage-service-net/src/StorageService.Domain/Events/SampleCreatedDomainEvent.cs new file mode 100644 index 00000000..653d5897 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/Events/SampleCreatedDomainEvent.cs @@ -0,0 +1,22 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.Domain.Events; + +/// +/// EN: Domain event raised when a new Sample is created. +/// VI: Domain event được phát ra khi một Sample mới được tạo. +/// +public class SampleCreatedDomainEvent : INotification +{ + /// + /// EN: The newly created sample. + /// VI: Sample mới được tạo. + /// + public Sample Sample { get; } + + public SampleCreatedDomainEvent(Sample sample) + { + Sample = sample; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/Events/SampleStatusChangedDomainEvent.cs b/services/storage-service-net/src/StorageService.Domain/Events/SampleStatusChangedDomainEvent.cs new file mode 100644 index 00000000..d5c28c0a --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/Events/SampleStatusChangedDomainEvent.cs @@ -0,0 +1,39 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.Domain.Events; + +/// +/// EN: Domain event raised when Sample status changes. +/// VI: Domain event được phát ra khi trạng thái Sample thay đổi. +/// +public class SampleStatusChangedDomainEvent : INotification +{ + /// + /// EN: The sample ID. + /// VI: ID của sample. + /// + public Guid SampleId { get; } + + /// + /// EN: Previous status before the change. + /// VI: Trạng thái trước khi thay đổi. + /// + public SampleStatus PreviousStatus { get; } + + /// + /// EN: New status after the change. + /// VI: Trạng thái mới sau khi thay đổi. + /// + public SampleStatus NewStatus { get; } + + public SampleStatusChangedDomainEvent( + Guid sampleId, + SampleStatus previousStatus, + SampleStatus newStatus) + { + SampleId = sampleId; + PreviousStatus = previousStatus; + NewStatus = newStatus; + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/Exceptions/DomainException.cs b/services/storage-service-net/src/StorageService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..189eb3b3 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace StorageService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/Exceptions/SampleDomainException.cs b/services/storage-service-net/src/StorageService.Domain/Exceptions/SampleDomainException.cs new file mode 100644 index 00000000..a4ca7026 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/Exceptions/SampleDomainException.cs @@ -0,0 +1,21 @@ +namespace StorageService.Domain.Exceptions; + +/// +/// EN: Exception for Sample aggregate domain errors. +/// VI: Exception cho các lỗi domain của Sample aggregate. +/// +public class SampleDomainException : DomainException +{ + public SampleDomainException() + { + } + + public SampleDomainException(string message) : base(message) + { + } + + public SampleDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/Entity.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..14bb3d64 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/Enumeration.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..ba6354ef --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/IAggregateRoot.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..1d36956c --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/IRepository.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..8b6b5409 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/IUnitOfWork.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..a7803357 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Domain/SeedWork/ValueObject.cs b/services/storage-service-net/src/StorageService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..67e59e80 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace StorageService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/storage-service-net/src/StorageService.Domain/StorageService.Domain.csproj b/services/storage-service-net/src/StorageService.Domain/StorageService.Domain.csproj new file mode 100644 index 00000000..1b2c094f --- /dev/null +++ b/services/storage-service-net/src/StorageService.Domain/StorageService.Domain.csproj @@ -0,0 +1,14 @@ + + + + StorageService.Domain + StorageService.Domain + Domain layer containing core business logic and entities for Storage Service + + + + + + + + diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Configuration/StorageSettings.cs b/services/storage-service-net/src/StorageService.Infrastructure/Configuration/StorageSettings.cs new file mode 100644 index 00000000..d97e4a4a --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Configuration/StorageSettings.cs @@ -0,0 +1,69 @@ +namespace StorageService.Infrastructure.Configuration; + +/// +/// EN: Storage settings for MinIO and Aliyun OSS. +/// VI: Cấu hình storage cho MinIO và Aliyun OSS. +/// +public class StorageSettings +{ + public const string SectionName = "Storage"; + + /// EN: Active storage provider (minio or aliyun) / VI: Provider storage đang dùng (minio hoặc aliyun) + public string Provider { get; set; } = "minio"; + + /// EN: Default bucket name / VI: Tên bucket mặc định + public string DefaultBucket { get; set; } = "storage"; + + /// EN: Pre-signed URL expiration in seconds / VI: Thời gian hết hạn pre-signed URL (giây) + public int PreSignedUrlExpirationSeconds { get; set; } = 3600; + + /// EN: Maximum file size in bytes / VI: Kích thước file tối đa (bytes) + public long MaxFileSizeBytes { get; set; } = 104857600; // 100MB + + /// EN: MinIO configuration / VI: Cấu hình MinIO + public MinioSettings MinIO { get; set; } = new(); + + /// EN: Aliyun OSS configuration / VI: Cấu hình Aliyun OSS + public AliyunOssSettings AliyunOSS { get; set; } = new(); +} + +/// +/// EN: MinIO-specific settings. +/// VI: Cấu hình riêng cho MinIO. +/// +public class MinioSettings +{ + /// EN: MinIO endpoint (host:port) / VI: Endpoint MinIO (host:port) + public string Endpoint { get; set; } = "localhost:9000"; + + /// EN: Access key / VI: Access key + public string AccessKey { get; set; } = string.Empty; + + /// EN: Secret key / VI: Secret key + public string SecretKey { get; set; } = string.Empty; + + /// EN: Use SSL/HTTPS / VI: Sử dụng SSL/HTTPS + public bool UseSSL { get; set; } = false; + + /// EN: Region (optional) / VI: Region (tùy chọn) + public string? Region { get; set; } +} + +/// +/// EN: Aliyun OSS-specific settings. +/// VI: Cấu hình riêng cho Aliyun OSS. +/// +public class AliyunOssSettings +{ + /// EN: OSS endpoint / VI: Endpoint OSS + public string Endpoint { get; set; } = string.Empty; + + /// EN: Access key ID / VI: Access key ID + public string AccessKeyId { get; set; } = string.Empty; + + /// EN: Access key secret / VI: Access key secret + public string AccessKeySecret { get; set; } = string.Empty; + + /// EN: Region / VI: Region + public string Region { get; set; } = string.Empty; +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs b/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..6320c299 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Configuration; +using StorageService.Infrastructure.ExternalServices; +using StorageService.Infrastructure.Idempotency; +using StorageService.Infrastructure.Persistence; +using StorageService.Infrastructure.Persistence.Repositories; +using StorageService.Infrastructure.Storage; + +namespace StorageService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration, + string? environmentName = null) + { + // EN: Bind configuration sections / VI: Bind các configuration sections + services.Configure(configuration.GetSection(StorageSettings.SectionName)); + services.Configure(configuration.GetSection(IamServiceSettings.SectionName)); + + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + if (!string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase)) + { + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(StorageServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + } + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + services.AddScoped(); + + // EN: Register storage providers / VI: Đăng ký storage providers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // EN: Register IAM Service client with Polly resilience / VI: Đăng ký IAM Service client với Polly resilience + services.AddHttpClient() + .SetHandlerLifetime(TimeSpan.FromMinutes(5)) + .AddPolicyHandler(GetRetryPolicy()) + .AddPolicyHandler(GetCircuitBreakerPolicy()); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } + + /// + /// EN: Get retry policy for HTTP client. + /// VI: Lấy retry policy cho HTTP client. + /// + private static IAsyncPolicy GetRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } + + /// + /// EN: Get circuit breaker policy for HTTP client. + /// VI: Lấy circuit breaker policy cho HTTP client. + /// + private static IAsyncPolicy GetCircuitBreakerPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs b/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs new file mode 100644 index 00000000..170e8499 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Sample entity. +/// VI: Cấu hình EF Core cho entity Sample. +/// +public class SampleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("samples"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + // EN: Ignore domain events (not persisted) + // VI: Bỏ qua domain events (không lưu) + builder.Ignore(s => s.DomainEvents); + + // EN: Properties / VI: Các thuộc tính + builder.Property(s => s.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_description") + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Status relationship / VI: Quan hệ với Status + builder.Property(s => s.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(s => s.Status) + .WithMany() + .HasForeignKey(s => s.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // EN: Indexes / VI: Các index + builder.HasIndex("_name"); + builder.HasIndex(s => s.StatusId); + builder.HasIndex("_createdAt"); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs b/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..17e38f38 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StorageService.Domain.AggregatesModel.SampleAggregate; + +namespace StorageService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for SampleStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration SampleStatus. +/// +public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("sample_statuses"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed initial data / VI: Seed dữ liệu ban đầu + builder.HasData( + SampleStatus.Draft, + SampleStatus.Active, + SampleStatus.Completed, + SampleStatus.Cancelled + ); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs new file mode 100644 index 00000000..8d2f92fe --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/HttpIamServiceClient.cs @@ -0,0 +1,94 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StorageService.Infrastructure.ExternalServices; + +/// +/// EN: HTTP client for communicating with IAM Service. +/// VI: HTTP client để giao tiếp với IAM Service. +/// +public class HttpIamServiceClient : IIamServiceClient +{ + private readonly HttpClient _httpClient; + private readonly IamServiceSettings _settings; + private readonly ILogger _logger; + + public HttpIamServiceClient( + HttpClient httpClient, + IOptions settings, + ILogger logger) + { + _httpClient = httpClient; + _settings = settings.Value; + _logger = logger; + + _httpClient.BaseAddress = new Uri(_settings.BaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); + } + + /// + public async Task ValidateUserAsync( + string accessToken, + CancellationToken cancellationToken = default) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/users/me"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Service-Name", _settings.ServiceName); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to validate user token. Status: {StatusCode}", response.StatusCode); + return null; + } + + var userResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (userResponse?.Data == null) + return null; + + return new IamUserInfo( + userResponse.Data.Id, + userResponse.Data.Email, + userResponse.Data.DisplayName, + true, + userResponse.Data.Roles ?? new List()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating user with IAM Service"); + return null; + } + } + + /// + public async Task UserExistsAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/users/{userId}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Service-Name", _settings.ServiceName); + + var response = await _httpClient.SendAsync(request, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking user exists in IAM Service: {UserId}", userId); + return false; + } + } + + // EN: Internal DTOs for IAM Service responses / VI: Internal DTOs cho responses từ IAM Service + private sealed record UserMeResponse(UserData? Data); + private sealed record UserData(string Id, string Email, string? DisplayName, List? Roles); +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs new file mode 100644 index 00000000..c09d4956 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/ExternalServices/IamServiceClient.cs @@ -0,0 +1,49 @@ +namespace StorageService.Infrastructure.ExternalServices; + +/// +/// EN: Configuration for IAM Service communication. +/// VI: Cấu hình giao tiếp với IAM Service. +/// +public class IamServiceSettings +{ + public const string SectionName = "IamService"; + + /// EN: IAM Service base URL / VI: URL cơ sở của IAM Service + public string BaseUrl { get; set; } = "http://iam-service:5001"; + + /// EN: Internal service name header / VI: Header tên service nội bộ + public string ServiceName { get; set; } = "storage-service"; + + /// EN: Request timeout in seconds / VI: Timeout request (giây) + public int TimeoutSeconds { get; set; } = 30; +} + +/// +/// EN: User information from IAM Service. +/// VI: Thông tin user từ IAM Service. +/// +public record IamUserInfo( + string UserId, + string Email, + string? DisplayName, + bool IsActive, + IReadOnlyList Roles); + +/// +/// EN: Interface for communicating with IAM Service. +/// VI: Interface giao tiếp với IAM Service. +/// +public interface IIamServiceClient +{ + /// + /// EN: Validate user token and get user info. + /// VI: Xác thực token và lấy thông tin user. + /// + Task ValidateUserAsync(string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user exists. + /// VI: Kiểm tra user có tồn tại. + /// + Task UserExistsAsync(string userId, string accessToken, CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/ClientRequest.cs b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..607975b3 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace StorageService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/IRequestManager.cs b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..0fef21f3 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace StorageService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/RequestManager.cs b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..abb3d869 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace StorageService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly StorageServiceContext _context; + + public RequestManager(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileRepository.cs new file mode 100644 index 00000000..b3bb6588 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/FileRepository.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Persistence.Repositories; + +/// +/// EN: Repository implementation for StorageFile aggregate. +/// VI: Repository implementation cho StorageFile aggregate. +/// +public class FileRepository : IFileRepository +{ + private readonly StorageServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public FileRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task AddAsync(StorageFile file, CancellationToken cancellationToken = default) + { + var entry = await _context.StorageFiles.AddAsync(file, cancellationToken); + return entry.Entity; + } + + /// + public void Update(StorageFile file) + { + _context.Entry(file).State = EntityState.Modified; + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.StorageFiles + .FirstOrDefaultAsync(f => f.Id == id, cancellationToken); + } + + /// + public async Task GetByObjectKeyAsync(string objectKey, CancellationToken cancellationToken = default) + { + return await _context.StorageFiles + .FirstOrDefaultAsync(f => f.ObjectKey == objectKey, cancellationToken); + } + + /// + public async Task> GetByUserIdAsync( + string userId, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default) + { + return await _context.StorageFiles + .Where(f => f.UserId == userId) + .OrderByDescending(f => f.UploadedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + + /// + public async Task GetTotalSizeByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.StorageFiles + .Where(f => f.UserId == userId) + .SumAsync(f => f.FileSizeBytes, cancellationToken); + } + + /// + public async Task GetFileCountByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.StorageFiles + .Where(f => f.UserId == userId) + .CountAsync(cancellationToken); + } + + /// + public async Task> SearchAsync( + string userId, + string? searchTerm, + int skip = 0, + int take = 20, + CancellationToken cancellationToken = default) + { + var query = _context.StorageFiles.Where(f => f.UserId == userId); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + query = query.Where(f => EF.Functions.ILike(f.FileName, $"%{searchTerm}%")); + } + + return await query + .OrderByDescending(f => f.UploadedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/QuotaRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/QuotaRepository.cs new file mode 100644 index 00000000..e1e4ab37 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/Repositories/QuotaRepository.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Persistence.Repositories; + +/// +/// EN: Repository implementation for UserStorageQuota aggregate. +/// VI: Repository implementation cho UserStorageQuota aggregate. +/// +public class QuotaRepository : IQuotaRepository +{ + private readonly StorageServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public QuotaRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task AddAsync(UserStorageQuota quota, CancellationToken cancellationToken = default) + { + var entry = await _context.UserStorageQuotas.AddAsync(quota, cancellationToken); + return entry.Entity; + } + + /// + public void Update(UserStorageQuota quota) + { + _context.Entry(quota).State = EntityState.Modified; + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.UserStorageQuotas + .FirstOrDefaultAsync(q => q.Id == id, cancellationToken); + } + + /// + public async Task GetByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.UserStorageQuotas + .FirstOrDefaultAsync(q => q.UserId == userId, cancellationToken); + } + + /// + public async Task GetOrCreateAsync(string userId, CancellationToken cancellationToken = default) + { + var quota = await GetByUserIdAsync(userId, cancellationToken); + + if (quota != null) + return quota; + + // EN: Create default quota for new user / VI: Tạo quota mặc định cho user mới + quota = new UserStorageQuota(userId); + await AddAsync(quota, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + + return quota; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs new file mode 100644 index 00000000..20dc2b7d --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Persistence/StorageServiceContext.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Persistence; + +/// +/// EN: Storage Service DbContext. +/// VI: DbContext cho Storage Service. +/// +public class StorageServiceContext : DbContext, IUnitOfWork +{ + public DbSet StorageFiles => Set(); + public DbSet UserStorageQuotas => Set(); + + public StorageServiceContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // EN: Configure StorageFile entity / VI: Cấu hình entity StorageFile + modelBuilder.Entity(entity => + { + entity.ToTable("storage_files"); + + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .HasColumnName("id"); + + entity.Property(e => e.FileName) + .HasColumnName("file_name") + .HasMaxLength(255) + .IsRequired(); + + entity.Property(e => e.BucketName) + .HasColumnName("bucket_name") + .HasMaxLength(100) + .IsRequired(); + + entity.Property(e => e.ObjectKey) + .HasColumnName("object_key") + .HasMaxLength(500) + .IsRequired(); + + entity.Property(e => e.ContentType) + .HasColumnName("content_type") + .HasMaxLength(100) + .IsRequired(); + + entity.Property(e => e.FileSizeBytes) + .HasColumnName("file_size_bytes") + .IsRequired(); + + entity.Property(e => e.UserId) + .HasColumnName("user_id") + .HasMaxLength(100) + .IsRequired(); + + entity.Property(e => e.TenantId) + .HasColumnName("tenant_id") + .HasMaxLength(100); + + entity.Property(e => e.Provider) + .HasColumnName("provider") + .HasConversion() + .IsRequired(); + + entity.Property(e => e.AccessLevel) + .HasColumnName("access_level") + .HasConversion() + .IsRequired(); + + entity.Property(e => e.UploadedAt) + .HasColumnName("uploaded_at") + .IsRequired(); + + entity.Property(e => e.LastAccessedAt) + .HasColumnName("last_accessed_at"); + + entity.Property(e => e.ExpiresAt) + .HasColumnName("expires_at"); + + entity.Property(e => e.Checksum) + .HasColumnName("checksum") + .HasMaxLength(100); + + entity.Property(e => e.IsDeleted) + .HasColumnName("is_deleted") + .HasDefaultValue(false); + + entity.Property(e => e.DeletedAt) + .HasColumnName("deleted_at"); + + // EN: Indexes / VI: Indexes + entity.HasIndex(e => e.ObjectKey).IsUnique(); + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.UploadedAt); + entity.HasIndex(e => e.IsDeleted); + + // EN: Filter for soft delete / VI: Filter cho soft delete + entity.HasQueryFilter(e => !e.IsDeleted); + + // EN: Ignore domain events / VI: Bỏ qua domain events + entity.Ignore(e => e.DomainEvents); + }); + + // EN: Configure UserStorageQuota entity / VI: Cấu hình entity UserStorageQuota + modelBuilder.Entity(entity => + { + entity.ToTable("user_storage_quotas"); + + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .HasColumnName("id"); + + entity.Property(e => e.UserId) + .HasColumnName("user_id") + .HasMaxLength(100) + .IsRequired(); + + entity.Property(e => e.MaxStorageBytes) + .HasColumnName("max_storage_bytes") + .IsRequired(); + + entity.Property(e => e.UsedStorageBytes) + .HasColumnName("used_storage_bytes") + .IsRequired(); + + entity.Property(e => e.MaxFileCount) + .HasColumnName("max_file_count") + .IsRequired(); + + entity.Property(e => e.CurrentFileCount) + .HasColumnName("current_file_count") + .IsRequired(); + + entity.Property(e => e.QuotaTier) + .HasColumnName("quota_tier") + .HasMaxLength(50); + + entity.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + entity.Property(e => e.LastUpdatedAt) + .HasColumnName("last_updated_at") + .IsRequired(); + + // EN: Indexes / VI: Indexes + entity.HasIndex(e => e.UserId).IsUnique(); + + // EN: Ignore domain events / VI: Bỏ qua domain events + entity.Ignore(e => e.DomainEvents); + }); + } + + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + await base.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Repositories/SampleRepository.cs b/services/storage-service-net/src/StorageService.Infrastructure/Repositories/SampleRepository.cs new file mode 100644 index 00000000..4d566b3b --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Repositories/SampleRepository.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using StorageService.Domain.AggregatesModel.SampleAggregate; +using StorageService.Domain.SeedWork; + +namespace StorageService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Sample aggregate. +/// VI: Triển khai repository cho Sample aggregate. +/// +public class SampleRepository : ISampleRepository +{ + private readonly StorageServiceContext _context; + + /// + /// EN: Unit of work for transaction management. + /// VI: Unit of work cho quản lý transaction. + /// + public IUnitOfWork UnitOfWork => _context; + + public SampleRepository(StorageServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetAsync(Guid sampleId) + { + var sample = await _context.Samples + .Include(s => s.Status) + .FirstOrDefaultAsync(s => s.Id == sampleId); + + return sample; + } + + /// + public async Task> GetAllAsync() + { + return await _context.Samples + .Include(s => s.Status) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } + + /// + public Sample Add(Sample sample) + { + return _context.Samples.Add(sample).Entity; + } + + /// + public void Update(Sample sample) + { + _context.Entry(sample).State = EntityState.Modified; + } + + /// + public void Delete(Sample sample) + { + _context.Samples.Remove(sample); + } + + /// + public async Task> GetByStatusAsync(int statusId) + { + return await _context.Samples + .Include(s => s.Status) + .Where(s => s.StatusId == statusId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Storage/AliyunOssStorageProvider.cs b/services/storage-service-net/src/StorageService.Infrastructure/Storage/AliyunOssStorageProvider.cs new file mode 100644 index 00000000..e274bac1 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Storage/AliyunOssStorageProvider.cs @@ -0,0 +1,164 @@ +using Aliyun.OSS; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Infrastructure.Configuration; + +namespace StorageService.Infrastructure.Storage; + +/// +/// EN: Aliyun OSS storage provider implementation. +/// VI: Implementation storage provider cho Aliyun OSS. +/// +public class AliyunOssStorageProvider : IStorageProvider +{ + private readonly IOss _ossClient; + private readonly ILogger _logger; + private readonly AliyunOssSettings _settings; + + public StorageProvider ProviderType => StorageProvider.AliyunOSS; + + public AliyunOssStorageProvider( + IOptions settings, + ILogger logger) + { + _logger = logger; + _settings = settings.Value.AliyunOSS; + + _ossClient = new OssClient( + _settings.Endpoint, + _settings.AccessKeyId, + _settings.AccessKeySecret); + + _logger.LogInformation("Aliyun OSS client initialized with endpoint: {Endpoint}", _settings.Endpoint); + } + + /// + public Task UploadAsync( + string bucketName, + string objectKey, + Stream dataStream, + string contentType, + CancellationToken cancellationToken = default) + { + try + { + EnsureBucketExistsAsync(bucketName, cancellationToken).Wait(); + + var metadata = new ObjectMetadata { ContentType = contentType }; + var result = _ossClient.PutObject(bucketName, objectKey, dataStream, metadata); + + _logger.LogInformation("File uploaded to Aliyun OSS: {Bucket}/{ObjectKey}", bucketName, objectKey); + + return Task.FromResult(StorageResult.Ok(objectKey, dataStream.Length, result.ETag)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload file to Aliyun OSS: {Bucket}/{ObjectKey}", bucketName, objectKey); + return Task.FromResult(StorageResult.Fail(ex.Message)); + } + } + + /// + public Task DownloadAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + var ossObject = _ossClient.GetObject(bucketName, objectKey); + var memoryStream = new MemoryStream(); + ossObject.Content.CopyTo(memoryStream); + memoryStream.Position = 0; + + return Task.FromResult(memoryStream); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download file from Aliyun OSS: {Bucket}/{ObjectKey}", bucketName, objectKey); + return Task.FromResult(null); + } + } + + /// + public Task DeleteAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + _ossClient.DeleteObject(bucketName, objectKey); + _logger.LogInformation("File deleted from Aliyun OSS: {Bucket}/{ObjectKey}", bucketName, objectKey); + return Task.FromResult(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete file from Aliyun OSS: {Bucket}/{ObjectKey}", bucketName, objectKey); + return Task.FromResult(false); + } + } + + /// + public Task ExistsAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + var exists = _ossClient.DoesObjectExist(bucketName, objectKey); + return Task.FromResult(exists); + } + catch + { + return Task.FromResult(false); + } + } + + /// + public Task GetPreSignedDownloadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default) + { + var expiration = DateTime.UtcNow.AddSeconds(expirationSeconds); + var uri = _ossClient.GeneratePresignedUri(bucketName, objectKey, expiration, SignHttpMethod.Get); + return Task.FromResult(uri.ToString()); + } + + /// + public Task GetPreSignedUploadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default) + { + var expiration = DateTime.UtcNow.AddSeconds(expirationSeconds); + var uri = _ossClient.GeneratePresignedUri(bucketName, objectKey, expiration, SignHttpMethod.Put); + return Task.FromResult(uri.ToString()); + } + + /// + public Task EnsureBucketExistsAsync( + string bucketName, + CancellationToken cancellationToken = default) + { + try + { + if (!_ossClient.DoesBucketExist(bucketName)) + { + _ossClient.CreateBucket(bucketName); + _logger.LogInformation("Created Aliyun OSS bucket: {Bucket}", bucketName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to ensure bucket exists: {Bucket}", bucketName); + } + + return Task.CompletedTask; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Storage/IStorageProvider.cs b/services/storage-service-net/src/StorageService.Infrastructure/Storage/IStorageProvider.cs new file mode 100644 index 00000000..cd4f48bd --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Storage/IStorageProvider.cs @@ -0,0 +1,99 @@ +using StorageService.Domain.AggregatesModel.FileAggregate; + +namespace StorageService.Infrastructure.Storage; + +/// +/// EN: Storage operation result. +/// VI: Kết quả thao tác storage. +/// +public class StorageResult +{ + public bool Success { get; set; } + public string? ObjectKey { get; set; } + public string? DownloadUrl { get; set; } + public string? Error { get; set; } + public long? SizeBytes { get; set; } + public string? Checksum { get; set; } + + public static StorageResult Ok(string objectKey, long? sizeBytes = null, string? checksum = null) + => new() { Success = true, ObjectKey = objectKey, SizeBytes = sizeBytes, Checksum = checksum }; + + public static StorageResult Fail(string error) + => new() { Success = false, Error = error }; +} + +/// +/// EN: Interface for storage providers (MinIO, Aliyun OSS, etc.). +/// VI: Interface cho các storage provider (MinIO, Aliyun OSS, v.v.). +/// +public interface IStorageProvider +{ + /// EN: Provider type / VI: Loại provider + StorageProvider ProviderType { get; } + + /// + /// EN: Upload file to storage. + /// VI: Upload file lên storage. + /// + Task UploadAsync( + string bucketName, + string objectKey, + Stream dataStream, + string contentType, + CancellationToken cancellationToken = default); + + /// + /// EN: Download file from storage. + /// VI: Download file từ storage. + /// + Task DownloadAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default); + + /// + /// EN: Delete file from storage. + /// VI: Xóa file khỏi storage. + /// + Task DeleteAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default); + + /// + /// EN: Check if file exists. + /// VI: Kiểm tra file có tồn tại. + /// + Task ExistsAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default); + + /// + /// EN: Generate pre-signed URL for download. + /// VI: Tạo pre-signed URL để download. + /// + Task GetPreSignedDownloadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default); + + /// + /// EN: Generate pre-signed URL for upload. + /// VI: Tạo pre-signed URL để upload. + /// + Task GetPreSignedUploadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default); + + /// + /// EN: Ensure bucket exists (create if not). + /// VI: Đảm bảo bucket tồn tại (tạo nếu chưa có). + /// + Task EnsureBucketExistsAsync( + string bucketName, + CancellationToken cancellationToken = default); +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Storage/MinioStorageProvider.cs b/services/storage-service-net/src/StorageService.Infrastructure/Storage/MinioStorageProvider.cs new file mode 100644 index 00000000..f9eeea56 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Storage/MinioStorageProvider.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel.Args; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Infrastructure.Configuration; + +namespace StorageService.Infrastructure.Storage; + +/// +/// EN: MinIO storage provider implementation. +/// VI: Implementation storage provider cho MinIO. +/// +public class MinioStorageProvider : IStorageProvider +{ + private readonly IMinioClient _minioClient; + private readonly ILogger _logger; + + public StorageProvider ProviderType => StorageProvider.MinIO; + + public MinioStorageProvider( + IOptions settings, + ILogger logger) + { + _logger = logger; + var minioSettings = settings.Value.MinIO; + + _minioClient = new MinioClient() + .WithEndpoint(minioSettings.Endpoint) + .WithCredentials(minioSettings.AccessKey, minioSettings.SecretKey) + .WithSSL(minioSettings.UseSSL) + .Build(); + + _logger.LogInformation("MinIO client initialized with endpoint: {Endpoint}", minioSettings.Endpoint); + } + + /// + public async Task UploadAsync( + string bucketName, + string objectKey, + Stream dataStream, + string contentType, + CancellationToken cancellationToken = default) + { + try + { + await EnsureBucketExistsAsync(bucketName, cancellationToken); + + var putArgs = new PutObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey) + .WithStreamData(dataStream) + .WithObjectSize(dataStream.Length) + .WithContentType(contentType); + + var response = await _minioClient.PutObjectAsync(putArgs, cancellationToken); + + _logger.LogInformation("File uploaded to MinIO: {Bucket}/{ObjectKey}", bucketName, objectKey); + + return StorageResult.Ok(objectKey, dataStream.Length, response.Etag); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to upload file to MinIO: {Bucket}/{ObjectKey}", bucketName, objectKey); + return StorageResult.Fail(ex.Message); + } + } + + /// + public async Task DownloadAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + var memoryStream = new MemoryStream(); + + var getArgs = new GetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey) + .WithCallbackStream(stream => stream.CopyTo(memoryStream)); + + await _minioClient.GetObjectAsync(getArgs, cancellationToken); + + memoryStream.Position = 0; + return memoryStream; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download file from MinIO: {Bucket}/{ObjectKey}", bucketName, objectKey); + return null; + } + } + + /// + public async Task DeleteAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + var removeArgs = new RemoveObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey); + + await _minioClient.RemoveObjectAsync(removeArgs, cancellationToken); + + _logger.LogInformation("File deleted from MinIO: {Bucket}/{ObjectKey}", bucketName, objectKey); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete file from MinIO: {Bucket}/{ObjectKey}", bucketName, objectKey); + return false; + } + } + + /// + public async Task ExistsAsync( + string bucketName, + string objectKey, + CancellationToken cancellationToken = default) + { + try + { + var statArgs = new StatObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey); + + await _minioClient.StatObjectAsync(statArgs, cancellationToken); + return true; + } + catch + { + return false; + } + } + + /// + public async Task GetPreSignedDownloadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default) + { + var presignedArgs = new PresignedGetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey) + .WithExpiry(expirationSeconds); + + return await _minioClient.PresignedGetObjectAsync(presignedArgs); + } + + /// + public async Task GetPreSignedUploadUrlAsync( + string bucketName, + string objectKey, + int expirationSeconds = 3600, + CancellationToken cancellationToken = default) + { + var presignedArgs = new PresignedPutObjectArgs() + .WithBucket(bucketName) + .WithObject(objectKey) + .WithExpiry(expirationSeconds); + + return await _minioClient.PresignedPutObjectAsync(presignedArgs); + } + + /// + public async Task EnsureBucketExistsAsync( + string bucketName, + CancellationToken cancellationToken = default) + { + var bucketExistsArgs = new BucketExistsArgs().WithBucket(bucketName); + var exists = await _minioClient.BucketExistsAsync(bucketExistsArgs, cancellationToken); + + if (!exists) + { + var makeBucketArgs = new MakeBucketArgs().WithBucket(bucketName); + await _minioClient.MakeBucketAsync(makeBucketArgs, cancellationToken); + _logger.LogInformation("Created MinIO bucket: {Bucket}", bucketName); + } + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/Storage/StorageProviderFactory.cs b/services/storage-service-net/src/StorageService.Infrastructure/Storage/StorageProviderFactory.cs new file mode 100644 index 00000000..fe719ef4 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/Storage/StorageProviderFactory.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Infrastructure.Configuration; + +namespace StorageService.Infrastructure.Storage; + +/// +/// EN: Factory to create and select storage providers based on configuration. +/// VI: Factory để tạo và chọn storage provider dựa trên cấu hình. +/// +public interface IStorageProviderFactory +{ + /// + /// EN: Get the currently configured storage provider. + /// VI: Lấy storage provider đang được cấu hình. + /// + IStorageProvider GetProvider(); + + /// + /// EN: Get a specific storage provider by type. + /// VI: Lấy storage provider theo loại. + /// + IStorageProvider GetProvider(StorageProvider providerType); +} + +/// +/// EN: Implementation of storage provider factory. +/// VI: Implementation của storage provider factory. +/// +public class StorageProviderFactory : IStorageProviderFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly StorageSettings _settings; + private readonly ILogger _logger; + + public StorageProviderFactory( + IServiceProvider serviceProvider, + IOptions settings, + ILogger logger) + { + _serviceProvider = serviceProvider; + _settings = settings.Value; + _logger = logger; + } + + /// + public IStorageProvider GetProvider() + { + var providerType = _settings.Provider.ToLowerInvariant() switch + { + "minio" => StorageProvider.MinIO, + "aliyun" or "aliyunoss" => StorageProvider.AliyunOSS, + _ => throw new InvalidOperationException($"Unknown storage provider: {_settings.Provider}") + }; + + return GetProvider(providerType); + } + + /// + public IStorageProvider GetProvider(StorageProvider providerType) + { + _logger.LogDebug("Getting storage provider: {ProviderType}", providerType); + + return providerType switch + { + StorageProvider.MinIO => + (IStorageProvider)_serviceProvider.GetService(typeof(MinioStorageProvider))!, + StorageProvider.AliyunOSS => + (IStorageProvider)_serviceProvider.GetService(typeof(AliyunOssStorageProvider))!, + _ => throw new InvalidOperationException($"Unsupported storage provider: {providerType}") + }; + } +} diff --git a/services/storage-service-net/src/StorageService.Infrastructure/StorageService.Infrastructure.csproj b/services/storage-service-net/src/StorageService.Infrastructure/StorageService.Infrastructure.csproj new file mode 100644 index 00000000..6308b617 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/StorageService.Infrastructure.csproj @@ -0,0 +1,42 @@ + + + + StorageService.Infrastructure + StorageService.Infrastructure + Infrastructure layer for data access and external services for Storage Service + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/storage-service-net/src/StorageService.Infrastructure/StorageServiceContext.cs b/services/storage-service-net/src/StorageService.Infrastructure/StorageServiceContext.cs new file mode 100644 index 00000000..7124b573 --- /dev/null +++ b/services/storage-service-net/src/StorageService.Infrastructure/StorageServiceContext.cs @@ -0,0 +1,160 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using StorageService.Domain.AggregatesModel.SampleAggregate; +using StorageService.Domain.SeedWork; +using StorageService.Infrastructure.EntityConfigurations; + +namespace StorageService.Infrastructure; + +/// +/// EN: EF Core DbContext for StorageService. +/// VI: EF Core DbContext cho StorageService. +/// +public class StorageServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + /// + /// EN: Samples table. + /// VI: Bảng Samples. + /// + public DbSet Samples => Set(); + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public StorageServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public StorageServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("StorageServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/storage-service-net/tests/StorageService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/storage-service-net/tests/StorageService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..e9bcd941 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace StorageService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/storage-service-net/tests/StorageService.FunctionalTests/CustomWebApplicationFactory.cs b/services/storage-service-net/tests/StorageService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..a3c75f46 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using StorageService.Infrastructure; + +namespace StorageService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(StorageServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/storage-service-net/tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj b/services/storage-service-net/tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj new file mode 100644 index 00000000..223fc858 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + StorageService.FunctionalTests + StorageService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Application/CreateSampleCommandHandlerTests.cs new file mode 100644 index 00000000..5d81b3db --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/Application/CreateSampleCommandHandlerTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using StorageService.API.Application.Commands; +using StorageService.Domain.AggregatesModel.SampleAggregate; +using StorageService.Domain.SeedWork; +using Xunit; + +namespace StorageService.UnitTests.Application; + +/// +/// EN: Unit tests for CreateSampleCommandHandler. +/// VI: Unit tests cho CreateSampleCommandHandler. +/// +public class CreateSampleCommandHandlerTests +{ + private readonly Mock _mockRepository; + private readonly Mock> _mockLogger; + private readonly CreateSampleCommandHandler _handler; + + public CreateSampleCommandHandlerTests() + { + _mockRepository = new Mock(); + _mockLogger = new Mock>(); + + var mockUnitOfWork = new Mock(); + mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object); + + _handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", "Test Description"); + + _mockRepository.Setup(r => r.Add(It.IsAny())) + .Returns((Sample s) => s); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().NotBeEmpty(); + _mockRepository.Verify(r => r.Add(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCallSaveEntities() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", null); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny()), Times.Once); + } +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/Domain/SampleAggregateTests.cs b/services/storage-service-net/tests/StorageService.UnitTests/Domain/SampleAggregateTests.cs new file mode 100644 index 00000000..9833ce80 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/Domain/SampleAggregateTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using StorageService.Domain.AggregatesModel.SampleAggregate; +using StorageService.Domain.Exceptions; +using Xunit; + +namespace StorageService.UnitTests.Domain; + +/// +/// EN: Unit tests for Sample aggregate. +/// VI: Unit tests cho Sample aggregate. +/// +public class SampleAggregateTests +{ + [Fact] + public void CreateSample_WithValidName_ShouldCreateWithDraftStatus() + { + // Arrange + var name = "Test Sample"; + var description = "Test Description"; + + // Act + var sample = new Sample(name, description); + + // Assert + sample.Name.Should().Be(name); + sample.Description.Should().Be(description); + sample.Status.Should().Be(SampleStatus.Draft); + sample.Id.Should().NotBeEmpty(); + sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent + } + + [Fact] + public void CreateSample_WithEmptyName_ShouldThrowException() + { + // Arrange + var name = ""; + + // Act + var act = () => new Sample(name); + + // Assert + act.Should().Throw() + .WithMessage("Sample name cannot be empty"); + } + + [Fact] + public void Activate_WhenDraft_ShouldChangeToActive() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.ClearDomainEvents(); + + // Act + sample.Activate(); + + // Assert + sample.Status.Should().Be(SampleStatus.Active); + sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent + } + + [Fact] + public void Activate_WhenNotDraft_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + + // Act + var act = () => sample.Activate(); + + // Assert + act.Should().Throw() + .WithMessage("Only draft samples can be activated"); + } + + [Fact] + public void Complete_WhenActive_ShouldChangeToCompleted() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.ClearDomainEvents(); + + // Act + sample.Complete(); + + // Assert + sample.Status.Should().Be(SampleStatus.Completed); + } + + [Fact] + public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled() + { + // Arrange + var sample = new Sample("Test Sample"); + + // Act + sample.Cancel(); + + // Assert + sample.Status.Should().Be(SampleStatus.Cancelled); + } + + [Fact] + public void Cancel_WhenCompleted_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.Complete(); + + // Act + var act = () => sample.Cancel(); + + // Assert + act.Should().Throw() + .WithMessage("Cannot cancel a completed sample"); + } + + [Fact] + public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription() + { + // Arrange + var sample = new Sample("Original Name", "Original Description"); + var newName = "Updated Name"; + var newDescription = "Updated Description"; + + // Act + sample.Update(newName, newDescription); + + // Assert + sample.Name.Should().Be(newName); + sample.Description.Should().Be(newDescription); + sample.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void Update_WhenCancelled_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Cancel(); + + // Act + var act = () => sample.Update("New Name", null); + + // Assert + act.Should().Throw() + .WithMessage("Cannot update a cancelled sample"); + } +} diff --git a/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj b/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj new file mode 100644 index 00000000..86811727 --- /dev/null +++ b/services/storage-service-net/tests/StorageService.UnitTests/StorageService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + StorageService.UnitTests + StorageService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +