feat: introduce admin API endpoints and handlers for managing campaigns, vouchers, and redemptions.

This commit is contained in:
Ho Ngoc Hai
2026-01-17 22:41:40 +07:00
parent 4f8abb4a64
commit 616a8973e4
80 changed files with 6061 additions and 0 deletions

View 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

View 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
~$*

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

View 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/MyService.API/MyService.API.csproj", "src/MyService.API/"]
COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"]
COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"]
COPY ["Directory.Build.props", "./"]
# EN: Restore dependencies
# VI: Khôi phục dependencies
RUN dotnet restore "src/MyService.API/MyService.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/MyService.API"
RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore
# Publish stage / Giai đoạn publish
FROM build AS publish
RUN dotnet publish "MyService.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", "MyService.API.dll"]

View File

@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/MyService.API/MyService.API.csproj" />
<Project Path="src/MyService.Domain/MyService.Domain.csproj" />
<Project Path="src/MyService.Infrastructure/MyService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj" />
<Project Path="tests/MyService.UnitTests/MyService.UnitTests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,72 @@
version: '3.8'
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
myservice-api:
build:
context: .
dockerfile: Dockerfile
container_name: myservice-api
ports:
- "5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- REDIS_URL=redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- myservice-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: myservice-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myservice_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- myservice-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: myservice-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- myservice-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
networks:
myservice-network:
driver: bridge

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

View 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

View 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:
-**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)

View 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

View File

@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.101",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using MediatR;
namespace MyService.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;
}
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MyService.Infrastructure;
namespace MyService.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 MyServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
MyServiceContext 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;
}
});
}
}

View File

@@ -0,0 +1,63 @@
using FluentValidation;
using MediatR;
namespace MyService.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();
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace MyService.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>;

View File

@@ -0,0 +1,70 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,21 @@
using MediatR;
namespace MyService.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);

View File

@@ -0,0 +1,46 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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);
}
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace MyService.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>;

View File

@@ -0,0 +1,54 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,16 @@
using MediatR;
namespace MyService.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>;

View File

@@ -0,0 +1,54 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace MyService.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
);

View File

@@ -0,0 +1,39 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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
);
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace MyService.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>>;

View File

@@ -0,0 +1,34 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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
));
}
}

View File

@@ -0,0 +1,25 @@
using FluentValidation;
using MyService.API.Application.Commands;
namespace MyService.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);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using MyService.API.Application.Commands;
namespace MyService.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);
}
}

View File

@@ -0,0 +1,200 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MyService.API.Application.Commands;
using MyService.API.Application.Queries;
namespace MyService.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);

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>MyService.API</AssemblyName>
<RootNamespace>MyService.API</RootNamespace>
<Description>Web API layer with CQRS pattern</Description>
<UserSecretsId>myservice-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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyService.Domain\MyService.Domain.csproj" />
<ProjectReference Include="..\MyService.Infrastructure\MyService.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,144 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using MyService.API.Application.Behaviors;
using MyService.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 MyService API / Khởi động MyService 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 = "MyService API",
Version = "v1",
Description = "MyService microservice API / API microservice MyService"
});
});
// 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", "MyService 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 { }

View File

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

View File

@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"System": "Information"
}
}
}
}

View File

@@ -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": "*"
}

View File

@@ -0,0 +1,61 @@
using MyService.Domain.SeedWork;
namespace MyService.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);
}

View File

@@ -0,0 +1,158 @@
using MyService.Domain.Events;
using MyService.Domain.Exceptions;
using MyService.Domain.SeedWork;
namespace MyService.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));
}
}

View File

@@ -0,0 +1,77 @@
using MyService.Domain.SeedWork;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,39 @@
using MediatR;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.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;
}
}

View File

@@ -0,0 +1,21 @@
namespace MyService.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)
{
}
}

View File

@@ -0,0 +1,21 @@
namespace MyService.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)
{
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyService.Domain</AssemblyName>
<RootNamespace>MyService.Domain</RootNamespace>
<Description>Domain layer containing core business logic and entities</Description>
</PropertyGroup>
<ItemGroup>
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using MediatR;
namespace MyService.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);
}
}

View File

@@ -0,0 +1,95 @@
using System.Reflection;
namespace MyService.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);
}

View File

@@ -0,0 +1,15 @@
namespace MyService.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
{
}

View File

@@ -0,0 +1,15 @@
namespace MyService.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; }
}

View File

@@ -0,0 +1,30 @@
namespace MyService.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);
}

View File

