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.
This commit is contained in:
11
NOTE.MD
11
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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<UserDto>("/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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<UserDto>("/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
|
||||
|
||||
@@ -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<br/>(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<br/>(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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// EN: Command to confirm email
|
||||
// VI: Command để xác nhận email
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace IamService.API.Application.Commands.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to confirm email with token.
|
||||
/// VI: Command để xác nhận email với token.
|
||||
/// </summary>
|
||||
public record ConfirmEmailCommand(
|
||||
string Email,
|
||||
string Token
|
||||
) : IRequest<ConfirmEmailResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of email confirmation.
|
||||
/// VI: Kết quả xác nhận email.
|
||||
/// </summary>
|
||||
public record ConfirmEmailResult(
|
||||
bool Success,
|
||||
string Message
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ConfirmEmailCommand.
|
||||
/// VI: Handler cho ConfirmEmailCommand.
|
||||
/// </summary>
|
||||
public class ConfirmEmailCommandHandler : IRequestHandler<ConfirmEmailCommand, ConfirmEmailResult>
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<ConfirmEmailCommandHandler> _logger;
|
||||
|
||||
public ConfirmEmailCommandHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<ConfirmEmailCommandHandler> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ConfirmEmailResult> 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// EN: Command to disable 2FA
|
||||
// VI: Command để tắt 2FA
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace IamService.API.Application.Commands.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to disable 2FA for user.
|
||||
/// VI: Command để tắt 2FA cho user.
|
||||
/// </summary>
|
||||
public record Disable2FACommand(
|
||||
Guid UserId,
|
||||
string Code
|
||||
) : IRequest<Disable2FAResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of disabling 2FA.
|
||||
/// VI: Kết quả tắt 2FA.
|
||||
/// </summary>
|
||||
public record Disable2FAResult(
|
||||
bool Success,
|
||||
string Message
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for Disable2FACommand.
|
||||
/// VI: Handler cho Disable2FACommand.
|
||||
/// </summary>
|
||||
public class Disable2FACommandHandler : IRequestHandler<Disable2FACommand, Disable2FAResult>
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITwoFactorService _twoFactorService;
|
||||
private readonly ILogger<Disable2FACommandHandler> _logger;
|
||||
|
||||
public Disable2FACommandHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITwoFactorService twoFactorService,
|
||||
ILogger<Disable2FACommandHandler> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_twoFactorService = twoFactorService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Disable2FAResult> 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// EN: Command to enable 2FA
|
||||
// VI: Command để bật 2FA
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace IamService.API.Application.Commands.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to enable 2FA for user.
|
||||
/// VI: Command để bật 2FA cho user.
|
||||
/// </summary>
|
||||
public record Enable2FACommand(
|
||||
Guid UserId
|
||||
) : IRequest<Enable2FAResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of enabling 2FA.
|
||||
/// VI: Kết quả bật 2FA.
|
||||
/// </summary>
|
||||
public record Enable2FAResult(
|
||||
bool Success,
|
||||
string SecretKey,
|
||||
string QrCodeBase64,
|
||||
string ManualEntryKey,
|
||||
string[] RecoveryCodes
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for Enable2FACommand.
|
||||
/// VI: Handler cho Enable2FACommand.
|
||||
/// </summary>
|
||||
public class Enable2FACommandHandler : IRequestHandler<Enable2FACommand, Enable2FAResult>
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITwoFactorService _twoFactorService;
|
||||
private readonly ILogger<Enable2FACommandHandler> _logger;
|
||||
|
||||
public Enable2FACommandHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITwoFactorService twoFactorService,
|
||||
ILogger<Enable2FACommandHandler> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_twoFactorService = twoFactorService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Enable2FAResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to process external login from OAuth providers.
|
||||
/// VI: Command xử lý external login từ OAuth providers.
|
||||
/// </summary>
|
||||
/// <param name="Provider">OAuth provider name (Google, Facebook)</param>
|
||||
/// <param name="ProviderUserId">User ID from the provider</param>
|
||||
/// <param name="Email">User email from provider</param>
|
||||
/// <param name="Name">User name from provider</param>
|
||||
/// <param name="PictureUrl">Profile picture URL</param>
|
||||
public record ExternalLoginCommand(
|
||||
string Provider,
|
||||
string ProviderUserId,
|
||||
string Email,
|
||||
string? Name,
|
||||
string? PictureUrl) : IRequest<ExternalLoginResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of external login command.
|
||||
/// VI: Kết quả của command external login.
|
||||
/// </summary>
|
||||
public record ExternalLoginResult
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Whether login was successful.
|
||||
/// VI: Đăng nhập có thành công không.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Error message if login failed.
|
||||
/// VI: Thông báo lỗi nếu đăng nhập thất bại.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: User ID if successful.
|
||||
/// VI: User ID nếu thành công.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: User email.
|
||||
/// VI: Email của user.
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether this is a new user registration.
|
||||
/// VI: Đây có phải đăng ký user mới không.
|
||||
/// </summary>
|
||||
public bool IsNewUser { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Access token for the user.
|
||||
/// VI: Access token cho user.
|
||||
/// </summary>
|
||||
public string? AccessToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Refresh token for the user.
|
||||
/// VI: Refresh token cho user.
|
||||
/// </summary>
|
||||
public string? RefreshToken { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for processing external login from OAuth providers.
|
||||
/// VI: Handler xử lý external login từ OAuth providers.
|
||||
/// </summary>
|
||||
public class ExternalLoginCommandHandler : IRequestHandler<ExternalLoginCommand, ExternalLoginResult>
|
||||
{
|
||||
private readonly ISocialLoginService _socialLoginService;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly ILogger<ExternalLoginCommandHandler> _logger;
|
||||
|
||||
public ExternalLoginCommandHandler(
|
||||
ISocialLoginService socialLoginService,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
ILogger<ExternalLoginCommandHandler> logger)
|
||||
{
|
||||
_socialLoginService = socialLoginService;
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExternalLoginResult> 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to send email verification link.
|
||||
/// VI: Command để gửi link xác thực email.
|
||||
/// </summary>
|
||||
public record SendVerificationEmailCommand(
|
||||
string Email
|
||||
) : IRequest<SendVerificationEmailResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of sending verification email.
|
||||
/// VI: Kết quả gửi email xác thực.
|
||||
/// </summary>
|
||||
public record SendVerificationEmailResult(
|
||||
bool Success,
|
||||
string Message
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for SendVerificationEmailCommand.
|
||||
/// VI: Handler cho SendVerificationEmailCommand.
|
||||
/// </summary>
|
||||
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, SendVerificationEmailResult>
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
|
||||
|
||||
public SendVerificationEmailCommandHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IEmailService emailService,
|
||||
ILogger<SendVerificationEmailCommandHandler> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_emailService = emailService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SendVerificationEmailResult> 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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public record Verify2FACommand(
|
||||
Guid UserId,
|
||||
string Code
|
||||
) : IRequest<Verify2FAResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of 2FA verification.
|
||||
/// VI: Kết quả xác minh 2FA.
|
||||
/// </summary>
|
||||
public record Verify2FAResult(
|
||||
bool Success,
|
||||
string Message
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for Verify2FACommand.
|
||||
/// VI: Handler cho Verify2FACommand.
|
||||
/// </summary>
|
||||
public class Verify2FACommandHandler : IRequestHandler<Verify2FACommand, Verify2FAResult>
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITwoFactorService _twoFactorService;
|
||||
private readonly ILogger<Verify2FACommandHandler> _logger;
|
||||
|
||||
public Verify2FACommandHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITwoFactorService twoFactorService,
|
||||
ILogger<Verify2FACommandHandler> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_twoFactorService = twoFactorService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Verify2FAResult> 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.");
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,332 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new LogoutResponse { Success = result.Success, Message = result.Message });
|
||||
}
|
||||
|
||||
#region Email Verification Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send email verification link.
|
||||
/// VI: Gửi link xác thực email.
|
||||
/// </summary>
|
||||
/// <param name="request">Email address to verify</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Result of sending verification email</returns>
|
||||
[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<SendVerificationEmailResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> 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<SendVerificationEmailResult>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Confirm email with token.
|
||||
/// VI: Xác nhận email với token.
|
||||
/// </summary>
|
||||
/// <param name="request">Email and token</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Result of email confirmation</returns>
|
||||
[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<ConfirmEmailResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> 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<ConfirmEmailResult>.Fail("INVALID_TOKEN", result.Message));
|
||||
}
|
||||
|
||||
return Ok(ApiResponse<ConfirmEmailResult>.Ok(result));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Two-Factor Authentication Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// EN: Enable 2FA for current user.
|
||||
/// VI: Bật 2FA cho user hiện tại.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>QR code and recovery codes</returns>
|
||||
[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<Enable2FAResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> 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<Enable2FAResponse>.Ok(new Enable2FAResponse
|
||||
{
|
||||
QrCodeBase64 = result.QrCodeBase64,
|
||||
ManualEntryKey = result.ManualEntryKey,
|
||||
RecoveryCodes = result.RecoveryCodes
|
||||
}));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ApiResponse<Enable2FAResponse>.Fail("2FA_ALREADY_ENABLED", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verify 2FA code and complete setup.
|
||||
/// VI: Xác minh mã 2FA và hoàn tất cài đặt.
|
||||
/// </summary>
|
||||
/// <param name="request">2FA verification code</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Result of 2FA verification</returns>
|
||||
[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<Verify2FAResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> 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<Verify2FAResult>.Fail("INVALID_CODE", result.Message));
|
||||
}
|
||||
|
||||
return Ok(ApiResponse<Verify2FAResult>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Disable 2FA for current user.
|
||||
/// VI: Tắt 2FA cho user hiện tại.
|
||||
/// </summary>
|
||||
/// <param name="request">Current 2FA code for verification</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Result of disabling 2FA</returns>
|
||||
[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<Disable2FAResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> 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<Disable2FAResult>.Fail("DISABLE_FAILED", result.Message));
|
||||
}
|
||||
|
||||
return Ok(ApiResponse<Disable2FAResult>.Ok(result));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Social Login Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="provider">Provider name (Google, Facebook)</param>
|
||||
/// <param name="returnUrl">URL to redirect after login</param>
|
||||
/// <returns>Challenge result for external provider</returns>
|
||||
[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<object>.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handle callback from external OAuth provider.
|
||||
/// VI: Xử lý callback từ OAuth provider bên ngoài.
|
||||
/// </summary>
|
||||
/// <param name="returnUrl">URL to redirect after login</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>External login result with tokens</returns>
|
||||
[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<IActionResult> 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<ExternalLoginResult>.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<ExternalLoginResult>.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<ExternalLoginResult>.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<ExternalLoginResult>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of linked providers</returns>
|
||||
[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<LinkedAccountsResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> 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<LinkedAccountsResponse>.Ok(new LinkedAccountsResponse
|
||||
{
|
||||
LinkedProviders = providers
|
||||
}));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request/Response Models
|
||||
@@ -339,3 +665,134 @@ public class LogoutResponse
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Email Verification Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request body for sending verification email.
|
||||
/// VI: Request body để gửi email xác thực.
|
||||
/// </summary>
|
||||
public class SendVerificationEmailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Email address to verify.
|
||||
/// VI: Địa chỉ email cần xác thực.
|
||||
/// </summary>
|
||||
/// <example>user@example.com</example>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request body for confirming email.
|
||||
/// VI: Request body để xác nhận email.
|
||||
/// </summary>
|
||||
public class ConfirmEmailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Email address.
|
||||
/// VI: Địa chỉ email.
|
||||
/// </summary>
|
||||
/// <example>user@example.com</example>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verification token from email.
|
||||
/// VI: Token xác thực từ email.
|
||||
/// </summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 2FA Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// EN: Response for enabling 2FA.
|
||||
/// VI: Response cho việc bật 2FA.
|
||||
/// </summary>
|
||||
public class Enable2FAResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: QR code as base64 image.
|
||||
/// VI: QR code dạng ảnh base64.
|
||||
/// </summary>
|
||||
public string QrCodeBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Manual entry key for authenticator apps.
|
||||
/// VI: Key nhập thủ công cho ứng dụng authenticator.
|
||||
/// </summary>
|
||||
public string ManualEntryKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Recovery codes for account recovery.
|
||||
/// VI: Mã khôi phục cho tài khoản.
|
||||
/// </summary>
|
||||
public string[] RecoveryCodes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request body for 2FA verification.
|
||||
/// VI: Request body cho xác minh 2FA.
|
||||
/// </summary>
|
||||
public class Verify2FARequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: TOTP code from authenticator app.
|
||||
/// VI: Mã TOTP từ ứng dụng authenticator.
|
||||
/// </summary>
|
||||
/// <example>123456</example>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request body for disabling 2FA.
|
||||
/// VI: Request body cho việc tắt 2FA.
|
||||
/// </summary>
|
||||
public class Disable2FARequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Current TOTP code for verification.
|
||||
/// VI: Mã TOTP hiện tại để xác minh.
|
||||
/// </summary>
|
||||
/// <example>123456</example>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Social Login Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// EN: Response for linked accounts.
|
||||
/// VI: Response cho các tài khoản đã liên kết.
|
||||
/// </summary>
|
||||
public class LinkedAccountsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: List of linked OAuth providers.
|
||||
/// VI: Danh sách các OAuth providers đã liên kết.
|
||||
/// </summary>
|
||||
public List<LinkedAccountInfo> LinkedProviders { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Information about a linked account.
|
||||
/// VI: Thông tin về tài khoản đã liên kết.
|
||||
/// </summary>
|
||||
public class LinkedAccountInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Provider name (Google, Facebook).
|
||||
/// VI: Tên provider (Google, Facebook).
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Display name of the provider.
|
||||
/// VI: Tên hiển thị của provider.
|
||||
/// </summary>
|
||||
public string ProviderDisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -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<Caching.ICacheService, Caching.RedisCacheService>();
|
||||
}
|
||||
|
||||
// EN: Configure Email service
|
||||
// VI: Cấu hình Email service
|
||||
services.Configure<EmailSettings>(configuration.GetSection(EmailSettings.SectionName));
|
||||
services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
|
||||
// EN: Configure 2FA service
|
||||
// VI: Cấu hình 2FA service
|
||||
services.Configure<TwoFactorSettings>(configuration.GetSection(TwoFactorSettings.SectionName));
|
||||
services.AddScoped<ITwoFactorService, TotpTwoFactorService>();
|
||||
|
||||
// EN: Configure Social Login service
|
||||
// VI: Cấu hình Social Login service
|
||||
services.Configure<SocialLoginSettings>(configuration.GetSection("SocialLogin"));
|
||||
services.AddScoped<ISocialLoginService, SocialLoginService>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// EN: Email settings configuration for SMTP
|
||||
// VI: Cấu hình email settings cho SMTP
|
||||
|
||||
namespace IamService.Infrastructure.Email;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Email settings for SMTP configuration.
|
||||
/// VI: Cấu hình SMTP cho email.
|
||||
/// </summary>
|
||||
public class EmailSettings
|
||||
{
|
||||
public const string SectionName = "Email";
|
||||
|
||||
/// <summary>
|
||||
/// EN: SMTP server address (e.g., smtp.mailgun.org).
|
||||
/// VI: Địa chỉ SMTP server.
|
||||
/// </summary>
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: SMTP port (typically 587 for TLS).
|
||||
/// VI: Port SMTP (thường là 587 cho TLS).
|
||||
/// </summary>
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
|
||||
/// <summary>
|
||||
/// EN: SMTP login username.
|
||||
/// VI: Tên đăng nhập SMTP.
|
||||
/// </summary>
|
||||
public string SmtpLogin { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: SMTP password.
|
||||
/// VI: Mật khẩu SMTP.
|
||||
/// </summary>
|
||||
public string SmtpPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sender email address.
|
||||
/// VI: Địa chỉ email người gửi.
|
||||
/// </summary>
|
||||
public string SenderEmail { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sender display name.
|
||||
/// VI: Tên hiển thị người gửi.
|
||||
/// </summary>
|
||||
public string SenderName { get; set; } = "IAM Service";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base URL for verification links.
|
||||
/// VI: URL cơ sở cho các link xác thực.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:5001";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// EN: Email service interface for sending emails
|
||||
// VI: Interface email service để gửi email
|
||||
|
||||
namespace IamService.Infrastructure.Email;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Email service interface for sending various types of emails.
|
||||
/// VI: Interface email service để gửi các loại email khác nhau.
|
||||
/// </summary>
|
||||
public interface IEmailService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Send a generic email.
|
||||
/// VI: Gửi email chung.
|
||||
/// </summary>
|
||||
Task SendEmailAsync(string to, string subject, string htmlBody, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send email verification email with token.
|
||||
/// VI: Gửi email xác thực với token.
|
||||
/// </summary>
|
||||
Task SendVerificationEmailAsync(string email, string token, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send 2FA code via email.
|
||||
/// VI: Gửi mã 2FA qua email.
|
||||
/// </summary>
|
||||
Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send password reset email.
|
||||
/// VI: Gửi email đặt lại mật khẩu.
|
||||
/// </summary>
|
||||
Task SendPasswordResetEmailAsync(string email, string token, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: SMTP-based email service implementation using MailKit.
|
||||
/// VI: Implementation email service dựa trên SMTP sử dụng MailKit.
|
||||
/// </summary>
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings;
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
|
||||
public SmtpEmailService(
|
||||
IOptions<EmailSettings> settings,
|
||||
ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<title>Email Verification</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #4a90d9; color: white; padding: 20px; text-align: center; }}
|
||||
.content {{ padding: 30px; background: #f9f9f9; }}
|
||||
.button {{ display: inline-block; padding: 12px 30px; background: #4a90d9; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
|
||||
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""container"">
|
||||
<div class=""header"">
|
||||
<h1>Email Verification</h1>
|
||||
</div>
|
||||
<div class=""content"">
|
||||
<p>Hello,</p>
|
||||
<p>Thank you for registering. Please click the button below to verify your email address:</p>
|
||||
<p style=""text-align: center;"">
|
||||
<a href=""{verificationUrl}"" class=""button"">Verify Email</a>
|
||||
</p>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style=""word-break: break-all; font-size: 12px;"">{verificationUrl}</p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you did not create an account, please ignore this email.</p>
|
||||
</div>
|
||||
<div class=""footer"">
|
||||
<p>© {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
await SendEmailAsync(email, "Verify Your Email Address", htmlBody, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Send2FACodeAsync(string email, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<title>2FA Verification Code</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #50c878; color: white; padding: 20px; text-align: center; }}
|
||||
.content {{ padding: 30px; background: #f9f9f9; }}
|
||||
.code {{ font-size: 32px; font-weight: bold; text-align: center; padding: 20px; background: #fff; border: 2px dashed #50c878; margin: 20px 0; letter-spacing: 8px; }}
|
||||
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""container"">
|
||||
<div class=""header"">
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
</div>
|
||||
<div class=""content"">
|
||||
<p>Hello,</p>
|
||||
<p>Your verification code is:</p>
|
||||
<div class=""code"">{code}</div>
|
||||
<p>This code will expire in 5 minutes.</p>
|
||||
<p>If you did not request this code, please secure your account immediately.</p>
|
||||
</div>
|
||||
<div class=""footer"">
|
||||
<p>© {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
await SendEmailAsync(email, "Your 2FA Verification Code", htmlBody, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<title>Password Reset</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #ff6b6b; color: white; padding: 20px; text-align: center; }}
|
||||
.content {{ padding: 30px; background: #f9f9f9; }}
|
||||
.button {{ display: inline-block; padding: 12px 30px; background: #ff6b6b; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }}
|
||||
.footer {{ padding: 20px; text-align: center; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""container"">
|
||||
<div class=""header"">
|
||||
<h1>Password Reset</h1>
|
||||
</div>
|
||||
<div class=""content"">
|
||||
<p>Hello,</p>
|
||||
<p>We received a request to reset your password. Click the button below to proceed:</p>
|
||||
<p style=""text-align: center;"">
|
||||
<a href=""{resetUrl}"" class=""button"">Reset Password</a>
|
||||
</p>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style=""word-break: break-all; font-size: 12px;"">{resetUrl}</p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you did not request a password reset, please ignore this email.</p>
|
||||
</div>
|
||||
<div class=""footer"">
|
||||
<p>© {DateTime.UtcNow.Year} IAM Service. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
await SendEmailAsync(email, "Reset Your Password", htmlBody, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@
|
||||
<!-- EN: Redis cache / VI: Redis cache -->
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
|
||||
<!-- EN: Email sending with MailKit / VI: Gửi email với MailKit -->
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
|
||||
<!-- EN: 2FA with TOTP / VI: 2FA với TOTP -->
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
|
||||
<!-- EN: ASP.NET Core Identity / VI: ASP.NET Core Identity -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
|
||||
|
||||
@@ -38,6 +45,10 @@
|
||||
|
||||
<!-- EN: JWT Bearer for API authentication / VI: JWT Bearer cho API authentication -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
||||
|
||||
<!-- EN: Social Login with Google and Facebook / VI: Social Login với Google và Facebook -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of external authentication.
|
||||
/// VI: Kết quả xác thực bên ngoài.
|
||||
/// </summary>
|
||||
public record ExternalAuthResult
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Whether authentication was successful.
|
||||
/// VI: Xác thực có thành công không.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Error message if authentication failed.
|
||||
/// VI: Thông báo lỗi nếu xác thực thất bại.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: External provider name (Google, Facebook).
|
||||
/// VI: Tên provider bên ngoài (Google, Facebook).
|
||||
/// </summary>
|
||||
public string? Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: User ID from external provider.
|
||||
/// VI: User ID từ provider bên ngoài.
|
||||
/// </summary>
|
||||
public string? ProviderUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: User email from external provider.
|
||||
/// VI: Email người dùng từ provider bên ngoài.
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: User name from external provider.
|
||||
/// VI: Tên người dùng từ provider bên ngoài.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Profile picture URL.
|
||||
/// VI: URL ảnh đại diện.
|
||||
/// </summary>
|
||||
public string? PictureUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: The user if already exists or was created.
|
||||
/// VI: User nếu đã tồn tại hoặc được tạo mới.
|
||||
/// </summary>
|
||||
public ApplicationUser? User { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether this is a new user registration.
|
||||
/// VI: Đây có phải là đăng ký người dùng mới không.
|
||||
/// </summary>
|
||||
public bool IsNewUser { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for handling social login operations.
|
||||
/// VI: Interface xử lý các thao tác social login.
|
||||
/// </summary>
|
||||
public interface ISocialLoginService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="provider">Provider name (Google, Facebook)</param>
|
||||
/// <param name="providerUserId">User ID from provider</param>
|
||||
/// <param name="email">User email</param>
|
||||
/// <param name="name">User name</param>
|
||||
/// <param name="pictureUrl">Profile picture URL</param>
|
||||
/// <returns>External authentication result</returns>
|
||||
Task<ExternalAuthResult> ProcessExternalLoginAsync(
|
||||
string provider,
|
||||
string providerUserId,
|
||||
string email,
|
||||
string? name,
|
||||
string? pictureUrl);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="userId">Existing user ID</param>
|
||||
/// <param name="provider">Provider name</param>
|
||||
/// <param name="providerUserId">User ID from provider</param>
|
||||
/// <returns>Success result</returns>
|
||||
Task<bool> LinkExternalAccountAsync(Guid userId, string provider, string providerUserId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unlink external account from user.
|
||||
/// VI: Hủy liên kết tài khoản bên ngoài khỏi user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User ID</param>
|
||||
/// <param name="provider">Provider name</param>
|
||||
/// <returns>Success result</returns>
|
||||
Task<bool> UnlinkExternalAccountAsync(Guid userId, string provider);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="userId">User ID</param>
|
||||
/// <returns>List of provider names</returns>
|
||||
Task<IEnumerable<string>> GetLinkedProvidersAsync(Guid userId);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Service for handling social login operations.
|
||||
/// VI: Service xử lý các thao tác social login.
|
||||
/// </summary>
|
||||
public class SocialLoginService : ISocialLoginService
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<SocialLoginService> _logger;
|
||||
|
||||
public SocialLoginService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<SocialLoginService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalAuthResult> 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<string>> GetLinkedProvidersAsync(Guid userId)
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId.ToString());
|
||||
if (user == null)
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
var logins = await _userManager.GetLoginsAsync(user);
|
||||
return logins.Select(l => l.LoginProvider);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Configuration settings for Google OAuth.
|
||||
/// VI: Cấu hình cho Google OAuth.
|
||||
/// </summary>
|
||||
public class GoogleAuthSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Google OAuth Client ID.
|
||||
/// VI: Client ID của Google OAuth.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Google OAuth Client Secret.
|
||||
/// VI: Client Secret của Google OAuth.
|
||||
/// </summary>
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Configuration settings for Facebook OAuth.
|
||||
/// VI: Cấu hình cho Facebook OAuth.
|
||||
/// </summary>
|
||||
public class FacebookAuthSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Facebook App ID.
|
||||
/// VI: App ID của Facebook.
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Facebook App Secret.
|
||||
/// VI: App Secret của Facebook.
|
||||
/// </summary>
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Combined social login settings.
|
||||
/// VI: Cấu hình tổng hợp social login.
|
||||
/// </summary>
|
||||
public class SocialLoginSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Google OAuth settings.
|
||||
/// VI: Cấu hình Google OAuth.
|
||||
/// </summary>
|
||||
public GoogleAuthSettings Google { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Facebook OAuth settings.
|
||||
/// VI: Cấu hình Facebook OAuth.
|
||||
/// </summary>
|
||||
public FacebookAuthSettings Facebook { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Callback URL after external authentication.
|
||||
/// VI: URL callback sau khi xác thực bên ngoài.
|
||||
/// </summary>
|
||||
public string CallbackUrl { get; set; } = "/api/auth/external-callback";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Two-factor authentication service interface.
|
||||
/// VI: Interface service xác thực hai yếu tố.
|
||||
/// </summary>
|
||||
public interface ITwoFactorService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Generate a new secret key for 2FA.
|
||||
/// VI: Tạo secret key mới cho 2FA.
|
||||
/// </summary>
|
||||
string GenerateSecretKey();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate QR code URI for authenticator apps.
|
||||
/// VI: Tạo URI QR code cho ứng dụng authenticator.
|
||||
/// </summary>
|
||||
string GenerateQrCodeUri(string email, string secretKey);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate QR code as base64 image.
|
||||
/// VI: Tạo QR code dưới dạng ảnh base64.
|
||||
/// </summary>
|
||||
string GenerateQrCodeBase64(string email, string secretKey);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate TOTP code against secret key.
|
||||
/// VI: Xác thực mã TOTP với secret key.
|
||||
/// </summary>
|
||||
bool ValidateCode(string secretKey, string code);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate recovery codes.
|
||||
/// VI: Tạo mã khôi phục.
|
||||
/// </summary>
|
||||
IEnumerable<string> GenerateRecoveryCodes(int count = 10);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: TOTP-based two-factor authentication service implementation.
|
||||
/// VI: Implementation service 2FA dựa trên TOTP.
|
||||
/// </summary>
|
||||
public class TotpTwoFactorService : ITwoFactorService
|
||||
{
|
||||
private readonly TwoFactorSettings _settings;
|
||||
private readonly ILogger<TotpTwoFactorService> _logger;
|
||||
|
||||
public TotpTwoFactorService(
|
||||
IOptions<TwoFactorSettings> settings,
|
||||
ILogger<TotpTwoFactorService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> GenerateRecoveryCodes(int count = 10)
|
||||
{
|
||||
var codes = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Settings for two-factor authentication.
|
||||
/// VI: Cấu hình cho xác thực hai yếu tố.
|
||||
/// </summary>
|
||||
public class TwoFactorSettings
|
||||
{
|
||||
public const string SectionName = "TwoFactor";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Issuer name displayed in authenticator apps.
|
||||
/// VI: Tên issuer hiển thị trong ứng dụng authenticator.
|
||||
/// </summary>
|
||||
public string Issuer { get; set; } = "IAM Service";
|
||||
|
||||
/// <summary>
|
||||
/// EN: Code length (default 6 digits).
|
||||
/// VI: Độ dài mã (mặc định 6 chữ số).
|
||||
/// </summary>
|
||||
public int CodeLength { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public int ValidityPeriodSeconds { get; set; } = 30;
|
||||
}
|
||||
40
services/storage-service-net/.env.example
Normal file
40
services/storage-service-net/.env.example
Normal file
@@ -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
|
||||
75
services/storage-service-net/.gitignore
vendored
Normal file
75
services/storage-service-net/.gitignore
vendored
Normal file
@@ -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
|
||||
~$*
|
||||
22
services/storage-service-net/Directory.Build.props
Normal file
22
services/storage-service-net/Directory.Build.props
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Authors>GoodGo Team</Authors>
|
||||
<Company>GoodGo</Company>
|
||||
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
services/storage-service-net/Dockerfile
Normal file
66
services/storage-service-net/Dockerfile
Normal file
@@ -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"]
|
||||
95
services/storage-service-net/README.md
Normal file
95
services/storage-service-net/README.md
Normal file
@@ -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
|
||||
11
services/storage-service-net/StorageService.slnx
Normal file
11
services/storage-service-net/StorageService.slnx
Normal file
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/StorageService.API/StorageService.API.csproj" />
|
||||
<Project Path="src/StorageService.Domain/StorageService.Domain.csproj" />
|
||||
<Project Path="src/StorageService.Infrastructure/StorageService.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/StorageService.FunctionalTests/StorageService.FunctionalTests.csproj" />
|
||||
<Project Path="tests/StorageService.UnitTests/StorageService.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
102
services/storage-service-net/docker-compose.yml
Normal file
102
services/storage-service-net/docker-compose.yml
Normal file
@@ -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
|
||||
271
services/storage-service-net/docs/en/ARCHITECTURE.md
Normal file
271
services/storage-service-net/docs/en/ARCHITECTURE.md
Normal file
@@ -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)
|
||||
265
services/storage-service-net/docs/en/README.md
Normal file
265
services/storage-service-net/docs/en/README.md
Normal file
@@ -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<CreateSampleCommandResult>;
|
||||
|
||||
// Handle command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Read Operations)
|
||||
|
||||
```csharp
|
||||
// Define query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Business logic validation
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
// State transition
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run with coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Run specific test project
|
||||
dotnet test tests/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
|
||||
271
services/storage-service-net/docs/vi/ARCHITECTURE.md
Normal file
271
services/storage-service-net/docs/vi/ARCHITECTURE.md
Normal file
@@ -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)
|
||||
265
services/storage-service-net/docs/vi/README.md
Normal file
265
services/storage-service-net/docs/vi/README.md
Normal file
@@ -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<CreateSampleCommandResult>;
|
||||
|
||||
// Xử lý command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Thao Tác Đọc)
|
||||
|
||||
```csharp
|
||||
// Định nghĩa query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Validation business logic
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Tên sample không được để trống");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt");
|
||||
// Chuyển đổi trạng thái
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kiểm Thử
|
||||
|
||||
```bash
|
||||
# Chạy tất cả tests
|
||||
dotnet test
|
||||
|
||||
# Chạy với coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Chạy project test cụ thể
|
||||
dotnet test tests/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
|
||||
7
services/storage-service-net/global.json
Normal file
7
services/storage-service-net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for logging request handling.
|
||||
/// VI: MediatR behavior để logging việc xử lý request.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StorageService.Infrastructure;
|
||||
|
||||
namespace StorageService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for handling database transactions.
|
||||
/// VI: MediatR behavior để xử lý database transactions.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly StorageServiceContext _dbContext;
|
||||
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public TransactionBehavior(
|
||||
StorageServiceContext dbContext,
|
||||
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for FluentValidation integration.
|
||||
/// VI: MediatR behavior để tích hợp FluentValidation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class ValidatorBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
private readonly ILogger<ValidatorBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public ValidatorBehavior(
|
||||
IEnumerable<IValidator<TRequest>> validators,
|
||||
ILogger<ValidatorBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_validators = validators ?? throw new ArgumentNullException(nameof(validators));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> 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<TRequest>(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a file.
|
||||
/// VI: Command để xóa file.
|
||||
/// </summary>
|
||||
public record DeleteFileCommand(
|
||||
Guid FileId,
|
||||
string UserId
|
||||
) : IRequest<DeleteFileResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of file deletion.
|
||||
/// VI: Kết quả xóa file.
|
||||
/// </summary>
|
||||
public record DeleteFileResult(
|
||||
bool Success,
|
||||
string? Error);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteFileCommand.
|
||||
/// VI: Handler cho DeleteFileCommand.
|
||||
/// </summary>
|
||||
public class DeleteFileCommandHandler : IRequestHandler<DeleteFileCommand, DeleteFileResult>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
private readonly IQuotaRepository _quotaRepository;
|
||||
private readonly IStorageProviderFactory _storageProviderFactory;
|
||||
private readonly ILogger<DeleteFileCommandHandler> _logger;
|
||||
|
||||
public DeleteFileCommandHandler(
|
||||
IFileRepository fileRepository,
|
||||
IQuotaRepository quotaRepository,
|
||||
IStorageProviderFactory storageProviderFactory,
|
||||
ILogger<DeleteFileCommandHandler> logger)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
_quotaRepository = quotaRepository;
|
||||
_storageProviderFactory = storageProviderFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DeleteFileResult> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to upload a file.
|
||||
/// VI: Command để upload file.
|
||||
/// </summary>
|
||||
public record UploadFileCommand(
|
||||
Stream FileStream,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSizeBytes,
|
||||
string UserId,
|
||||
string? TenantId = null,
|
||||
FileAccessLevel AccessLevel = FileAccessLevel.Private
|
||||
) : IRequest<UploadFileResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of file upload.
|
||||
/// VI: Kết quả upload file.
|
||||
/// </summary>
|
||||
public record UploadFileResult(
|
||||
bool Success,
|
||||
Guid? FileId,
|
||||
string? ObjectKey,
|
||||
string? Error);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UploadFileCommand.
|
||||
/// VI: Handler cho UploadFileCommand.
|
||||
/// </summary>
|
||||
public class UploadFileCommandHandler : IRequestHandler<UploadFileCommand, UploadFileResult>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
private readonly IQuotaRepository _quotaRepository;
|
||||
private readonly IStorageProviderFactory _storageProviderFactory;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<UploadFileCommandHandler> _logger;
|
||||
|
||||
public UploadFileCommandHandler(
|
||||
IFileRepository fileRepository,
|
||||
IQuotaRepository quotaRepository,
|
||||
IStorageProviderFactory storageProviderFactory,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<UploadFileCommandHandler> logger)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
_quotaRepository = quotaRepository;
|
||||
_storageProviderFactory = storageProviderFactory;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UploadFileResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for file information.
|
||||
/// VI: DTO cho thông tin file.
|
||||
/// </summary>
|
||||
public record FileDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSizeBytes,
|
||||
string Provider,
|
||||
string AccessLevel,
|
||||
DateTime UploadedAt,
|
||||
DateTime? LastAccessedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for user files query.
|
||||
/// VI: Kết quả query files của user.
|
||||
/// </summary>
|
||||
public record UserFilesResult(
|
||||
IReadOnlyList<FileDto> Files,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: DTO for user quota.
|
||||
/// VI: DTO cho quota user.
|
||||
/// </summary>
|
||||
public record QuotaDto(
|
||||
string UserId,
|
||||
long MaxStorageBytes,
|
||||
long UsedStorageBytes,
|
||||
long RemainingStorageBytes,
|
||||
int MaxFileCount,
|
||||
int CurrentFileCount,
|
||||
int RemainingFileCount,
|
||||
double UsagePercentage,
|
||||
string? QuotaTier);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result for download URL query.
|
||||
/// VI: Kết quả query download URL.
|
||||
/// </summary>
|
||||
public record DownloadUrlResult(
|
||||
bool Success,
|
||||
string? Url,
|
||||
int? ExpiresInSeconds,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mapper from domain entities to DTOs.
|
||||
/// VI: Mapper từ domain entities sang DTOs.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get file by ID.
|
||||
/// VI: Query để lấy file theo ID.
|
||||
/// </summary>
|
||||
public record GetFileQuery(Guid FileId, string UserId) : IRequest<FileDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get user files.
|
||||
/// VI: Query để lấy files của user.
|
||||
/// </summary>
|
||||
public record GetUserFilesQuery(
|
||||
string UserId,
|
||||
int Skip = 0,
|
||||
int Take = 20,
|
||||
string? SearchTerm = null
|
||||
) : IRequest<UserFilesResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get user quota.
|
||||
/// VI: Query để lấy quota user.
|
||||
/// </summary>
|
||||
public record GetUserQuotaQuery(string UserId) : IRequest<QuotaDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get pre-signed download URL.
|
||||
/// VI: Query để lấy pre-signed download URL.
|
||||
/// </summary>
|
||||
public record GetDownloadUrlQuery(
|
||||
Guid FileId,
|
||||
string UserId,
|
||||
int ExpirationSeconds = 3600
|
||||
) : IRequest<DownloadUrlResult>;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetFileQuery.
|
||||
/// VI: Handler cho GetFileQuery.
|
||||
/// </summary>
|
||||
public class GetFileQueryHandler : IRequestHandler<GetFileQuery, FileDto?>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
|
||||
public GetFileQueryHandler(IFileRepository fileRepository)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
}
|
||||
|
||||
public async Task<FileDto?> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetUserFilesQuery.
|
||||
/// VI: Handler cho GetUserFilesQuery.
|
||||
/// </summary>
|
||||
public class GetUserFilesQueryHandler : IRequestHandler<GetUserFilesQuery, UserFilesResult>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
|
||||
public GetUserFilesQueryHandler(IFileRepository fileRepository)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
}
|
||||
|
||||
public async Task<UserFilesResult> Handle(GetUserFilesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<StorageFile> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetUserQuotaQuery.
|
||||
/// VI: Handler cho GetUserQuotaQuery.
|
||||
/// </summary>
|
||||
public class GetUserQuotaQueryHandler : IRequestHandler<GetUserQuotaQuery, QuotaDto?>
|
||||
{
|
||||
private readonly IQuotaRepository _quotaRepository;
|
||||
|
||||
public GetUserQuotaQueryHandler(IQuotaRepository quotaRepository)
|
||||
{
|
||||
_quotaRepository = quotaRepository;
|
||||
}
|
||||
|
||||
public async Task<QuotaDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetDownloadUrlQuery.
|
||||
/// VI: Handler cho GetDownloadUrlQuery.
|
||||
/// </summary>
|
||||
public class GetDownloadUrlQueryHandler : IRequestHandler<GetDownloadUrlQuery, DownloadUrlResult>
|
||||
{
|
||||
private readonly IFileRepository _fileRepository;
|
||||
private readonly IStorageProviderFactory _storageProviderFactory;
|
||||
private readonly ILogger<GetDownloadUrlQueryHandler> _logger;
|
||||
|
||||
public GetDownloadUrlQueryHandler(
|
||||
IFileRepository fileRepository,
|
||||
IStorageProviderFactory storageProviderFactory,
|
||||
ILogger<GetDownloadUrlQueryHandler> logger)
|
||||
{
|
||||
_fileRepository = fileRepository;
|
||||
_storageProviderFactory = storageProviderFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DownloadUrlResult> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FluentValidation;
|
||||
using StorageService.API.Application.Commands;
|
||||
|
||||
namespace StorageService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using StorageService.API.Application.Commands;
|
||||
|
||||
namespace StorageService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for file operations.
|
||||
/// VI: Controller cho các thao tác file.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/files")]
|
||||
[SwaggerTag("File Management - Upload, download, and manage files")]
|
||||
public class FilesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<FilesController> _logger;
|
||||
|
||||
public FilesController(IMediator mediator, ILogger<FilesController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Upload a file.
|
||||
/// VI: Upload file.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<UploadFileResult>>> Upload(
|
||||
IFormFile file,
|
||||
[FromQuery] string? accessLevel = "private",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized(new ApiResponse<UploadFileResult> { Success = false, Error = "User ID not found" });
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new ApiResponse<UploadFileResult> { 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<UploadFileResult> { Success = false, Error = result.Error });
|
||||
|
||||
return Ok(new ApiResponse<UploadFileResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user files.
|
||||
/// VI: Lấy danh sách files của user.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<UserFilesResult>>> 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<UserFilesResult> { 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<UserFilesResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get file by ID.
|
||||
/// VI: Lấy thông tin file theo ID.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<FileDto>>> GetFile(
|
||||
Guid fileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized(new ApiResponse<FileDto> { 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<FileDto> { Success = false, Error = "File not found" });
|
||||
|
||||
return Ok(new ApiResponse<FileDto> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get pre-signed download URL.
|
||||
/// VI: Lấy URL download có chữ ký.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<DownloadUrlResult>>> GetDownloadUrl(
|
||||
Guid fileId,
|
||||
[FromQuery] int expirationSeconds = 3600,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized(new ApiResponse<DownloadUrlResult> { 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<DownloadUrlResult> { Success = false, Error = result.Error });
|
||||
|
||||
return Ok(new ApiResponse<DownloadUrlResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a file.
|
||||
/// VI: Xóa file.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<DeleteFileResult>>> Delete(
|
||||
Guid fileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = GetUserId();
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized(new ApiResponse<DeleteFileResult> { 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<DeleteFileResult> { Success = false, Error = result.Error });
|
||||
|
||||
return Ok(new ApiResponse<DeleteFileResult> { Success = true, Data = result });
|
||||
}
|
||||
|
||||
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Standard API response wrapper.
|
||||
/// VI: Wrapper response API chuẩn.
|
||||
/// </summary>
|
||||
public class ApiResponse<T>
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public T? Data { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for storage quota operations.
|
||||
/// VI: Controller cho các thao tác quota storage.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current user's storage quota.
|
||||
/// VI: Lấy quota storage của user hiện tại.
|
||||
/// </summary>
|
||||
[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<ActionResult<ApiResponse<QuotaDto>>> GetQuota(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized(new ApiResponse<QuotaDto> { 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<QuotaDto> { Success = false, Error = "Quota not found" });
|
||||
|
||||
return Ok(new ApiResponse<QuotaDto> { Success = true, Data = result });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
144
services/storage-service-net/src/StorageService.API/Program.cs
Normal file
144
services/storage-service-net/src/StorageService.API/Program.cs
Normal file
@@ -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<Program>();
|
||||
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
|
||||
});
|
||||
|
||||
// EN: Add FluentValidation / VI: Thêm FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
// 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 { }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>StorageService.API</AssemblyName>
|
||||
<RootNamespace>StorageService.API</RootNamespace>
|
||||
<Description>Web API layer with CQRS pattern for Storage Service</Description>
|
||||
<UserSecretsId>storageservice-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for CQRS / VI: MediatR cho CQRS -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
<!-- EN: API Versioning / VI: API Versioning -->
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
|
||||
<!-- EN: Health checks / VI: Health checks -->
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
|
||||
<!-- EN: Problem Details (RFC 7807) / VI: Problem Details (RFC 7807) -->
|
||||
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
|
||||
|
||||
<!-- EN: Serilog for structured logging / VI: Serilog cho structured logging -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StorageService.Domain\StorageService.Domain.csproj" />
|
||||
<ProjectReference Include="..\StorageService.Infrastructure\StorageService.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a file is uploaded.
|
||||
/// VI: Domain event được phát khi file được upload.
|
||||
/// </summary>
|
||||
public record FileUploadedDomainEvent(
|
||||
Guid FileId,
|
||||
string FileName,
|
||||
string UserId,
|
||||
long FileSizeBytes
|
||||
) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a file is deleted.
|
||||
/// VI: Domain event được phát khi file bị xóa.
|
||||
/// </summary>
|
||||
public record FileDeletedDomainEvent(
|
||||
Guid FileId,
|
||||
string UserId,
|
||||
long FileSizeBytes
|
||||
) : INotification;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when user quota is updated.
|
||||
/// VI: Domain event được phát khi quota user được cập nhật.
|
||||
/// </summary>
|
||||
public record UserQuotaUpdatedDomainEvent(
|
||||
string UserId,
|
||||
long UsedStorageBytes,
|
||||
int FileCount
|
||||
) : INotification;
|
||||
@@ -0,0 +1,67 @@
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for StorageFile aggregate.
|
||||
/// VI: Interface repository cho StorageFile aggregate.
|
||||
/// </summary>
|
||||
public interface IFileRepository : IRepository<StorageFile>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add a new file.
|
||||
/// VI: Thêm file mới.
|
||||
/// </summary>
|
||||
Task<StorageFile> AddAsync(StorageFile file, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing file.
|
||||
/// VI: Cập nhật file.
|
||||
/// </summary>
|
||||
void Update(StorageFile file);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get file by ID.
|
||||
/// VI: Lấy file theo ID.
|
||||
/// </summary>
|
||||
Task<StorageFile?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get file by object key.
|
||||
/// VI: Lấy file theo object key.
|
||||
/// </summary>
|
||||
Task<StorageFile?> GetByObjectKeyAsync(string objectKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get files by user ID.
|
||||
/// VI: Lấy files theo user ID.
|
||||
/// </summary>
|
||||
Task<IEnumerable<StorageFile>> GetByUserIdAsync(
|
||||
string userId,
|
||||
int skip = 0,
|
||||
int take = 20,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get total size of files by user ID.
|
||||
/// VI: Lấy tổng kích thước files theo user ID.
|
||||
/// </summary>
|
||||
Task<long> GetTotalSizeByUserIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get file count by user ID.
|
||||
/// VI: Lấy số lượng files theo user ID.
|
||||
/// </summary>
|
||||
Task<int> GetFileCountByUserIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Search files by name.
|
||||
/// VI: Tìm kiếm files theo tên.
|
||||
/// </summary>
|
||||
Task<IEnumerable<StorageFile>> SearchAsync(
|
||||
string userId,
|
||||
string? searchTerm,
|
||||
int skip = 0,
|
||||
int take = 20,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Storage provider enumeration.
|
||||
/// VI: Enum định nghĩa các provider lưu trữ.
|
||||
/// </summary>
|
||||
public enum StorageProvider
|
||||
{
|
||||
/// <summary>EN: MinIO S3-compatible storage / VI: MinIO lưu trữ tương thích S3</summary>
|
||||
MinIO = 1,
|
||||
|
||||
/// <summary>EN: Alibaba Cloud OSS / VI: Alibaba Cloud OSS</summary>
|
||||
AliyunOSS = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: File access level enumeration.
|
||||
/// VI: Enum định nghĩa mức độ truy cập file.
|
||||
/// </summary>
|
||||
public enum FileAccessLevel
|
||||
{
|
||||
/// <summary>EN: Private - Only owner can access / VI: Riêng tư - Chỉ owner có thể truy cập</summary>
|
||||
Private = 1,
|
||||
|
||||
/// <summary>EN: Public - Anyone can access / VI: Công khai - Bất kỳ ai có thể truy cập</summary>
|
||||
Public = 2,
|
||||
|
||||
/// <summary>EN: Shared - Specific users can access / VI: Chia sẻ - Người dùng cụ thể có thể truy cập</summary>
|
||||
Shared = 3
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a file stored in the storage system.
|
||||
/// VI: Đại diện cho file được lưu trữ trong hệ thống.
|
||||
/// </summary>
|
||||
public class StorageFile : Entity, IAggregateRoot
|
||||
{
|
||||
/// <summary>EN: Original file name / VI: Tên file gốc</summary>
|
||||
public string FileName { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Bucket/container name / VI: Tên bucket/container</summary>
|
||||
public string BucketName { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Unique key in storage / VI: Key duy nhất trong storage</summary>
|
||||
public string ObjectKey { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: MIME content type / VI: MIME content type</summary>
|
||||
public string ContentType { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: File size in bytes / VI: Kích thước file (bytes)</summary>
|
||||
public long FileSizeBytes { get; private set; }
|
||||
|
||||
/// <summary>EN: Owner user ID / VI: ID người dùng sở hữu</summary>
|
||||
public string UserId { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Tenant ID for multi-tenancy / VI: Tenant ID cho multi-tenancy</summary>
|
||||
public string? TenantId { get; private set; }
|
||||
|
||||
/// <summary>EN: Storage provider used / VI: Provider lưu trữ được sử dụng</summary>
|
||||
public StorageProvider Provider { get; private set; }
|
||||
|
||||
/// <summary>EN: Access level for the file / VI: Mức độ truy cập của file</summary>
|
||||
public FileAccessLevel AccessLevel { get; private set; }
|
||||
|
||||
/// <summary>EN: Upload timestamp / VI: Thời gian upload</summary>
|
||||
public DateTime UploadedAt { get; private set; }
|
||||
|
||||
/// <summary>EN: Last access timestamp / VI: Thời gian truy cập cuối</summary>
|
||||
public DateTime? LastAccessedAt { get; private set; }
|
||||
|
||||
/// <summary>EN: Expiration timestamp (for temporary files) / VI: Thời gian hết hạn (cho file tạm)</summary>
|
||||
public DateTime? ExpiresAt { get; private set; }
|
||||
|
||||
/// <summary>EN: File checksum (MD5/SHA256) / VI: Checksum file (MD5/SHA256)</summary>
|
||||
public string? Checksum { get; private set; }
|
||||
|
||||
/// <summary>EN: Soft delete flag / VI: Cờ xóa mềm</summary>
|
||||
public bool IsDeleted { get; private set; }
|
||||
|
||||
/// <summary>EN: Deleted timestamp / VI: Thời gian xóa</summary>
|
||||
public DateTime? DeletedAt { get; private set; }
|
||||
|
||||
// EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số
|
||||
protected StorageFile() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new storage file.
|
||||
/// VI: Tạo một storage file mới.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark file as accessed.
|
||||
/// VI: Đánh dấu file đã được truy cập.
|
||||
/// </summary>
|
||||
public void MarkAccessed()
|
||||
{
|
||||
LastAccessedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update access level.
|
||||
/// VI: Cập nhật mức độ truy cập.
|
||||
/// </summary>
|
||||
public void UpdateAccessLevel(FileAccessLevel newLevel)
|
||||
{
|
||||
if (IsDeleted)
|
||||
throw new InvalidOperationException("Cannot update deleted file");
|
||||
|
||||
AccessLevel = newLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete the file.
|
||||
/// VI: Xóa mềm file.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
if (IsDeleted)
|
||||
return;
|
||||
|
||||
IsDeleted = true;
|
||||
DeletedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new FileDeletedDomainEvent(Id, UserId, FileSizeBytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Set expiration time.
|
||||
/// VI: Đặt thời gian hết hạn.
|
||||
/// </summary>
|
||||
public void SetExpiration(DateTime expiresAt)
|
||||
{
|
||||
if (expiresAt <= DateTime.UtcNow)
|
||||
throw new ArgumentException("Expiration must be in the future", nameof(expiresAt));
|
||||
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.QuotaAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for UserStorageQuota aggregate.
|
||||
/// VI: Interface repository cho UserStorageQuota aggregate.
|
||||
/// </summary>
|
||||
public interface IQuotaRepository : IRepository<UserStorageQuota>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add a new quota.
|
||||
/// VI: Thêm quota mới.
|
||||
/// </summary>
|
||||
Task<UserStorageQuota> AddAsync(UserStorageQuota quota, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing quota.
|
||||
/// VI: Cập nhật quota.
|
||||
/// </summary>
|
||||
void Update(UserStorageQuota quota);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get quota by ID.
|
||||
/// VI: Lấy quota theo ID.
|
||||
/// </summary>
|
||||
Task<UserStorageQuota?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get quota by user ID.
|
||||
/// VI: Lấy quota theo user ID.
|
||||
/// </summary>
|
||||
Task<UserStorageQuota?> GetByUserIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get or create quota for user.
|
||||
/// VI: Lấy hoặc tạo quota cho user.
|
||||
/// </summary>
|
||||
Task<UserStorageQuota> GetOrCreateAsync(string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using StorageService.Domain.AggregatesModel.FileAggregate;
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.QuotaAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Represents a user's storage quota and usage.
|
||||
/// VI: Đại diện cho quota và usage lưu trữ của user.
|
||||
/// </summary>
|
||||
public class UserStorageQuota : Entity, IAggregateRoot
|
||||
{
|
||||
/// <summary>EN: User ID / VI: ID người dùng</summary>
|
||||
public string UserId { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Maximum storage in bytes / VI: Dung lượng tối đa (bytes)</summary>
|
||||
public long MaxStorageBytes { get; private set; }
|
||||
|
||||
/// <summary>EN: Currently used storage in bytes / VI: Dung lượng đã sử dụng (bytes)</summary>
|
||||
public long UsedStorageBytes { get; private set; }
|
||||
|
||||
/// <summary>EN: Maximum number of files / VI: Số file tối đa</summary>
|
||||
public int MaxFileCount { get; private set; }
|
||||
|
||||
/// <summary>EN: Current file count / VI: Số file hiện tại</summary>
|
||||
public int CurrentFileCount { get; private set; }
|
||||
|
||||
/// <summary>EN: Quota tier/plan name / VI: Tên tier/plan quota</summary>
|
||||
public string? QuotaTier { get; private set; }
|
||||
|
||||
/// <summary>EN: Last updated timestamp / VI: Thời gian cập nhật cuối</summary>
|
||||
public DateTime LastUpdatedAt { get; private set; }
|
||||
|
||||
/// <summary>EN: Created timestamp / VI: Thời gian tạo</summary>
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
// EN: EF Core requires parameterless constructor / VI: EF Core cần constructor không tham số
|
||||
protected UserStorageQuota() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new user storage quota.
|
||||
/// VI: Tạo quota lưu trữ mới cho user.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get remaining storage in bytes.
|
||||
/// VI: Lấy dung lượng còn lại (bytes).
|
||||
/// </summary>
|
||||
public long RemainingStorageBytes => Math.Max(0, MaxStorageBytes - UsedStorageBytes);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get remaining file count.
|
||||
/// VI: Lấy số file còn có thể upload.
|
||||
/// </summary>
|
||||
public int RemainingFileCount => Math.Max(0, MaxFileCount - CurrentFileCount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get usage percentage.
|
||||
/// VI: Lấy phần trăm sử dụng.
|
||||
/// </summary>
|
||||
public double UsagePercentage => MaxStorageBytes > 0
|
||||
? Math.Round((double)UsedStorageBytes / MaxStorageBytes * 100, 2)
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool CanUpload(long fileSizeBytes)
|
||||
{
|
||||
return UsedStorageBytes + fileSizeBytes <= MaxStorageBytes
|
||||
&& CurrentFileCount < MaxFileCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add storage usage after upload.
|
||||
/// VI: Thêm usage sau khi upload.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove storage usage after delete.
|
||||
/// VI: Giảm usage sau khi xóa.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update quota limits.
|
||||
/// VI: Cập nhật giới hạn quota.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Recalculate usage (for sync purposes).
|
||||
/// VI: Tính lại usage (để sync).
|
||||
/// </summary>
|
||||
public void RecalculateUsage(long totalBytes, int totalFiles)
|
||||
{
|
||||
UsedStorageBytes = Math.Max(0, totalBytes);
|
||||
CurrentFileCount = Math.Max(0, totalFiles);
|
||||
LastUpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using StorageService.Domain.Events;
|
||||
using StorageService.Domain.Exceptions;
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using StorageService.Domain.SeedWork;
|
||||
|
||||
namespace StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using StorageService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace StorageService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StorageService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exception for domain errors.
|
||||
/// VI: Exception cơ sở cho các lỗi domain.
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StorageService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Sample aggregate domain errors.
|
||||
/// VI: Exception cho các lỗi domain của Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleDomainException : DomainException
|
||||
{
|
||||
public SampleDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for all domain entities.
|
||||
/// VI: Lớp cơ sở cho tất cả các entity trong domain.
|
||||
/// </summary>
|
||||
public abstract class Entity
|
||||
{
|
||||
private int? _requestedHashCode;
|
||||
private Guid _id;
|
||||
private List<INotification> _domainEvents = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unique identifier for the entity.
|
||||
/// VI: Định danh duy nhất cho entity.
|
||||
/// </summary>
|
||||
public virtual Guid Id
|
||||
{
|
||||
get => _id;
|
||||
protected set => _id = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain events raised by this entity.
|
||||
/// VI: Các domain event được phát ra bởi entity này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a domain event to be dispatched.
|
||||
/// VI: Thêm một domain event để dispatch.
|
||||
/// </summary>
|
||||
public void AddDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Add(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a domain event.
|
||||
/// VI: Xóa một domain event.
|
||||
/// </summary>
|
||||
public void RemoveDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Remove(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Clear all domain events.
|
||||
/// VI: Xóa tất cả domain events.
|
||||
/// </summary>
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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ú.
|
||||
/// </remarks>
|
||||
public abstract class Enumeration : IComparable
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The name of the enumeration value.
|
||||
/// VI: Tên của giá trị enumeration.
|
||||
/// </summary>
|
||||
public string Name { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: The unique identifier of the enumeration value.
|
||||
/// VI: Định danh duy nhất của giá trị enumeration.
|
||||
/// </summary>
|
||||
public int Id { get; private set; }
|
||||
|
||||
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
|
||||
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.Select(f => f.GetValue(null))
|
||||
.Cast<T>();
|
||||
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
|
||||
{
|
||||
return Math.Abs(firstValue.Id - secondValue.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse an integer ID to the corresponding enumeration value.
|
||||
/// VI: Parse một ID integer thành giá trị enumeration tương ứng.
|
||||
/// </summary>
|
||||
public static T FromValue<T>(int value) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static T FromDisplayName<T>(string displayName) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
private static T Parse<T, TValue>(TValue value, string description, Func<T, bool> predicate) where T : Enumeration
|
||||
{
|
||||
var matchingItem = GetAll<T>().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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Marker interface for aggregate roots.
|
||||
/// VI: Interface đánh dấu cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IAggregateRoot
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic repository interface for aggregate roots.
|
||||
/// VI: Interface repository generic cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">EN: The aggregate root type / VI: Kiểu aggregate root</typeparam>
|
||||
public interface IRepository<T> where T : IAggregateRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The unit of work for this repository.
|
||||
/// VI: Unit of work cho repository này.
|
||||
/// </summary>
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of Work pattern interface.
|
||||
/// VI: Interface cho Unit of Work pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: Number of entities written / VI: Số entity đã ghi</returns>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save all changes and dispatch domain events.
|
||||
/// VI: Lưu tất cả thay đổi và dispatch domain events.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: True if successful / VI: True nếu thành công</returns>
|
||||
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace StorageService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for Value Objects following DDD patterns.
|
||||
/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public abstract class ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
protected abstract IEnumerable<object?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
protected ValueObject GetCopy()
|
||||
{
|
||||
return (ValueObject)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>StorageService.Domain</AssemblyName>
|
||||
<RootNamespace>StorageService.Domain</RootNamespace>
|
||||
<Description>Domain layer containing core business logic and entities for Storage Service</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
|
||||
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace StorageService.Infrastructure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Storage settings for MinIO and Aliyun OSS.
|
||||
/// VI: Cấu hình storage cho MinIO và Aliyun OSS.
|
||||
/// </summary>
|
||||
public class StorageSettings
|
||||
{
|
||||
public const string SectionName = "Storage";
|
||||
|
||||
/// <summary>EN: Active storage provider (minio or aliyun) / VI: Provider storage đang dùng (minio hoặc aliyun)</summary>
|
||||
public string Provider { get; set; } = "minio";
|
||||
|
||||
/// <summary>EN: Default bucket name / VI: Tên bucket mặc định</summary>
|
||||
public string DefaultBucket { get; set; } = "storage";
|
||||
|
||||
/// <summary>EN: Pre-signed URL expiration in seconds / VI: Thời gian hết hạn pre-signed URL (giây)</summary>
|
||||
public int PreSignedUrlExpirationSeconds { get; set; } = 3600;
|
||||
|
||||
/// <summary>EN: Maximum file size in bytes / VI: Kích thước file tối đa (bytes)</summary>
|
||||
public long MaxFileSizeBytes { get; set; } = 104857600; // 100MB
|
||||
|
||||
/// <summary>EN: MinIO configuration / VI: Cấu hình MinIO</summary>
|
||||
public MinioSettings MinIO { get; set; } = new();
|
||||
|
||||
/// <summary>EN: Aliyun OSS configuration / VI: Cấu hình Aliyun OSS</summary>
|
||||
public AliyunOssSettings AliyunOSS { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: MinIO-specific settings.
|
||||
/// VI: Cấu hình riêng cho MinIO.
|
||||
/// </summary>
|
||||
public class MinioSettings
|
||||
{
|
||||
/// <summary>EN: MinIO endpoint (host:port) / VI: Endpoint MinIO (host:port)</summary>
|
||||
public string Endpoint { get; set; } = "localhost:9000";
|
||||
|
||||
/// <summary>EN: Access key / VI: Access key</summary>
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Secret key / VI: Secret key</summary>
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Use SSL/HTTPS / VI: Sử dụng SSL/HTTPS</summary>
|
||||
public bool UseSSL { get; set; } = false;
|
||||
|
||||
/// <summary>EN: Region (optional) / VI: Region (tùy chọn)</summary>
|
||||
public string? Region { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Aliyun OSS-specific settings.
|
||||
/// VI: Cấu hình riêng cho Aliyun OSS.
|
||||
/// </summary>
|
||||
public class AliyunOssSettings
|
||||
{
|
||||
/// <summary>EN: OSS endpoint / VI: Endpoint OSS</summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Access key ID / VI: Access key ID</summary>
|
||||
public string AccessKeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Access key secret / VI: Access key secret</summary>
|
||||
public string AccessKeySecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>EN: Region / VI: Region</summary>
|
||||
public string Region { get; set; } = string.Empty;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user