@@ -0,0 +1,53 @@
namespace MyService.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();
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MyService.Domain.AggregatesModel.SampleAggregate;
using MyService.Infrastructure.Idempotency;
using MyService.Infrastructure.Repositories;
namespace MyService.Infrastructure;
/// <summary>
/// EN: Dependency injection extensions for Infrastructure layer.
/// VI: Extensions dependency injection cho lớp Infrastructure.
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// EN: Add infrastructure services to the DI container.
/// VI: Thêm các services infrastructure vào DI container.
/// </summary>
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
services.AddDbContext<MyServiceContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
?? throw new InvalidOperationException("Connection string not configured");
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(MyServiceContext).Assembly.FullName);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorCodesToAdd: null);
});
// EN: Enable sensitive data logging in development only
// VI: Chỉ bật sensitive data logging trong development
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// EN: Register repositories / VI: Đăng ký repositories
services.AddScoped<ISampleRepository, SampleRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();
return services;
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Sample entity.
/// VI: Cấu hình EF Core cho entity Sample.
/// </summary>
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
{
public void Configure(EntityTypeBuilder<Sample> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("samples");
// EN: Primary key / VI: Khóa chính
builder.HasKey(s => s.Id);
// EN: Ignore domain events (not persisted)
// VI: Bỏ qua domain events (không lưu)
builder.Ignore(s => s.DomainEvents);
// EN: Properties / VI: Các thuộc tính
builder.Property(s => s.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string?>("_description")
.HasColumnName("description")
.HasMaxLength(1000);
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: Status relationship / VI: Quan hệ với Status
builder.Property(s => s.StatusId)
.HasColumnName("status_id")
.IsRequired();
builder.HasOne(s => s.Status)
.WithMany()
.HasForeignKey(s => s.StatusId)
.OnDelete(DeleteBehavior.Restrict);
// EN: Indexes / VI: Các index
builder.HasIndex("_name");
builder.HasIndex(s => s.StatusId);
builder.HasIndex("_createdAt");
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MyService.Domain.AggregatesModel.SampleAggregate;
namespace MyService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for SampleStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
/// </summary>
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
{
public void Configure(EntityTypeBuilder<SampleStatus> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("sample_statuses");
// EN: Primary key / VI: Khóa chính
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
builder.HasData(
SampleStatus.Draft,
SampleStatus.Active,
SampleStatus.Completed,
SampleStatus.Cancelled
);
}
}

View File

@@ -0,0 +1,26 @@
namespace MyService.Infrastructure.Idempotency;
/// <summary>
/// EN: Entity for tracking client requests to ensure idempotency.
/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency.
/// </summary>
public class ClientRequest
{
/// <summary>
/// EN: Unique request identifier.
/// VI: Định danh request duy nhất.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// EN: Name of the command/request type.
/// VI: Tên của loại command/request.
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// EN: Timestamp when the request was received.
/// VI: Thời điểm request được nhận.
/// </summary>
public DateTime Time { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace MyService.Infrastructure.Idempotency;
/// <summary>
/// EN: Interface for managing client request idempotency.
/// VI: Interface để quản lý idempotency của client requests.
/// </summary>
public interface IRequestManager
{
/// <summary>
/// EN: Check if a request with the given ID exists.
/// VI: Kiểm tra xem request với ID cho trước có tồn tại không.
/// </summary>
/// <param name="id">EN: Request ID / VI: ID của request</param>
/// <returns>EN: True if exists / VI: True nếu tồn tại</returns>
Task<bool> ExistAsync(Guid id);
/// <summary>
/// EN: Create a new request record for tracking.
/// VI: Tạo bản ghi request mới để theo dõi.
/// </summary>
/// <typeparam name="T">EN: Command type / VI: Loại command</typeparam>
/// <param name="id">EN: Request ID / VI: ID của request</param>
Task CreateRequestForCommandAsync<T>(Guid id);
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
namespace MyService.Infrastructure.Idempotency;
/// <summary>
/// EN: Implementation of request manager for idempotency.
/// VI: Triển khai request manager cho idempotency.
/// </summary>
public class RequestManager : IRequestManager
{
private readonly MyServiceContext _context;
public RequestManager(MyServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc/>
public async Task<bool> ExistAsync(Guid id)
{
var request = await _context
.FindAsync<ClientRequest>(id);
return request != null;
}
/// <inheritdoc/>
public async Task CreateRequestForCommandAsync<T>(Guid id)
{
var exists = await ExistAsync(id);
var request = exists
? throw new InvalidOperationException($"Request with {id} already exists")
: new ClientRequest
{
Id = id,
Name = typeof(T).Name,
Time = DateTime.UtcNow
};
_context.Add(request);
await _context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyService.Infrastructure</AssemblyName>
<RootNamespace>MyService.Infrastructure</RootNamespace>
<Description>Infrastructure layer for data access and external services</Description>
</PropertyGroup>
<ItemGroup>
<!-- EN: Entity Framework Core with PostgreSQL / VI: Entity Framework Core với PostgreSQL -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- EN: MediatR for dispatching domain events / VI: MediatR để dispatch domain events -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- EN: Dapper for read-optimized queries / VI: Dapper cho queries tối ưu đọc -->
<PackageReference Include="Dapper" Version="2.1.35" />
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="Polly" Version="8.5.0" />
<!-- EN: Redis cache / VI: Redis cache -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyService.Domain\MyService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,160 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using MyService.Domain.AggregatesModel.SampleAggregate;
using MyService.Domain.SeedWork;
using MyService.Infrastructure.EntityConfigurations;
namespace MyService.Infrastructure;
/// <summary>
/// EN: EF Core DbContext for MyService.
/// VI: EF Core DbContext cho MyService.
/// </summary>
public class MyServiceContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// </summary>
public DbSet<Sample> Samples => Set<Sample>();
/// <summary>
/// EN: Read-only access to current transaction.
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
/// </summary>
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
/// <summary>
/// EN: Check if there is an active transaction.
/// VI: Kiểm tra xem có transaction đang hoạt động không.
/// </summary>
public bool HasActiveTransaction => _currentTransaction != null;
public MyServiceContext(DbContextOptions<MyServiceContext> options) : base(options)
{
_mediator = null!;
}
public MyServiceContext(DbContextOptions<MyServiceContext> options, IMediator mediator) : base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
System.Diagnostics.Debug.WriteLine("MyServiceContext::ctor - " + GetHashCode());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
}
/// <summary>
/// EN: Save entities and dispatch domain events.
/// VI: Lưu entities và dispatch domain events.
/// </summary>
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
{
// EN: Dispatch domain events before saving (side effects)
// VI: Dispatch domain events trước khi lưu (side effects)
await DispatchDomainEventsAsync();
// EN: Save changes to database
// VI: Lưu thay đổi vào database
await base.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// EN: Begin a new transaction if none is active.
/// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động.
/// </summary>
public async Task<IDbContextTransaction?> BeginTransactionAsync()
{
if (_currentTransaction != null) return null;
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
return _currentTransaction;
}
/// <summary>
/// EN: Commit the current transaction.
/// VI: Commit transaction hiện tại.
/// </summary>
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction != _currentTransaction)
throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
try
{
await SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
RollbackTransaction();
throw;
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
/// <summary>
/// EN: Rollback the current transaction.
/// VI: Rollback transaction hiện tại.
/// </summary>
public void RollbackTransaction()
{
try
{
_currentTransaction?.Rollback();
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
/// <summary>
/// EN: Dispatch all domain events from tracked entities.
/// VI: Dispatch tất cả domain events từ các entities đang được track.
/// </summary>
private async Task DispatchDomainEventsAsync()
{
var domainEntities = ChangeTracker
.Entries<Entity>()
.Where(x => x.Entity.DomainEvents.Any())
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.Entity.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent);
}
}
}

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using MyService.Domain.AggregatesModel.SampleAggregate;
using MyService.Domain.SeedWork;
namespace MyService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Sample aggregate.
/// VI: Triển khai repository cho Sample aggregate.
/// </summary>
public class SampleRepository : ISampleRepository
{
private readonly MyServiceContext _context;
/// <summary>
/// EN: Unit of work for transaction management.
/// VI: Unit of work cho quản lý transaction.
/// </summary>
public IUnitOfWork UnitOfWork => _context;
public SampleRepository(MyServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc/>
public async Task<Sample?> GetAsync(Guid sampleId)
{
var sample = await _context.Samples
.Include(s => s.Status)
.FirstOrDefaultAsync(s => s.Id == sampleId);
return sample;
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetAllAsync()
{
return await _context.Samples
.Include(s => s.Status)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
/// <inheritdoc/>
public Sample Add(Sample sample)
{
return _context.Samples.Add(sample).Entity;
}
/// <inheritdoc/>
public void Update(Sample sample)
{
_context.Entry(sample).State = EntityState.Modified;
}
/// <inheritdoc/>
public void Delete(Sample sample)
{
_context.Samples.Remove(sample);
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
{
return await _context.Samples
.Include(s => s.Status)
.Where(s => s.StatusId == statusId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
}

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace MyService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Samples API endpoints.
/// VI: Functional tests cho các endpoints API Samples.
/// </summary>
public class SamplesControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public SamplesControllerTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
[Fact]
public async Task GetSamples_ShouldReturnOkWithEmptyList()
{
// Act
var response = await _client.GetAsync("/api/v1/samples");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<ApiResponse<List<object>>>();
content?.Success.Should().BeTrue();
}
[Fact]
public async Task CreateSample_WithValidData_ShouldReturnCreated()
{
// Arrange
var request = new { Name = "Test Sample", Description = "Test Description" };
// Act
var response = await _client.PostAsJsonAsync("/api/v1/samples", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var content = await response.Content.ReadFromJsonAsync<ApiResponse<CreateSampleResult>>();
content?.Success.Should().BeTrue();
content?.Data?.Id.Should().NotBeEmpty();
}
[Fact]
public async Task GetSample_WithInvalidId_ShouldReturnNotFound()
{
// Arrange
var invalidId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/api/v1/samples/{invalidId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task HealthCheck_ShouldReturnHealthy()
{
// Act
var response = await _client.GetAsync("/health/live");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
// EN: Helper DTOs for deserialization
// VI: Helper DTOs để deserialize
private record ApiResponse<T>(bool Success, T? Data);
private record CreateSampleResult(Guid Id);
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MyService.Infrastructure;
namespace MyService.FunctionalTests;
/// <summary>
/// EN: Custom WebApplicationFactory for functional tests.
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// EN: Remove the existing DbContext registration
// VI: Xóa đăng ký DbContext hiện tại
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<MyServiceContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(MyServiceContext));
if (dbContextDescriptor != null)
{
services.Remove(dbContextDescriptor);
}
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<MyServiceContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
});
// EN: Ensure database is created with seed data
// VI: Đảm bảo database được tạo với seed data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyServiceContext>();
db.Database.EnsureCreated();
});
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyService.FunctionalTests</AssemblyName>
<RootNamespace>MyService.FunctionalTests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- EN: Test framework / VI: Test framework -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Integration testing / VI: Integration testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<!-- EN: Test containers for database / VI: Test containers cho database -->
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<!-- EN: Coverage / VI: Coverage -->
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyService.API\MyService.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using MyService.API.Application.Commands;
using MyService.Domain.AggregatesModel.SampleAggregate;
using MyService.Domain.SeedWork;
using Xunit;
namespace MyService.UnitTests.Application;
/// <summary>
/// EN: Unit tests for CreateSampleCommandHandler.
/// VI: Unit tests cho CreateSampleCommandHandler.
/// </summary>
public class CreateSampleCommandHandlerTests
{
private readonly Mock<ISampleRepository> _mockRepository;
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
private readonly CreateSampleCommandHandler _handler;
public CreateSampleCommandHandlerTests()
{
_mockRepository = new Mock<ISampleRepository>();
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
var mockUnitOfWork = new Mock<IUnitOfWork>();
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", "Test Description");
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
.Returns((Sample s) => s);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().NotBeEmpty();
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", null);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,151 @@
using FluentAssertions;
using MyService.Domain.AggregatesModel.SampleAggregate;
using MyService.Domain.Exceptions;
using Xunit;
namespace MyService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Sample aggregate.
/// VI: Unit tests cho Sample aggregate.
/// </summary>
public class SampleAggregateTests
{
[Fact]
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
{
// Arrange
var name = "Test Sample";
var description = "Test Description";
// Act
var sample = new Sample(name, description);
// Assert
sample.Name.Should().Be(name);
sample.Description.Should().Be(description);
sample.Status.Should().Be(SampleStatus.Draft);
sample.Id.Should().NotBeEmpty();
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
}
[Fact]
public void CreateSample_WithEmptyName_ShouldThrowException()
{
// Arrange
var name = "";
// Act
var act = () => new Sample(name);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Sample name cannot be empty");
}
[Fact]
public void Activate_WhenDraft_ShouldChangeToActive()
{
// Arrange
var sample = new Sample("Test Sample");
sample.ClearDomainEvents();
// Act
sample.Activate();
// Assert
sample.Status.Should().Be(SampleStatus.Active);
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
}
[Fact]
public void Activate_WhenNotDraft_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
// Act
var act = () => sample.Activate();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Only draft samples can be activated");
}
[Fact]
public void Complete_WhenActive_ShouldChangeToCompleted()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.ClearDomainEvents();
// Act
sample.Complete();
// Assert
sample.Status.Should().Be(SampleStatus.Completed);
}
[Fact]
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
{
// Arrange
var sample = new Sample("Test Sample");
// Act
sample.Cancel();
// Assert
sample.Status.Should().Be(SampleStatus.Cancelled);
}
[Fact]
public void Cancel_WhenCompleted_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.Complete();
// Act
var act = () => sample.Cancel();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot cancel a completed sample");
}
[Fact]
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
{
// Arrange
var sample = new Sample("Original Name", "Original Description");
var newName = "Updated Name";
var newDescription = "Updated Description";
// Act
sample.Update(newName, newDescription);
// Assert
sample.Name.Should().Be(newName);
sample.Description.Should().Be(newDescription);
sample.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Update_WhenCancelled_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Cancel();
// Act
var act = () => sample.Update("New Name", null);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot update a cancelled sample");
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyService.UnitTests</AssemblyName>
<RootNamespace>MyService.UnitTests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- EN: Test framework / VI: Test framework -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Assertions and mocking / VI: Assertions và mocking -->
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<!-- EN: Coverage / VI: Coverage -->
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyService.Domain\MyService.Domain.csproj" />
<ProjectReference Include="..\..\src\MyService.API\MyService.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,159 @@
using MediatR;
using Microsoft.Extensions.Logging;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.Exceptions;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpdateCampaignCommand.
/// VI: Handler cho UpdateCampaignCommand.
/// </summary>
public class UpdateCampaignCommandHandler : IRequestHandler<UpdateCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<UpdateCampaignCommandHandler> _logger;
public UpdateCampaignCommandHandler(
ICampaignRepository campaignRepository,
ILogger<UpdateCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> Handle(UpdateCampaignCommand request, CancellationToken cancellationToken)
{
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
campaign.Update(request.Name, request.Description, request.StartDate, request.EndDate, request.MaxPerUser);
_campaignRepository.Update(campaign);
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Campaign {CampaignId} updated", request.CampaignId);
return true;
}
}
/// <summary>
/// EN: Handler for CompleteCampaignCommand.
/// VI: Handler cho CompleteCampaignCommand.
/// </summary>
public class CompleteCampaignCommandHandler : IRequestHandler<CompleteCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<CompleteCampaignCommandHandler> _logger;
public CompleteCampaignCommandHandler(
ICampaignRepository campaignRepository,
ILogger<CompleteCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> Handle(CompleteCampaignCommand request, CancellationToken cancellationToken)
{
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
campaign.Complete();
_campaignRepository.Update(campaign);
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Campaign {CampaignId} force completed", request.CampaignId);
return true;
}
}
/// <summary>
/// EN: Handler for DeleteCampaignCommand (soft delete).
/// VI: Handler cho DeleteCampaignCommand (xóa mềm).
/// </summary>
public class DeleteCampaignCommandHandler : IRequestHandler<DeleteCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<DeleteCampaignCommandHandler> _logger;
public DeleteCampaignCommandHandler(
ICampaignRepository campaignRepository,
ILogger<DeleteCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> Handle(DeleteCampaignCommand request, CancellationToken cancellationToken)
{
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
campaign.Cancel(); // Soft delete = Cancel
_campaignRepository.Update(campaign);
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Campaign {CampaignId} soft deleted (cancelled)", request.CampaignId);
return true;
}
}
/// <summary>
/// EN: Handler for RevokeVoucherCommand.
/// VI: Handler cho RevokeVoucherCommand.
/// </summary>
public class RevokeVoucherCommandHandler : IRequestHandler<RevokeVoucherCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<RevokeVoucherCommandHandler> _logger;
public RevokeVoucherCommandHandler(
ICampaignRepository campaignRepository,
ILogger<RevokeVoucherCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> Handle(RevokeVoucherCommand request, CancellationToken cancellationToken)
{
var voucher = await _campaignRepository.GetVoucherByIdAsync(request.VoucherId)
?? throw new PromotionDomainException($"Voucher {request.VoucherId} not found");
voucher.Expire(); // Revoke = Mark as expired
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Voucher {VoucherId} revoked. Reason: {Reason}", request.VoucherId, request.Reason);
return true;
}
}
/// <summary>
/// EN: Handler for ExtendVoucherExpiryCommand.
/// VI: Handler cho ExtendVoucherExpiryCommand.
/// </summary>
public class ExtendVoucherExpiryCommandHandler : IRequestHandler<ExtendVoucherExpiryCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<ExtendVoucherExpiryCommandHandler> _logger;
public ExtendVoucherExpiryCommandHandler(
ICampaignRepository campaignRepository,
ILogger<ExtendVoucherExpiryCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> Handle(ExtendVoucherExpiryCommand request, CancellationToken cancellationToken)
{
var voucher = await _campaignRepository.GetVoucherByIdAsync(request.VoucherId)
?? throw new PromotionDomainException($"Voucher {request.VoucherId} not found");
voucher.ExtendExpiry(request.AdditionalDays);
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Voucher {VoucherId} expiry extended by {Days} days", request.VoucherId, request.AdditionalDays);
return true;
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to update a campaign.
/// VI: Command để cập nhật chiến dịch.
/// </summary>
public record UpdateCampaignCommand(
Guid CampaignId,
string? Name = null,
string? Description = null,
DateTime? StartDate = null,
DateTime? EndDate = null,
int? MaxPerUser = null) : IRequest<bool>;
/// <summary>
/// EN: Command to force complete a campaign.
/// VI: Command để bắt buộc hoàn thành chiến dịch.
/// </summary>
public record CompleteCampaignCommand(Guid CampaignId) : IRequest<bool>;
/// <summary>
/// EN: Command to soft delete a campaign.
/// VI: Command để xóa mềm chiến dịch.
/// </summary>
public record DeleteCampaignCommand(Guid CampaignId) : IRequest<bool>;
/// <summary>
/// EN: Command to revoke a voucher.
/// VI: Command để thu hồi voucher.
/// </summary>
public record RevokeVoucherCommand(
Guid VoucherId,
string Reason) : IRequest<bool>;
/// <summary>
/// EN: Command to extend voucher expiry.
/// VI: Command để gia hạn voucher.
/// </summary>
public record ExtendVoucherExpiryCommand(
Guid VoucherId,
int AdditionalDays) : IRequest<bool>;

View File

@@ -0,0 +1,126 @@
using PromotionService.API.Application.DTOs;
namespace PromotionService.API.Application.DTOs;
/// <summary>
/// EN: Paginated response wrapper.
/// VI: Wrapper cho response phân trang.
/// </summary>
public record PaginatedResponse<T>(
IEnumerable<T> Items,
int TotalCount,
int PageNumber,
int PageSize,
int TotalPages);
/// <summary>
/// EN: Campaign statistics for admin view.
/// VI: Thống kê chiến dịch cho admin.
/// </summary>
public record AdminCampaignStatisticsDto(
Guid CampaignId,
string CampaignName,
string Status,
int TotalVouchers,
int IssuedVouchers,
int AvailableVouchers,
int ClaimedVouchers,
int RedeemedVouchers,
int ExpiredVouchers,
decimal TotalFaceValue,
decimal TotalRedeemedValue,
decimal TotalRefundedValue,
decimal UtilizationRate,
int TotalRedemptions,
DateTime? FirstClaimedAt,
DateTime? LastRedeemedAt);
/// <summary>
/// EN: Admin campaign list item with stats.
/// VI: Item danh sách chiến dịch admin với thống kê.
/// </summary>
public record AdminCampaignListDto(
Guid Id,
Guid MerchantId,
string Name,
string BackingAssetType,
string BackingAssetCode,
decimal FaceValue,
string AcquisitionType,
string Status,
int TotalVouchers,
int IssuedVouchers,
DateTime StartDate,
DateTime EndDate,
DateTime CreatedAt);
/// <summary>
/// EN: Admin voucher list item with details.
/// VI: Item danh sách voucher admin với chi tiết.
/// </summary>
public record AdminVoucherListDto(
Guid Id,
Guid CampaignId,
string CampaignName,
string Code,
Guid? OwnerId,
string? OwnerEmail,
decimal FaceValue,
decimal RemainingValue,
string Status,
DateTime? ClaimedAt,
DateTime? ExpiresAt,
DateTime? RedeemedAt,
DateTime CreatedAt);
/// <summary>
/// EN: Admin redemption list item.
/// VI: Item danh sách redemption admin.
/// </summary>
public record AdminRedemptionListDto(
Guid Id,
Guid VoucherId,
string VoucherCode,
Guid CampaignId,
string CampaignName,
Guid UserId,
Guid? OrderId,
decimal AmountUsed,
decimal AmountRefunded,
DateTime RedeemedAt);
/// <summary>
/// EN: Redemption statistics for admin.
/// VI: Thống kê redemption cho admin.
/// </summary>
public record AdminRedemptionStatisticsDto(
int TotalRedemptions,
decimal TotalAmountUsed,
decimal TotalAmountRefunded,
decimal AverageRedemptionAmount,
int RedemptionsToday,
int RedemptionsThisWeek,
int RedemptionsThisMonth);
/// <summary>
/// EN: Campaign filter for admin queries.
/// VI: Filter chiến dịch cho admin queries.
/// </summary>
public record AdminCampaignFilter(
Guid? MerchantId = null,
string? Status = null,
DateTime? StartDateFrom = null,
DateTime? StartDateTo = null,
string? SearchTerm = null);
/// <summary>
/// EN: Voucher filter for admin queries.
/// VI: Filter voucher cho admin queries.
/// </summary>
public record AdminVoucherFilter(
Guid? CampaignId = null,
Guid? UserId = null,
string? Status = null,
string? CodeSearch = null,
DateTime? ExpiresAfter = null,
DateTime? ExpiresBefore = null);

View File

@@ -0,0 +1,68 @@
using MediatR;
using PromotionService.API.Application.DTOs;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Query to get all campaigns with pagination and filters.
/// VI: Query để lấy tất cả chiến dịch với phân trang và bộ lọc.
/// </summary>
public record GetAllCampaignsQuery(
int PageNumber = 1,
int PageSize = 20,
Guid? MerchantId = null,
string? Status = null,
string? SearchTerm = null) : IRequest<PaginatedResponse<AdminCampaignListDto>>;
/// <summary>
/// EN: Query to get campaign statistics for admin.
/// VI: Query để lấy thống kê chiến dịch cho admin.
/// </summary>
public record GetAdminCampaignStatisticsQuery(Guid CampaignId) : IRequest<AdminCampaignStatisticsDto?>;
/// <summary>
/// EN: Query to get vouchers for a campaign.
/// VI: Query để lấy vouchers của chiến dịch.
/// </summary>
public record GetCampaignVouchersQuery(
Guid CampaignId,
int PageNumber = 1,
int PageSize = 20,
string? Status = null) : IRequest<PaginatedResponse<AdminVoucherListDto>>;
/// <summary>
/// EN: Query to get all vouchers with filters.
/// VI: Query để lấy tất cả vouchers với bộ lọc.
/// </summary>
public record GetAllVouchersQuery(
int PageNumber = 1,
int PageSize = 20,
Guid? CampaignId = null,
Guid? UserId = null,
string? Status = null,
string? CodeSearch = null) : IRequest<PaginatedResponse<AdminVoucherListDto>>;
/// <summary>
/// EN: Query to search vouchers by code.
/// VI: Query để tìm kiếm vouchers theo mã.
/// </summary>
public record SearchVouchersQuery(string SearchTerm) : IRequest<IEnumerable<AdminVoucherListDto>>;
/// <summary>
/// EN: Query to get all redemptions with filters.
/// VI: Query để lấy tất cả redemptions với bộ lọc.
/// </summary>
public record GetAllRedemptionsQuery(
int PageNumber = 1,
int PageSize = 20,
Guid? CampaignId = null,
Guid? VoucherId = null,
Guid? UserId = null,
DateTime? DateFrom = null,
DateTime? DateTo = null) : IRequest<PaginatedResponse<AdminRedemptionListDto>>;
/// <summary>
/// EN: Query to get redemption statistics.
/// VI: Query để lấy thống kê redemption.
/// </summary>
public record GetRedemptionStatisticsQuery(Guid? CampaignId = null) : IRequest<AdminRedemptionStatisticsDto>;

View File

@@ -0,0 +1,309 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using PromotionService.API.Application.DTOs;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
using PromotionService.Infrastructure;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetAllCampaignsQuery.
/// VI: Handler cho GetAllCampaignsQuery.
/// </summary>
public class GetAllCampaignsQueryHandler : IRequestHandler<GetAllCampaignsQuery, PaginatedResponse<AdminCampaignListDto>>
{
private readonly PromotionServiceContext _context;
public GetAllCampaignsQueryHandler(PromotionServiceContext context)
{
_context = context;
}
public async Task<PaginatedResponse<AdminCampaignListDto>> Handle(GetAllCampaignsQuery request, CancellationToken cancellationToken)
{
var query = _context.Campaigns.AsQueryable();
if (request.MerchantId.HasValue)
query = query.Where(c => c.MerchantId == request.MerchantId.Value);
if (!string.IsNullOrEmpty(request.Status))
{
var statusId = request.Status.ToLower() switch
{
"draft" => 1,
"active" => 2,
"paused" => 3,
"completed" => 4,
"cancelled" => 5,
_ => 0
};
if (statusId > 0)
query = query.Where(c => c.StatusId == statusId);
}
if (!string.IsNullOrEmpty(request.SearchTerm))
query = query.Where(c => c.Name.Contains(request.SearchTerm));
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
var items = await query
.OrderByDescending(c => c.CreatedAt)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(c => new AdminCampaignListDto(
c.Id,
c.MerchantId,
c.Name,
c.BackingAssetTypeId == 1 ? "Currency" : "Point",
c.BackingAssetCode,
c.FaceValue,
c.AcquisitionTypeId == 1 ? "Free" : c.AcquisitionTypeId == 2 ? "ExchangePoints" : "Purchase",
c.StatusId == 1 ? "Draft" : c.StatusId == 2 ? "Active" : c.StatusId == 3 ? "Paused" : c.StatusId == 4 ? "Completed" : "Cancelled",
c.TotalVouchers,
c.IssuedVouchers,
c.StartDate,
c.EndDate,
c.CreatedAt))
.ToListAsync(cancellationToken);
return new PaginatedResponse<AdminCampaignListDto>(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}
/// <summary>
/// EN: Handler for GetAdminCampaignStatisticsQuery.
/// VI: Handler cho GetAdminCampaignStatisticsQuery.
/// </summary>
public class GetAdminCampaignStatisticsQueryHandler : IRequestHandler<GetAdminCampaignStatisticsQuery, AdminCampaignStatisticsDto?>
{
private readonly PromotionServiceContext _context;
public GetAdminCampaignStatisticsQueryHandler(PromotionServiceContext context)
{
_context = context;
}
public async Task<AdminCampaignStatisticsDto?> Handle(GetAdminCampaignStatisticsQuery request, CancellationToken cancellationToken)
{
var campaign = await _context.Campaigns
.Include(c => c.Vouchers)
.FirstOrDefaultAsync(c => c.Id == request.CampaignId, cancellationToken);
if (campaign == null) return null;
var redemptions = await _context.Redemptions
.Where(r => r.CampaignId == request.CampaignId)
.ToListAsync(cancellationToken);
var vouchers = campaign.Vouchers.ToList();
var claimed = vouchers.Count(v => v.OwnerId.HasValue);
var redeemed = vouchers.Count(v => v.StatusId == VoucherStatus.FullyRedeemed.Id || v.StatusId == VoucherStatus.PartiallyRedeemed.Id);
var expired = vouchers.Count(v => v.StatusId == VoucherStatus.Expired.Id);
var totalRedeemed = redemptions.Sum(r => r.AmountUsed);
var totalRefunded = redemptions.Sum(r => r.AmountRefunded);
return new AdminCampaignStatisticsDto(
campaign.Id,
campaign.Name,
campaign.StatusId == 1 ? "Draft" : campaign.StatusId == 2 ? "Active" : campaign.StatusId == 3 ? "Paused" : campaign.StatusId == 4 ? "Completed" : "Cancelled",
campaign.TotalVouchers,
campaign.IssuedVouchers,
campaign.AvailableVoucherCount,
claimed,
redeemed,
expired,
campaign.FaceValue * campaign.TotalVouchers,
totalRedeemed,
totalRefunded,
campaign.TotalVouchers > 0 ? (decimal)redeemed / campaign.TotalVouchers * 100 : 0,
redemptions.Count,
vouchers.Where(v => v.ClaimedAt.HasValue).MinBy(v => v.ClaimedAt)?.ClaimedAt,
redemptions.OrderByDescending(r => r.RedeemedAt).FirstOrDefault()?.RedeemedAt);
}
}
/// <summary>
/// EN: Handler for GetAllVouchersQuery.
/// VI: Handler cho GetAllVouchersQuery.
/// </summary>
public class GetAllVouchersQueryHandler : IRequestHandler<GetAllVouchersQuery, PaginatedResponse<AdminVoucherListDto>>
{
private readonly PromotionServiceContext _context;
public GetAllVouchersQueryHandler(PromotionServiceContext context)
{
_context = context;
}
public async Task<PaginatedResponse<AdminVoucherListDto>> Handle(GetAllVouchersQuery request, CancellationToken cancellationToken)
{
var query = _context.Vouchers.AsQueryable();
if (request.CampaignId.HasValue)
query = query.Where(v => v.CampaignId == request.CampaignId.Value);
if (request.UserId.HasValue)
query = query.Where(v => v.OwnerId == request.UserId.Value);
if (!string.IsNullOrEmpty(request.CodeSearch))
query = query.Where(v => v.Code.Contains(request.CodeSearch));
if (!string.IsNullOrEmpty(request.Status))
{
var statusId = request.Status.ToLower() switch
{
"available" => 1,
"claimed" => 2,
"partiallyredeemed" => 3,
"fullyredeemed" => 4,
"expired" => 5,
_ => 0
};
if (statusId > 0)
query = query.Where(v => v.StatusId == statusId);
}
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
var items = await query
.OrderByDescending(v => v.CreatedAt)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Join(_context.Campaigns,
v => v.CampaignId,
c => c.Id,
(v, c) => new AdminVoucherListDto(
v.Id,
v.CampaignId,
c.Name,
v.Code,
v.OwnerId,
null,
v.FaceValue,
v.RemainingValue,
v.StatusId == 1 ? "Available" : v.StatusId == 2 ? "Claimed" : v.StatusId == 3 ? "PartiallyRedeemed" : v.StatusId == 4 ? "FullyRedeemed" : "Expired",
v.ClaimedAt,
v.ExpiresAt,
v.RedeemedAt,
v.CreatedAt))
.ToListAsync(cancellationToken);
return new PaginatedResponse<AdminVoucherListDto>(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}
/// <summary>
/// EN: Handler for GetAllRedemptionsQuery.
/// VI: Handler cho GetAllRedemptionsQuery.
/// </summary>
public class GetAllRedemptionsQueryHandler : IRequestHandler<GetAllRedemptionsQuery, PaginatedResponse<AdminRedemptionListDto>>
{
private readonly PromotionServiceContext _context;
public GetAllRedemptionsQueryHandler(PromotionServiceContext context)
{
_context = context;
}
public async Task<PaginatedResponse<AdminRedemptionListDto>> Handle(GetAllRedemptionsQuery request, CancellationToken cancellationToken)
{
var query = _context.Redemptions.AsQueryable();
if (request.CampaignId.HasValue)
query = query.Where(r => r.CampaignId == request.CampaignId.Value);
if (request.VoucherId.HasValue)
query = query.Where(r => r.VoucherId == request.VoucherId.Value);
if (request.UserId.HasValue)
query = query.Where(r => r.UserId == request.UserId.Value);
if (request.DateFrom.HasValue)
query = query.Where(r => r.RedeemedAt >= request.DateFrom.Value);
if (request.DateTo.HasValue)
query = query.Where(r => r.RedeemedAt <= request.DateTo.Value);
var totalCount = await query.CountAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
var items = await query
.OrderByDescending(r => r.RedeemedAt)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Join(_context.Vouchers,
r => r.VoucherId,
v => v.Id,
(r, v) => new { Redemption = r, Voucher = v })
.Join(_context.Campaigns,
rv => rv.Voucher.CampaignId,
c => c.Id,
(rv, c) => new AdminRedemptionListDto(
rv.Redemption.Id,
rv.Redemption.VoucherId,
rv.Voucher.Code,
rv.Redemption.CampaignId,
c.Name,
rv.Redemption.UserId,
rv.Redemption.OrderId,
rv.Redemption.AmountUsed,
rv.Redemption.AmountRefunded,
rv.Redemption.RedeemedAt))
.ToListAsync(cancellationToken);
return new PaginatedResponse<AdminRedemptionListDto>(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}
/// <summary>
/// EN: Handler for GetRedemptionStatisticsQuery.
/// VI: Handler cho GetRedemptionStatisticsQuery.
/// </summary>
public class GetRedemptionStatisticsQueryHandler : IRequestHandler<GetRedemptionStatisticsQuery, AdminRedemptionStatisticsDto>
{
private readonly PromotionServiceContext _context;
public GetRedemptionStatisticsQueryHandler(PromotionServiceContext context)
{
_context = context;
}
public async Task<AdminRedemptionStatisticsDto> Handle(GetRedemptionStatisticsQuery request, CancellationToken cancellationToken)
{
var query = _context.Redemptions.AsQueryable();
if (request.CampaignId.HasValue)
query = query.Where(r => r.CampaignId == request.CampaignId.Value);
var today = DateTime.UtcNow.Date;
var weekStart = today.AddDays(-(int)today.DayOfWeek);
var monthStart = new DateTime(today.Year, today.Month, 1);
var stats = await query
.GroupBy(_ => 1)
.Select(g => new
{
Total = g.Count(),
TotalAmountUsed = g.Sum(r => r.AmountUsed),
TotalAmountRefunded = g.Sum(r => r.AmountRefunded),
AvgAmount = g.Average(r => r.AmountUsed)
})
.FirstOrDefaultAsync(cancellationToken);
var todayCount = await query.CountAsync(r => r.RedeemedAt >= today, cancellationToken);
var weekCount = await query.CountAsync(r => r.RedeemedAt >= weekStart, cancellationToken);
var monthCount = await query.CountAsync(r => r.RedeemedAt >= monthStart, cancellationToken);
return new AdminRedemptionStatisticsDto(
stats?.Total ?? 0,
stats?.TotalAmountUsed ?? 0,
stats?.TotalAmountRefunded ?? 0,
stats?.AvgAmount ?? 0,
todayCount,
weekCount,
monthCount);
}
}

View File

@@ -0,0 +1,125 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PromotionService.API.Application.Commands;
using PromotionService.API.Application.DTOs;
using PromotionService.API.Application.Queries;
namespace PromotionService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for Campaign management.
/// VI: Admin controller để quản lý Campaign.
/// </summary>
[ApiController]
[Route("api/v1/admin/campaigns")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
public class AdminCampaignsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminCampaignsController> _logger;
public AdminCampaignsController(IMediator mediator, ILogger<AdminCampaignsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all campaigns with pagination and filters.
/// VI: Lấy tất cả chiến dịch với phân trang và bộ lọc.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PaginatedResponse<AdminCampaignListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminCampaignListDto>>> GetAllCampaigns(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? merchantId = null,
[FromQuery] string? status = null,
[FromQuery] string? searchTerm = null)
{
var result = await _mediator.Send(new GetAllCampaignsQuery(pageNumber, pageSize, merchantId, status, searchTerm));
return Ok(result);
}
/// <summary>
/// EN: Get campaign statistics.
/// VI: Lấy thống kê chiến dịch.
/// </summary>
[HttpGet("{id:guid}/statistics")]
[ProducesResponseType(typeof(AdminCampaignStatisticsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdminCampaignStatisticsDto>> GetCampaignStatistics(Guid id)
{
var result = await _mediator.Send(new GetAdminCampaignStatisticsQuery(id));
return result == null ? NotFound() : Ok(result);
}
/// <summary>
/// EN: Get vouchers for a campaign.
/// VI: Lấy vouchers của chiến dịch.
/// </summary>
[HttpGet("{id:guid}/vouchers")]
[ProducesResponseType(typeof(PaginatedResponse<AdminVoucherListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminVoucherListDto>>> GetCampaignVouchers(
Guid id,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? status = null)
{
var result = await _mediator.Send(new GetCampaignVouchersQuery(id, pageNumber, pageSize, status));
return Ok(result);
}
/// <summary>
/// EN: Update campaign details.
/// VI: Cập nhật thông tin chiến dịch.
/// </summary>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateCampaign(Guid id, [FromBody] UpdateCampaignRequest request)
{
var result = await _mediator.Send(new UpdateCampaignCommand(
id, request.Name, request.Description, request.StartDate, request.EndDate, request.MaxPerUser));
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Force complete a campaign.
/// VI: Bắt buộc hoàn thành chiến dịch.
/// </summary>
[HttpPost("{id:guid}/complete")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> CompleteCampaign(Guid id)
{
var result = await _mediator.Send(new CompleteCampaignCommand(id));
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Delete (soft delete) a campaign.
/// VI: Xóa (xóa mềm) chiến dịch.
/// </summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteCampaign(Guid id)
{
var result = await _mediator.Send(new DeleteCampaignCommand(id));
return result ? NoContent() : NotFound();
}
}
/// <summary>
/// EN: Request to update campaign.
/// VI: Request để cập nhật chiến dịch.
/// </summary>
public record UpdateCampaignRequest(
string? Name = null,
string? Description = null,
DateTime? StartDate = null,
DateTime? EndDate = null,
int? MaxPerUser = null);

View File

@@ -0,0 +1,89 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PromotionService.API.Application.DTOs;
using PromotionService.API.Application.Queries;
namespace PromotionService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for Redemption management.
/// VI: Admin controller để quản lý Redemption.
/// </summary>
[ApiController]
[Route("api/v1/admin/redemptions")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
public class AdminRedemptionsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminRedemptionsController> _logger;
public AdminRedemptionsController(IMediator mediator, ILogger<AdminRedemptionsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all redemptions with pagination and filters.
/// VI: Lấy tất cả redemptions với phân trang và bộ lọc.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PaginatedResponse<AdminRedemptionListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminRedemptionListDto>>> GetAllRedemptions(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? campaignId = null,
[FromQuery] Guid? voucherId = null,
[FromQuery] Guid? userId = null,
[FromQuery] DateTime? dateFrom = null,
[FromQuery] DateTime? dateTo = null)
{
var result = await _mediator.Send(new GetAllRedemptionsQuery(pageNumber, pageSize, campaignId, voucherId, userId, dateFrom, dateTo));
return Ok(result);
}
/// <summary>
/// EN: Get redemptions by campaign.
/// VI: Lấy redemptions theo chiến dịch.
/// </summary>
[HttpGet("by-campaign/{campaignId:guid}")]
[ProducesResponseType(typeof(PaginatedResponse<AdminRedemptionListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminRedemptionListDto>>> GetRedemptionsByCampaign(
Guid campaignId,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20)
{
var result = await _mediator.Send(new GetAllRedemptionsQuery(pageNumber, pageSize, campaignId, null, null, null, null));
return Ok(result);
}
/// <summary>
/// EN: Get redemptions by voucher.
/// VI: Lấy redemptions theo voucher.
/// </summary>
[HttpGet("by-voucher/{voucherId:guid}")]
[ProducesResponseType(typeof(PaginatedResponse<AdminRedemptionListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminRedemptionListDto>>> GetRedemptionsByVoucher(
Guid voucherId,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20)
{
var result = await _mediator.Send(new GetAllRedemptionsQuery(pageNumber, pageSize, null, voucherId, null, null, null));
return Ok(result);
}
/// <summary>
/// EN: Get redemption statistics.
/// VI: Lấy thống kê redemption.
/// </summary>
[HttpGet("statistics")]
[ProducesResponseType(typeof(AdminRedemptionStatisticsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<AdminRedemptionStatisticsDto>> GetRedemptionStatistics(
[FromQuery] Guid? campaignId = null)
{
var result = await _mediator.Send(new GetRedemptionStatisticsQuery(campaignId));
return Ok(result);
}
}

View File

@@ -0,0 +1,103 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PromotionService.API.Application.Commands;
using PromotionService.API.Application.DTOs;
using PromotionService.API.Application.Queries;
namespace PromotionService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for Voucher management.
/// VI: Admin controller để quản lý Voucher.
/// </summary>
[ApiController]
[Route("api/v1/admin/vouchers")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
public class AdminVouchersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminVouchersController> _logger;
public AdminVouchersController(IMediator mediator, ILogger<AdminVouchersController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all vouchers with pagination and filters.
/// VI: Lấy tất cả vouchers với phân trang và bộ lọc.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(PaginatedResponse<AdminVoucherListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminVoucherListDto>>> GetAllVouchers(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? campaignId = null,
[FromQuery] Guid? userId = null,
[FromQuery] string? status = null,
[FromQuery] string? codeSearch = null)
{
var result = await _mediator.Send(new GetAllVouchersQuery(pageNumber, pageSize, campaignId, userId, status, codeSearch));
return Ok(result);
}
/// <summary>
/// EN: Search vouchers by code.
/// VI: Tìm kiếm vouchers theo mã.
/// </summary>
[HttpGet("search")]
[ProducesResponseType(typeof(IEnumerable<AdminVoucherListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<AdminVoucherListDto>>> SearchVouchers(
[FromQuery] string q)
{
var result = await _mediator.Send(new SearchVouchersQuery(q));
return Ok(result);
}
/// <summary>
/// EN: Get vouchers by user.
/// VI: Lấy vouchers theo user.
/// </summary>
[HttpGet("by-user/{userId:guid}")]
[ProducesResponseType(typeof(PaginatedResponse<AdminVoucherListDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PaginatedResponse<AdminVoucherListDto>>> GetVouchersByUser(
Guid userId,
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20)
{
var result = await _mediator.Send(new GetAllVouchersQuery(pageNumber, pageSize, null, userId, null, null));
return Ok(result);
}
/// <summary>
/// EN: Revoke a voucher.
/// VI: Thu hồi voucher.
/// </summary>
[HttpPost("{id:guid}/revoke")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RevokeVoucher(Guid id, [FromBody] RevokeVoucherRequest request)
{
var result = await _mediator.Send(new RevokeVoucherCommand(id, request.Reason));
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Extend voucher expiry.
/// VI: Gia hạn voucher.
/// </summary>
[HttpPost("{id:guid}/extend")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ExtendVoucherExpiry(Guid id, [FromBody] ExtendExpiryRequest request)
{
var result = await _mediator.Send(new ExtendVoucherExpiryCommand(id, request.AdditionalDays));
return result ? Ok() : NotFound();
}
}
public record RevokeVoucherRequest(string Reason);
public record ExtendExpiryRequest(int AdditionalDays);

View File

@@ -305,6 +305,38 @@ public class Campaign : Entity, IAggregateRoot
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update campaign details (admin only).
/// VI: Cập nhật thông tin chiến dịch (chỉ admin).
/// </summary>
public void Update(string? name, string? description, DateTime? startDate, DateTime? endDate, int? maxPerUser)
{
if (!string.IsNullOrWhiteSpace(name))
Name = name;
if (description != null)
Description = description;
if (startDate.HasValue)
{
if (endDate.HasValue && endDate.Value <= startDate.Value)
throw new PromotionDomainException("End date must be after start date");
StartDate = startDate.Value;
}
if (endDate.HasValue)
{
if (endDate.Value <= StartDate)
throw new PromotionDomainException("End date must be after start date");
EndDate = endDate.Value;
}
if (maxPerUser.HasValue && maxPerUser.Value >= 0)
MaxPerUser = maxPerUser.Value;
UpdatedAt = DateTime.UtcNow;
}
#endregion
#region Voucher Management

View File

@@ -38,6 +38,12 @@ public interface ICampaignRepository : IRepository<Campaign>
/// </summary>
Task<IEnumerable<Voucher>> GetUserVouchersAsync(Guid userId);
/// <summary>
/// EN: Get voucher by ID.
/// VI: Lấy voucher theo ID.
/// </summary>
Task<Voucher?> GetVoucherByIdAsync(Guid voucherId);
/// <summary>
/// EN: Add a new campaign.
/// VI: Thêm chiến dịch mới.

View File

@@ -159,6 +159,26 @@ public class Voucher : Entity
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Extend voucher expiry by additional days.
/// VI: Gia hạn voucher thêm số ngày.
/// </summary>
public void ExtendExpiry(int additionalDays)
{
if (additionalDays <= 0)
throw new PromotionDomainException("Additional days must be positive");
if (Status == VoucherStatus.FullyRedeemed || Status == VoucherStatus.Expired)
throw new PromotionDomainException("Cannot extend expired or fully redeemed voucher");
if (!ExpiresAt.HasValue)
ExpiresAt = DateTime.UtcNow.AddDays(additionalDays);
else
ExpiresAt = ExpiresAt.Value.AddDays(additionalDays);
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Check if voucher is valid for redemption.
/// VI: Kiểm tra voucher có hợp lệ để sử dụng.

View File

@@ -59,6 +59,12 @@ public class CampaignRepository : ICampaignRepository
.ToListAsync();
}
public async Task<Voucher?> GetVoucherByIdAsync(Guid voucherId)
{
return await _context.Vouchers
.FirstOrDefaultAsync(v => v.Id == voucherId);
}
public Campaign Add(Campaign campaign)
{
return _context.Campaigns.Add(campaign).Entity;

View File

@@ -0,0 +1,128 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using PromotionService.API.Application.Commands;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.Exceptions;
using PromotionService.Domain.SeedWork;
using Xunit;
namespace PromotionService.UnitTests.Application;
/// <summary>
/// EN: Unit tests for ClaimVoucherCommandHandler.
/// VI: Unit tests cho ClaimVoucherCommandHandler.
/// </summary>
public class ClaimVoucherCommandHandlerTests
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<ClaimVoucherCommandHandler> _logger;
private readonly ClaimVoucherCommandHandler _handler;
public ClaimVoucherCommandHandlerTests()
{
_campaignRepository = Substitute.For<ICampaignRepository>();
_logger = Substitute.For<ILogger<ClaimVoucherCommandHandler>>();
var unitOfWork = Substitute.For<IUnitOfWork>();
unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
_campaignRepository.UnitOfWork.Returns(unitOfWork);
_handler = new ClaimVoucherCommandHandler(_campaignRepository, _logger);
}
private static Campaign CreateActiveCampaign()
{
var campaign = new Campaign(
merchantId: Guid.NewGuid(),
name: "Free Voucher Campaign",
description: null,
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 50_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 100,
startDate: DateTime.UtcNow.AddDays(-1),
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.GenerateVouchers(100);
campaign.Activate();
return campaign;
}
[Fact]
public async Task Handle_FreeCampaign_ClaimsVoucherSuccessfully()
{
// Arrange
var campaign = CreateActiveCampaign();
var userId = Guid.NewGuid();
var command = new ClaimVoucherCommand(campaign.Id, userId);
_campaignRepository.GetByIdAsync(campaign.Id)
.Returns(campaign);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.OwnerId.Should().Be(userId);
result.Status.Should().Be("Claimed");
result.RemainingValue.Should().Be(50_000m);
_campaignRepository.Received(1).Update(campaign);
await _campaignRepository.UnitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_NonFreeCampaign_ThrowsDomainException()
{
// Arrange
var campaign = new Campaign(
merchantId: Guid.NewGuid(),
name: "Paid Voucher Campaign",
description: null,
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 100_000m,
acquisitionType: AcquisitionType.Purchase, // Not free!
acquisitionPrice: 50_000m,
totalVouchers: 100,
startDate: DateTime.UtcNow.AddDays(-1),
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.GenerateVouchers(100);
campaign.Activate();
_campaignRepository.GetByIdAsync(campaign.Id).Returns(campaign);
var command = new ClaimVoucherCommand(campaign.Id, Guid.NewGuid());
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<PromotionDomainException>()
.WithMessage("*requires payment*");
}
[Fact]
public async Task Handle_CampaignNotFound_ThrowsDomainException()
{
// Arrange
var nonExistentId = Guid.NewGuid();
_campaignRepository.GetByIdAsync(nonExistentId).Returns((Campaign?)null);
var command = new ClaimVoucherCommand(nonExistentId, Guid.NewGuid());
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<PromotionDomainException>()
.WithMessage("*not found*");
}
}

View File

@@ -0,0 +1,172 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using PromotionService.API.Application.Commands;
using PromotionService.API.Application.Services;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.Exceptions;
using PromotionService.Domain.SeedWork;
using Xunit;
namespace PromotionService.UnitTests.Application;
/// <summary>
/// EN: Unit tests for CreateCampaignCommandHandler.
/// VI: Unit tests cho CreateCampaignCommandHandler.
/// </summary>
public class CreateCampaignCommandHandlerTests
{
private readonly ICampaignRepository _campaignRepository;
private readonly IWalletServiceClient _walletService;
private readonly ILogger<CreateCampaignCommandHandler> _logger;
private readonly CreateCampaignCommandHandler _handler;
public CreateCampaignCommandHandlerTests()
{
_campaignRepository = Substitute.For<ICampaignRepository>();
_walletService = Substitute.For<IWalletServiceClient>();
_logger = Substitute.For<ILogger<CreateCampaignCommandHandler>>();
// Setup UnitOfWork
var unitOfWork = Substitute.For<IUnitOfWork>();
unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
_campaignRepository.UnitOfWork.Returns(unitOfWork);
_handler = new CreateCampaignCommandHandler(
_campaignRepository,
_walletService,
_logger);
}
private static CreateCampaignCommand CreateValidCommand() => new CreateCampaignCommand(
MerchantId: Guid.NewGuid(),
MerchantWalletId: Guid.NewGuid(),
Name: "Test Campaign",
Description: "Test Description",
BackingAssetType: "Currency",
BackingAssetCode: "VND",
FaceValue: 100_000m,
AcquisitionType: "Free",
AcquisitionPrice: 0m,
TotalVouchers: 100,
StartDate: DateTime.UtcNow.AddDays(1),
EndDate: DateTime.UtcNow.AddDays(30),
VoucherValidityDays: 30,
MaxPerUser: 1);
[Fact]
public async Task Handle_ValidCommand_CreatesCampaignAndReturnsDto()
{
// Arrange
var command = CreateValidCommand();
var holdResult = new HoldResult(
HoldId: Guid.NewGuid(),
WalletId: command.MerchantWalletId,
Amount: command.FaceValue * command.TotalVouchers,
CurrencyType: command.BackingAssetCode,
ReferenceType: "CAMPAIGN",
ReferenceId: Guid.NewGuid(),
Status: "Active");
_walletService.CreateHoldAsync(
Arg.Any<Guid>(),
Arg.Any<decimal>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(holdResult);
_campaignRepository.Add(Arg.Any<Campaign>())
.Returns(callInfo => callInfo.Arg<Campaign>());
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be(command.Name);
result.TotalVouchers.Should().Be(command.TotalVouchers);
result.Status.Should().Be("Draft");
// Verify repository called
_campaignRepository.Received(1).Add(Arg.Is<Campaign>(c => c.Name == command.Name));
await _campaignRepository.UnitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ValidCommand_CallsWalletServiceToCreateEscrow()
{
// Arrange
var command = CreateValidCommand();
var expectedEscrowAmount = command.FaceValue * command.TotalVouchers;
_walletService.CreateHoldAsync(
Arg.Any<Guid>(),
Arg.Any<decimal>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new HoldResult(
Guid.NewGuid(),
command.MerchantWalletId,
expectedEscrowAmount,
"VND",
"CAMPAIGN",
Guid.NewGuid(),
"Active"));
_campaignRepository.Add(Arg.Any<Campaign>())
.Returns(callInfo => callInfo.Arg<Campaign>());
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert - Verify wallet service was called with correct escrow amount
await _walletService.Received(1).CreateHoldAsync(
command.MerchantWalletId,
expectedEscrowAmount,
command.BackingAssetCode,
"CAMPAIGN",
Arg.Any<Guid>(),
Arg.Is<string>(s => s.Contains(command.Name)),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_InvalidAcquisitionType_ThrowsDomainException()
{
// Arrange
var command = new CreateCampaignCommand(
MerchantId: Guid.NewGuid(),
MerchantWalletId: Guid.NewGuid(),
Name: "Test Campaign",
Description: null,
BackingAssetType: "Currency",
BackingAssetCode: "VND",
FaceValue: 100_000m,
AcquisitionType: "InvalidType", // Invalid!
AcquisitionPrice: 0m,
TotalVouchers: 100,
StartDate: DateTime.UtcNow.AddDays(1),
EndDate: DateTime.UtcNow.AddDays(30));
// Act & Assert
var act = async () => await _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<PromotionDomainException>()
.WithMessage("*Invalid acquisition type*");
// Verify wallet service was NOT called
await _walletService.DidNotReceive().CreateHoldAsync(
Arg.Any<Guid>(),
Arg.Any<decimal>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,326 @@
using FluentAssertions;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.Events;
using PromotionService.Domain.Exceptions;
using Xunit;
namespace PromotionService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Campaign aggregate root.
/// VI: Unit tests cho Campaign aggregate root.
/// </summary>
public class CampaignAggregateTests
{
#region Helper Methods
/// <summary>
/// EN: Creates a campaign with future start date (for constructor/lifecycle tests).
/// VI: Tạo campaign với ngày bắt đầu trong tương lai (cho constructor/lifecycle tests).
/// </summary>
private static Campaign CreateValidCampaign() => new Campaign(
merchantId: Guid.NewGuid(),
name: "Test Campaign",
description: "Test Description",
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 100_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 100,
startDate: DateTime.UtcNow.AddDays(1),
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
/// <summary>
/// EN: Creates a campaign with past start date (for issuing vouchers tests).
/// VI: Tạo campaign với ngày bắt đầu trong quá khứ (cho tests phát hành voucher).
/// </summary>
private static Campaign CreateActiveCampaign() => new Campaign(
merchantId: Guid.NewGuid(),
name: "Active Campaign",
description: "Active Description",
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 100_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 100,
startDate: DateTime.UtcNow.AddDays(-1), // Started yesterday
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
#endregion
#region Constructor Tests
[Fact]
public void Constructor_ValidParameters_CreatesCampaignInDraftStatus()
{
// Arrange & Act
var campaign = CreateValidCampaign();
// Assert
campaign.Id.Should().NotBeEmpty();
campaign.StatusId.Should().Be(CampaignStatus.Draft.Id);
campaign.TotalVouchers.Should().Be(100);
campaign.IssuedVouchers.Should().Be(0);
campaign.Vouchers.Should().BeEmpty();
}
[Fact]
public void Constructor_ZeroTotalVouchers_ThrowsException()
{
// Arrange & Act
var act = () => new Campaign(
merchantId: Guid.NewGuid(),
name: "Test Campaign",
description: null,
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 100_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 0, // Invalid!
startDate: DateTime.UtcNow.AddDays(1),
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
// Assert
act.Should().Throw<PromotionDomainException>()
.WithMessage("*Total vouchers*");
}
[Fact]
public void Constructor_EndDateBeforeStartDate_ThrowsException()
{
// Arrange & Act
var act = () => new Campaign(
merchantId: Guid.NewGuid(),
name: "Test Campaign",
description: null,
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 100_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 100,
startDate: DateTime.UtcNow.AddDays(30),
endDate: DateTime.UtcNow.AddDays(1), // Before start!
voucherValidityDays: 30,
maxPerUser: 1);
// Assert
act.Should().Throw<PromotionDomainException>()
.WithMessage("*End date*");
}
#endregion
#region Lifecycle Tests
[Fact]
public void Activate_DraftCampaign_ChangesStatusToActive()
{
// Arrange
var campaign = CreateValidCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
// Act
campaign.Activate();
// Assert
campaign.StatusId.Should().Be(CampaignStatus.Active.Id);
}
[Fact]
public void Activate_WithoutEscrow_ThrowsException()
{
// Arrange
var campaign = CreateValidCampaign();
// Act & Assert
var act = () => campaign.Activate();
act.Should().Throw<PromotionDomainException>()
.WithMessage("*escrow*");
}
[Fact]
public void Pause_ActiveCampaign_ChangesStatusToPaused()
{
// Arrange
var campaign = CreateValidCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.Activate();
// Act
campaign.Pause();
// Assert
campaign.StatusId.Should().Be(CampaignStatus.Paused.Id);
}
[Fact]
public void Cancel_DraftCampaign_ChangesStatusToCancelled()
{
// Arrange
var campaign = CreateValidCampaign();
// Act
campaign.Cancel();
// Assert
campaign.StatusId.Should().Be(CampaignStatus.Cancelled.Id);
}
[Fact]
public void Cancel_ActiveCampaign_ChangesStatusToCancelled()
{
// Arrange
var campaign = CreateValidCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.Activate();
// Act
campaign.Cancel();
// Assert
campaign.StatusId.Should().Be(CampaignStatus.Cancelled.Id);
campaign.DomainEvents.Should().ContainSingle(e => e is CampaignCancelledDomainEvent);
}
#endregion
#region Voucher Generation Tests
[Fact]
public void GenerateVouchers_ValidCount_CreatesVouchersWithUniqueCodes()
{
// Arrange
var campaign = CreateValidCampaign();
// Act
campaign.GenerateVouchers(10);
// Assert
campaign.Vouchers.Should().HaveCount(10);
campaign.Vouchers.Select(v => v.Code).Should().OnlyHaveUniqueItems();
campaign.Vouchers.All(v => v.FaceValue == campaign.FaceValue).Should().BeTrue();
}
[Fact]
public void GenerateVouchers_ExceedsTotalLimit_ThrowsException()
{
// Arrange
var campaign = CreateValidCampaign(); // totalVouchers = 100
// Act & Assert
var act = () => campaign.GenerateVouchers(150);
act.Should().Throw<PromotionDomainException>()
.WithMessage("*Maximum*");
}
#endregion
#region Issue Voucher Tests
[Fact]
public void IssueVoucher_ActiveCampaign_ReturnsClaimedVoucher()
{
// Arrange
var campaign = CreateActiveCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.GenerateVouchers(10);
campaign.Activate();
var userId = Guid.NewGuid();
// Act
var voucher = campaign.IssueVoucher(userId);
// Assert
voucher.Should().NotBeNull();
voucher.OwnerId.Should().Be(userId);
voucher.StatusId.Should().Be(VoucherStatus.Claimed.Id);
campaign.IssuedVouchers.Should().Be(1);
}
[Fact]
public void IssueVoucher_DraftCampaign_ThrowsException()
{
// Arrange
var campaign = CreateValidCampaign();
campaign.GenerateVouchers(10);
// Act & Assert
var act = () => campaign.IssueVoucher(Guid.NewGuid());
act.Should().Throw<CampaignNotActiveException>();
}
[Fact]
public void IssueVoucher_NoAvailableVouchers_ThrowsException()
{
// Arrange
var campaign = new Campaign(
merchantId: Guid.NewGuid(),
name: "Small Campaign",
description: null,
backingAssetType: AssetType.Currency,
backingAssetCode: "VND",
faceValue: 50_000m,
acquisitionType: AcquisitionType.Free,
acquisitionPrice: 0m,
totalVouchers: 1,
startDate: DateTime.UtcNow.AddDays(-1),
endDate: DateTime.UtcNow.AddDays(30),
voucherValidityDays: 30,
maxPerUser: 1);
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.GenerateVouchers(1);
campaign.Activate();
campaign.IssueVoucher(Guid.NewGuid()); // Take the only one
// Act & Assert
var act = () => campaign.IssueVoucher(Guid.NewGuid());
act.Should().Throw<PromotionDomainException>()
.WithMessage("*voucher*");
}
#endregion
#region Domain Events Tests
[Fact]
public void Activate_AddsCampaignActivatedDomainEvent()
{
// Arrange
var campaign = CreateValidCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
// Act
campaign.Activate();
// Assert
campaign.DomainEvents.Should().ContainSingle(e => e is CampaignActivatedDomainEvent);
}
[Fact]
public void IssueVoucher_AddsVoucherClaimedDomainEvent()
{
// Arrange
var campaign = CreateActiveCampaign();
campaign.SetEscrowHold(Guid.NewGuid(), Guid.NewGuid());
campaign.GenerateVouchers(10);
campaign.Activate();
// Act
campaign.IssueVoucher(Guid.NewGuid());
// Assert
campaign.DomainEvents.Should().Contain(e => e is VoucherClaimedDomainEvent);
}
#endregion
}

View File

@@ -0,0 +1,199 @@
using FluentAssertions;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.Exceptions;
using Xunit;
namespace PromotionService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Voucher entity.
/// VI: Unit tests cho Voucher entity.
/// </summary>
public class VoucherEntityTests
{
private const int DefaultValidityDays = 30;
#region Helper Methods
private static Voucher CreateAvailableVoucher() => new Voucher(
campaignId: Guid.NewGuid(),
code: "TEST-VOUCHER-001",
faceValue: 100_000m,
validityDays: DefaultValidityDays);
private static Voucher CreateClaimedVoucher()
{
var voucher = CreateAvailableVoucher();
voucher.Claim(Guid.NewGuid(), DefaultValidityDays);
return voucher;
}
#endregion
#region Constructor Tests
[Fact]
public void Constructor_ValidParameters_CreatesVoucherInAvailableStatus()
{
// Arrange & Act
var voucher = CreateAvailableVoucher();
// Assert
voucher.Id.Should().NotBeEmpty();
voucher.Code.Should().Be("TEST-VOUCHER-001");
voucher.FaceValue.Should().Be(100_000m);
voucher.RemainingValue.Should().Be(100_000m);
voucher.StatusId.Should().Be(VoucherStatus.Available.Id);
voucher.OwnerId.Should().BeNull();
}
#endregion
#region Claim Tests
[Fact]
public void Claim_AvailableVoucher_SetsOwnerAndClaimedStatus()
{
// Arrange
var voucher = CreateAvailableVoucher();
var userId = Guid.NewGuid();
// Act
voucher.Claim(userId, DefaultValidityDays);
// Assert
voucher.OwnerId.Should().Be(userId);
voucher.StatusId.Should().Be(VoucherStatus.Claimed.Id);
voucher.ClaimedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
voucher.ExpiresAt.Should().BeCloseTo(DateTime.UtcNow.AddDays(DefaultValidityDays), TimeSpan.FromSeconds(5));
}
[Fact]
public void Claim_AlreadyClaimedVoucher_ThrowsException()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act & Assert
var act = () => voucher.Claim(Guid.NewGuid(), DefaultValidityDays);
act.Should().Throw<VoucherAlreadyClaimedException>();
}
#endregion
#region Redeem Tests
[Fact]
public void Redeem_FullValue_SetsStatusToFullyRedeemed()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act
voucher.Redeem(100_000m); // Full face value
// Assert
voucher.RemainingValue.Should().Be(0);
voucher.StatusId.Should().Be(VoucherStatus.FullyRedeemed.Id);
voucher.RedeemedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Redeem_PartialValue_SetsStatusToPartiallyRedeemed()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act
voucher.Redeem(50_000m); // Half
// Assert
voucher.RemainingValue.Should().Be(50_000m);
voucher.StatusId.Should().Be(VoucherStatus.PartiallyRedeemed.Id);
}
[Fact]
public void Redeem_MoreThanRemainingValue_CapsAtRemainingValue()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act - Request more than available
var actualRedeemed = voucher.Redeem(150_000m);
// Assert - Only redeems what's available
actualRedeemed.Should().Be(100_000m);
voucher.RemainingValue.Should().Be(0);
voucher.StatusId.Should().Be(VoucherStatus.FullyRedeemed.Id);
}
[Fact]
public void Redeem_AvailableVoucher_ThrowsException()
{
// Arrange
var voucher = CreateAvailableVoucher(); // Not claimed
// Act & Assert
var act = () => voucher.Redeem(50_000m);
act.Should().Throw<PromotionDomainException>()
.WithMessage("*not been claimed*");
}
#endregion
#region Expire Tests
[Fact]
public void Expire_ClaimedVoucher_SetsStatusToExpired()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act
voucher.Expire();
// Assert
voucher.StatusId.Should().Be(VoucherStatus.Expired.Id);
}
[Fact]
public void Expire_FullyRedeemedVoucher_DoesNotChangeStatus()
{
// Arrange
var voucher = CreateClaimedVoucher();
voucher.Redeem(100_000m);
// Act
voucher.Expire();
// Assert - Status remains FullyRedeemed
voucher.StatusId.Should().Be(VoucherStatus.FullyRedeemed.Id);
}
#endregion
#region Validation Tests
[Fact]
public void IsValidForRedemption_ClaimedNotExpired_ReturnsTrue()
{
// Arrange
var voucher = CreateClaimedVoucher();
// Act & Assert
voucher.IsValidForRedemption().Should().BeTrue();
}
[Fact]
public void IsValidForRedemption_FullyRedeemed_ReturnsFalse()
{
// Arrange
var voucher = CreateClaimedVoucher();
voucher.Redeem(100_000m);
// Act & Assert
voucher.IsValidForRedemption().Should().BeFalse();
}
#endregion
}

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<!-- EN: Test framework / VI: Test framework -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>