feat(storage-service): Add Social Service to Docker Compose and enhance IAM service integration

- Introduced a new social-service in the Docker Compose configuration for local development, including build context, environment variables, and health checks.
- Updated architecture documentation to reflect the new storage service structure and its components, including user storage quotas and file management.
- Enhanced README files to provide clearer instructions on service setup, configuration, and API endpoints for file storage management.
- Implemented caching mechanisms in the IAM service client for improved performance and reduced latency in user information retrieval.
- Updated appsettings for development to include caching settings for IAM service interactions.
This commit is contained in:
Ho Ngoc Hai
2026-01-13 00:28:41 +07:00
parent 928a22fe3e
commit 4a1a0ef79c
385 changed files with 28872 additions and 808 deletions

View File

@@ -199,6 +199,41 @@ services:
timeout: 5s
retries: 5
# Social Service .NET - Social Graph Management
social-service:
build:
context: ../..
dockerfile: services/social-service-net/Dockerfile
container_name: social-service-local
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=${SOCIAL_DATABASE_URL:-Host=localhost;Port=5432;Database=social_db;Username=postgres;Password=postgres}
- IamService__BaseUrl=http://iam-service:5001
- IamService__ServiceName=social-service
ports:
- "5003:8080"
depends_on:
redis:
condition: service_healthy
traefik:
condition: service_started
networks:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.social-service.rule=PathPrefix(`/api/v1/relationships`) || PathPrefix(`/api/v1/blocks`)"
- "traefik.http.routers.social-service.entrypoints=web"
- "traefik.http.services.social-service.loadbalancer.server.port=8080"
- "traefik.http.services.social-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.social-service.loadbalancer.healthcheck.interval=10s"
# ===========================================================================
# FRONTEND APPLICATIONS (Temporarily disabled)
# ===========================================================================

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

75
services/chat-service-net/.gitignore vendored Normal file
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,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ChatService.API/ChatService.API.csproj" />
<Project Path="src/ChatService.Domain/ChatService.Domain.csproj" />
<Project Path="src/ChatService.Infrastructure/ChatService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ChatService.FunctionalTests/ChatService.FunctionalTests.csproj" />
<Project Path="tests/ChatService.UnitTests/ChatService.UnitTests.csproj" />
</Folder>
</Solution>

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

View File

@@ -0,0 +1,73 @@
version: '3.8'
# EN: Docker Compose for Chat Service local development
# VI: Docker Compose cho Chat Service phát triển local
services:
chatservice-api:
build:
context: .
dockerfile: Dockerfile
container_name: chatservice-api
ports:
- "5003:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=chatservice_db;Username=postgres;Password=postgres
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- chatservice-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
postgres:
image: postgres:16-alpine
container_name: chatservice-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: chatservice_db
ports:
- "5435:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- chatservice-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: chatservice-redis
ports:
- "6382:6379"
volumes:
- redis_data:/data
networks:
- chatservice-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
networks:
chatservice-network:
driver: bridge

View File

@@ -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 (ChatService.Domain)
The heart of the application containing pure business logic. This layer:
- Has **ZERO** external dependencies (except MediatR.Contracts for events)
- Contains only POCO classes
- Implements DDD tactical patterns
#### Components
| Component | Purpose |
|-----------|---------|
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
| **AggregatesModel** | Aggregate roots with their entities and value objects |
| **Events** | Domain events for cross-aggregate communication |
| **Exceptions** | Domain-specific exceptions for business rule violations |
### 2. Infrastructure Layer (ChatService.Infrastructure)
Technical implementations and external concerns:
- Database access (EF Core)
- Repository implementations
- External service integrations
### 3. API Layer (ChatService.API)
Application entry point and CQRS implementation:
- Controllers for HTTP handling
- Commands for write operations
- Queries for read operations
- MediatR behaviors for cross-cutting concerns
## CQRS Flow
```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 "ChatService" to "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} +
```
### 2. Configure Environment
```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/ChatService.API
```
## Project Structure
```
_template_dot_net/
├── src/
│ ├── ChatService.API/ # Presentation Layer (Controllers, CQRS)
│ │ ├── Controllers/ # API endpoints
│ │ ├── Application/ # CQRS Implementation
│ │ │ ├── Commands/ # Write operations (MediatR)
│ │ │ ├── Queries/ # Read operations
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
│ │ │ └── Validations/ # FluentValidation validators
│ │ ├── Middleware/ # Custom middleware
│ │ └── Program.cs # Application entry point
│ │
│ ├── ChatService.Domain/ # Domain Layer (Pure business logic)
│ │ ├── AggregatesModel/ # Aggregate roots and entities
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
│ │
│ └── ChatService.Infrastructure/ # Infrastructure Layer (Data access)
│ ├── EntityConfigurations/ # EF Core Fluent API configurations
│ ├── Repositories/ # Repository implementations
│ ├── Idempotency/ # Request idempotency handling
│ └── ChatServiceContext.cs # DbContext with Unit of Work
├── tests/
│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application)
│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints)
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Local development setup
├── global.json # .NET SDK version pinning
└── Directory.Build.props # Common MSBuild properties
```
## 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/ChatService.UnitTests
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` |
| `DATABASE_URL` | PostgreSQL connection string | - |
| `REDIS_URL` | Redis connection string | - |
| `JWT_SECRET` | JWT signing secret (min 32 chars) | - |
### 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 (ChatService.Domain)
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
-**ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events)
- Chỉ chứa các class POCO
- Triển khai các tactical patterns của DDD
#### Thành Phần
| Thành phần | Mục Đích |
|------------|----------|
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
| **AggregatesModel** | Aggregate roots với entities và value objects |
| **Events** | Domain events cho giao tiếp cross-aggregate |
| **Exceptions** | Domain exceptions cho vi phạm business rules |
### 2. Lớp Infrastructure (ChatService.Infrastructure)
Triển khai kỹ thuật và các mối quan tâm bên ngoài:
- Truy cập database (EF Core)
- Triển khai repositories
- Tích hợp external services
### 3. Lớp API (ChatService.API)
Điểm vào ứng dụng và triển khai CQRS:
- Controllers để xử lý HTTP
- Commands cho các thao tác ghi
- Queries cho các thao tác đọc
- MediatR behaviors cho cross-cutting concerns
## Luồng CQRS
```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ả "ChatService" thành "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/ChatService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/ChatService/YourService/g' {} +
```
### 2. Cấu Hình Môi Trường
```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/ChatService.API
```
## Cấu Trúc Dự Án
```
_template_dot_net/
├── src/
│ ├── ChatService.API/ # Lớp Presentation (Controllers, CQRS)
│ │ ├── Controllers/ # Các API endpoints
│ │ ├── Application/ # Triển khai CQRS
│ │ │ ├── Commands/ # Thao tác ghi (MediatR)
│ │ │ ├── Queries/ # Thao tác đọc
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
│ │ │ └── Validations/ # FluentValidation validators
│ │ ├── Middleware/ # Custom middleware
│ │ └── Program.cs # Điểm vào ứng dụng
│ │
│ ├── ChatService.Domain/ # Lớp Domain (Business logic thuần túy)
│ │ ├── AggregatesModel/ # Aggregate roots và entities
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
│ │
│ └── ChatService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu)
│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API
│ ├── Repositories/ # Triển khai repositories
│ ├── Idempotency/ # Xử lý idempotency request
│ └── ChatServiceContext.cs # DbContext với Unit of Work
├── tests/
│ ├── ChatService.UnitTests/ # Unit tests (Domain, Application)
│ └── ChatService.FunctionalTests/ # Integration tests (API endpoints)
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Thiết lập phát triển local
├── global.json # Pin phiên bản .NET SDK
└── Directory.Build.props # Thuộc tính MSBuild chung
```
## 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/ChatService.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 ChatService.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 ChatService.Infrastructure;
namespace ChatService.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 ChatServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
ChatServiceContext 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 ChatService.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,30 @@
using MediatR;
namespace ChatService.API.Application.Commands.Conversations;
/// <summary>
/// EN: Command to create a new conversation.
/// VI: Command để tạo cuộc hội thoại mới.
/// </summary>
public record CreateConversationCommand(
Guid CreatorId,
IEnumerable<Guid> ParticipantIds,
string? Name = null,
string? AvatarUrl = null,
bool IsGroup = false
) : IRequest<CreateConversationResult>;
public record CreateConversationResult(
Guid ConversationId,
string Type,
string? Name,
IEnumerable<ParticipantDto> Participants,
DateTime CreatedAt
);
public record ParticipantDto(
Guid ChatUserId,
string DisplayName,
string? AvatarUrl,
string Role
);

View File

@@ -0,0 +1,100 @@
using MediatR;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Commands.Conversations;
/// <summary>
/// EN: Handler for CreateConversationCommand.
/// VI: Handler cho CreateConversationCommand.
/// </summary>
public class CreateConversationCommandHandler : IRequestHandler<CreateConversationCommand, CreateConversationResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<CreateConversationCommandHandler> _logger;
public CreateConversationCommandHandler(
IConversationRepository conversationRepository,
IChatUserRepository chatUserRepository,
ILogger<CreateConversationCommandHandler> logger)
{
_conversationRepository = conversationRepository;
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<CreateConversationResult> Handle(CreateConversationCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Creating conversation for creator {CreatorId} with {ParticipantCount} participants",
request.CreatorId, request.ParticipantIds.Count());
// EN: Validate all participants exist
// VI: Kiểm tra tất cả participants tồn tại
var allParticipantIds = request.ParticipantIds.Append(request.CreatorId).Distinct().ToList();
var users = await _chatUserRepository.GetByIdsAsync(allParticipantIds, cancellationToken);
var userDict = users.ToDictionary(u => u.Id);
if (userDict.Count != allParticipantIds.Count)
{
var missingIds = allParticipantIds.Where(id => !userDict.ContainsKey(id));
throw new InvalidOperationException($"ChatUsers not found: {string.Join(", ", missingIds)}");
}
Conversation conversation;
// EN: Check for existing direct conversation
// VI: Kiểm tra direct conversation đã tồn tại
if (!request.IsGroup && request.ParticipantIds.Count() == 1)
{
var existingConversation = await _conversationRepository.FindDirectConversationAsync(
request.CreatorId, request.ParticipantIds.First(), cancellationToken);
if (existingConversation != null)
{
_logger.LogInformation("Found existing direct conversation {ConversationId}", existingConversation.Id);
return CreateResult(existingConversation, userDict);
}
// EN: Create new direct conversation
// VI: Tạo direct conversation mới
conversation = Conversation.CreateDirect(request.CreatorId, request.ParticipantIds.First());
}
else
{
// EN: Create new group conversation
// VI: Tạo group conversation mới
conversation = Conversation.CreateGroup(
request.Name ?? "Group Chat",
request.CreatorId,
request.ParticipantIds,
request.AvatarUrl);
}
_conversationRepository.Add(conversation);
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Created conversation {ConversationId} of type {Type}",
conversation.Id, conversation.Type.Name);
return CreateResult(conversation, userDict);
}
private static CreateConversationResult CreateResult(Conversation conversation, Dictionary<Guid, ChatUser> userDict)
{
var participants = conversation.Participants.Select(p => new ParticipantDto(
p.UserId,
userDict.TryGetValue(p.UserId, out var user) ? user.DisplayName : "Unknown",
userDict.TryGetValue(p.UserId, out var u) ? u.AvatarUrl : null,
p.IsAdmin ? "admin" : "member"
)).ToList();
return new CreateConversationResult(
conversation.Id,
conversation.Type.Name,
conversation.Name,
participants,
conversation.CreatedAt
);
}
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Command to register a user's E2EE key bundle.
/// VI: Command để đăng ký E2EE key bundle của user.
/// </summary>
public record RegisterUserKeysCommand(
string IdentityUserId,
string DisplayName,
string? AvatarUrl,
string IdentityPublicKey,
string SignedPreKey,
string SignedPreKeySignature,
IEnumerable<OneTimePreKeyDto>? OneTimePreKeys = null
) : IRequest<RegisterUserKeysResult>;
public record OneTimePreKeyDto(int KeyId, string PublicKey);
public record RegisterUserKeysResult(
Guid ChatUserId,
int OneTimePreKeysUploaded
);

View File

@@ -0,0 +1,84 @@
using MediatR;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Handler for RegisterUserKeysCommand.
/// VI: Handler cho RegisterUserKeysCommand.
/// </summary>
public class RegisterUserKeysCommandHandler : IRequestHandler<RegisterUserKeysCommand, RegisterUserKeysResult>
{
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<RegisterUserKeysCommandHandler> _logger;
public RegisterUserKeysCommandHandler(
IChatUserRepository chatUserRepository,
ILogger<RegisterUserKeysCommandHandler> logger)
{
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<RegisterUserKeysResult> Handle(RegisterUserKeysCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Registering keys for user {IdentityUserId}", request.IdentityUserId);
// EN: Check if user already exists
// VI: Kiểm tra xem user đã tồn tại chưa
var existingUser = await _chatUserRepository.GetByIdentityUserIdAsync(request.IdentityUserId, cancellationToken);
if (existingUser != null)
{
// EN: Update existing user's keys
// VI: Cập nhật keys của user đã tồn tại
existingUser.RegisterKeyBundle(
request.IdentityPublicKey,
request.SignedPreKey,
request.SignedPreKeySignature);
if (request.OneTimePreKeys?.Any() == true)
{
existingUser.UploadOneTimePreKeys(
request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey)));
}
_chatUserRepository.Update(existingUser);
await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Updated keys for existing user {ChatUserId}", existingUser.Id);
return new RegisterUserKeysResult(
existingUser.Id,
request.OneTimePreKeys?.Count() ?? 0);
}
// EN: Create new chat user
// VI: Tạo chat user mới
var chatUser = new ChatUser(
request.IdentityUserId,
request.DisplayName,
request.AvatarUrl);
chatUser.RegisterKeyBundle(
request.IdentityPublicKey,
request.SignedPreKey,
request.SignedPreKeySignature);
if (request.OneTimePreKeys?.Any() == true)
{
chatUser.UploadOneTimePreKeys(
request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey)));
}
_chatUserRepository.Add(chatUser);
await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Created new chat user {ChatUserId} for {IdentityUserId}",
chatUser.Id, request.IdentityUserId);
return new RegisterUserKeysResult(
chatUser.Id,
request.OneTimePreKeys?.Count() ?? 0);
}
}

View File

@@ -0,0 +1,17 @@
using MediatR;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Command to rotate the signed pre-key.
/// VI: Command để xoay vòng signed pre-key.
/// </summary>
public record RotatePreKeyCommand(
Guid ChatUserId,
string NewSignedPreKey,
string NewSignedPreKeySignature
) : IRequest<RotatePreKeyResult>;
public record RotatePreKeyResult(
DateTime RotatedAt
);

View File

@@ -0,0 +1,43 @@
using MediatR;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Handler for RotatePreKeyCommand.
/// VI: Handler cho RotatePreKeyCommand.
/// </summary>
public class RotatePreKeyCommandHandler : IRequestHandler<RotatePreKeyCommand, RotatePreKeyResult>
{
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<RotatePreKeyCommandHandler> _logger;
public RotatePreKeyCommandHandler(
IChatUserRepository chatUserRepository,
ILogger<RotatePreKeyCommandHandler> logger)
{
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<RotatePreKeyResult> Handle(RotatePreKeyCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Rotating pre-key for user {ChatUserId}", request.ChatUserId);
var user = await _chatUserRepository.GetByIdAsync(request.ChatUserId, cancellationToken);
if (user == null)
{
throw new InvalidOperationException($"Chat user {request.ChatUserId} not found");
}
user.RotateSignedPreKey(request.NewSignedPreKey, request.NewSignedPreKeySignature);
_chatUserRepository.Update(user);
await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Pre-key rotated for user {ChatUserId}", request.ChatUserId);
return new RotatePreKeyResult(DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,17 @@
using MediatR;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Command to upload one-time pre-keys.
/// VI: Command để upload các one-time pre-keys.
/// </summary>
public record UploadOneTimeKeysCommand(
Guid ChatUserId,
IEnumerable<OneTimePreKeyDto> OneTimePreKeys
) : IRequest<UploadOneTimeKeysResult>;
public record UploadOneTimeKeysResult(
int KeysUploaded,
int TotalAvailableKeys
);

View File

@@ -0,0 +1,50 @@
using MediatR;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Commands.Keys;
/// <summary>
/// EN: Handler for UploadOneTimeKeysCommand.
/// VI: Handler cho UploadOneTimeKeysCommand.
/// </summary>
public class UploadOneTimeKeysCommandHandler : IRequestHandler<UploadOneTimeKeysCommand, UploadOneTimeKeysResult>
{
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<UploadOneTimeKeysCommandHandler> _logger;
public UploadOneTimeKeysCommandHandler(
IChatUserRepository chatUserRepository,
ILogger<UploadOneTimeKeysCommandHandler> logger)
{
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<UploadOneTimeKeysResult> Handle(UploadOneTimeKeysCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Uploading {Count} one-time keys for user {ChatUserId}",
request.OneTimePreKeys.Count(), request.ChatUserId);
var user = await _chatUserRepository.GetWithKeysAsync(request.ChatUserId, cancellationToken);
if (user == null)
{
throw new InvalidOperationException($"Chat user {request.ChatUserId} not found");
}
user.UploadOneTimePreKeys(
request.OneTimePreKeys.Select(k => (k.KeyId, k.PublicKey)));
_chatUserRepository.Update(user);
await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
var totalAvailable = user.GetAvailableOneTimePreKeyCount();
_logger.LogInformation("Uploaded {Count} keys, total available: {Total}",
request.OneTimePreKeys.Count(), totalAvailable);
return new UploadOneTimeKeysResult(
request.OneTimePreKeys.Count(),
totalAvailable);
}
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace ChatService.API.Application.Commands.Messages;
/// <summary>
/// EN: Command to mark messages as read.
/// VI: Command để đánh dấu tin nhắn đã đọc.
/// </summary>
public record MarkMessagesReadCommand(
Guid ConversationId,
Guid UserId,
Guid? LastReadMessageId = null,
DateTime? ReadUpTo = null
) : IRequest<MarkMessagesReadResult>;
public record MarkMessagesReadResult(
int MessagesMarked,
DateTime? LastReadAt
);

View File

@@ -0,0 +1,103 @@
using MediatR;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
namespace ChatService.API.Application.Commands.Messages;
/// <summary>
/// EN: Handler for MarkMessagesReadCommand.
/// VI: Handler cho MarkMessagesReadCommand.
/// </summary>
public class MarkMessagesReadCommandHandler : IRequestHandler<MarkMessagesReadCommand, MarkMessagesReadResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly ILogger<MarkMessagesReadCommandHandler> _logger;
public MarkMessagesReadCommandHandler(
IConversationRepository conversationRepository,
ILogger<MarkMessagesReadCommandHandler> logger)
{
_conversationRepository = conversationRepository;
_logger = logger;
}
public async Task<MarkMessagesReadResult> Handle(MarkMessagesReadCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Marking messages as read for user {UserId} in conversation {ConversationId}",
request.UserId, request.ConversationId);
var conversation = await _conversationRepository.GetByIdWithMessagesAsync(request.ConversationId, cancellationToken);
if (conversation == null)
{
throw new InvalidOperationException($"Conversation {request.ConversationId} not found");
}
// EN: Get participant
// VI: Lấy participant
var participant = conversation.Participants.FirstOrDefault(p => p.UserId == request.UserId && p.IsActive);
if (participant == null)
{
throw new InvalidOperationException("User is not a participant in this conversation");
}
int markedCount = 0;
DateTime? lastReadAt = null;
if (request.LastReadMessageId.HasValue)
{
// EN: Mark up to specific message
// VI: Đánh dấu đến tin nhắn cụ thể
participant.UpdateLastRead(request.LastReadMessageId.Value);
lastReadAt = participant.LastReadAt;
// EN: Count messages marked
// VI: Đếm số tin nhắn đã đánh dấu
var message = conversation.Messages.FirstOrDefault(m => m.Id == request.LastReadMessageId.Value);
if (message != null)
{
markedCount = conversation.Messages
.Count(m => m.SenderId != request.UserId && m.CreatedAt <= message.CreatedAt);
}
}
else if (request.ReadUpTo.HasValue)
{
// EN: Mark all messages up to timestamp
// VI: Đánh dấu tất cả tin nhắn đến thời điểm
var latestMessage = conversation.Messages
.Where(m => m.CreatedAt <= request.ReadUpTo.Value)
.OrderByDescending(m => m.CreatedAt)
.FirstOrDefault();
if (latestMessage != null)
{
participant.UpdateLastRead(latestMessage.Id);
lastReadAt = participant.LastReadAt;
markedCount = conversation.Messages
.Count(m => m.SenderId != request.UserId && m.CreatedAt <= request.ReadUpTo.Value);
}
}
else
{
// EN: Mark all messages as read
// VI: Đánh dấu tất cả tin nhắn
var latestMessage = conversation.Messages
.OrderByDescending(m => m.CreatedAt)
.FirstOrDefault();
if (latestMessage != null)
{
participant.UpdateLastRead(latestMessage.Id);
lastReadAt = participant.LastReadAt;
markedCount = conversation.Messages
.Count(m => m.SenderId != request.UserId);
}
}
_conversationRepository.Update(conversation);
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Marked {Count} messages as read", markedCount);
return new MarkMessagesReadResult(markedCount, lastReadAt);
}
}

View File

@@ -0,0 +1,30 @@
using MediatR;
namespace ChatService.API.Application.Commands.Messages;
/// <summary>
/// EN: Command to send an encrypted message.
/// VI: Command để gửi tin nhắn đã mã hóa.
/// </summary>
/// <remarks>
/// EN: The message content is encrypted on client-side using X3DH + Double Ratchet.
/// VI: Nội dung tin nhắn được mã hóa ở client-side bằng X3DH + Double Ratchet.
/// </remarks>
public record SendMessageCommand(
Guid ConversationId,
Guid SenderId,
string EncryptedContent,
string Nonce,
string? AuthTag = null,
string MessageType = "text",
string? Metadata = null,
Guid? ReplyToMessageId = null
) : IRequest<SendMessageResult>;
public record SendMessageResult(
Guid MessageId,
Guid ConversationId,
Guid SenderId,
string Status,
DateTime CreatedAt
);

View File

@@ -0,0 +1,67 @@
using MediatR;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
namespace ChatService.API.Application.Commands.Messages;
/// <summary>
/// EN: Handler for SendMessageCommand.
/// VI: Handler cho SendMessageCommand.
/// </summary>
public class SendMessageCommandHandler : IRequestHandler<SendMessageCommand, SendMessageResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly ILogger<SendMessageCommandHandler> _logger;
public SendMessageCommandHandler(
IConversationRepository conversationRepository,
ILogger<SendMessageCommandHandler> logger)
{
_conversationRepository = conversationRepository;
_logger = logger;
}
public async Task<SendMessageResult> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Sending message from {SenderId} to conversation {ConversationId}",
request.SenderId, request.ConversationId);
var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken);
if (conversation == null)
{
throw new InvalidOperationException($"Conversation {request.ConversationId} not found");
}
// EN: Parse message type
// VI: Parse message type
var messageType = MessageType.FromName(request.MessageType);
if (messageType == null)
{
messageType = MessageType.Text;
}
// EN: Send encrypted message via Conversation aggregate
// VI: Gửi tin nhắn đã mã hóa qua Conversation aggregate
var message = conversation.SendMessage(
request.SenderId,
request.EncryptedContent,
request.Nonce,
messageType,
request.AuthTag,
request.Metadata,
request.ReplyToMessageId);
_conversationRepository.Update(conversation);
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Message {MessageId} sent to conversation {ConversationId}",
message.Id, conversation.Id);
return new SendMessageResult(
message.Id,
conversation.Id,
request.SenderId,
message.Status.Name,
message.CreatedAt);
}
}

View File

@@ -0,0 +1,48 @@
using MediatR;
namespace ChatService.API.Application.Queries.Conversations;
/// <summary>
/// EN: Query to get user's conversations.
/// VI: Query để lấy các cuộc hội thoại của user.
/// </summary>
public record GetConversationsQuery(
Guid UserId,
int Page = 1,
int PageSize = 20
) : IRequest<GetConversationsResult>;
public record GetConversationsResult(
IEnumerable<ConversationDto> Conversations,
int TotalCount,
int Page,
int PageSize
);
public record ConversationDto(
Guid Id,
string Type,
string? Name,
string? AvatarUrl,
IEnumerable<ConversationParticipantDto> Participants,
LastMessageDto? LastMessage,
int UnreadCount,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record ConversationParticipantDto(
Guid ChatUserId,
string DisplayName,
string? AvatarUrl,
string Role,
DateTime? LastReadAt
);
public record LastMessageDto(
Guid MessageId,
Guid SenderId,
string SenderName,
string Type,
DateTime CreatedAt
);

View File

@@ -0,0 +1,102 @@
using MediatR;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Queries.Conversations;
/// <summary>
/// EN: Handler for GetConversationsQuery.
/// VI: Handler cho GetConversationsQuery.
/// </summary>
public class GetConversationsQueryHandler : IRequestHandler<GetConversationsQuery, GetConversationsResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<GetConversationsQueryHandler> _logger;
public GetConversationsQueryHandler(
IConversationRepository conversationRepository,
IChatUserRepository chatUserRepository,
ILogger<GetConversationsQueryHandler> logger)
{
_conversationRepository = conversationRepository;
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<GetConversationsResult> Handle(GetConversationsQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation("Getting conversations for user {UserId}, page {Page}", request.UserId, request.Page);
var (conversations, totalCount) = await _conversationRepository.GetConversationsForUserAsync(
request.UserId, request.Page, request.PageSize, cancellationToken);
var conversationList = conversations.ToList();
var conversationDtos = new List<ConversationDto>();
foreach (var conversation in conversationList)
{
// EN: Get participant user info
// VI: Lấy thông tin user của participants
var participantIds = conversation.Participants.Select(p => p.UserId).ToList();
var users = await _chatUserRepository.GetByIdsAsync(participantIds, cancellationToken);
var userDict = users.ToDictionary(u => u.Id);
var participantDtos = conversation.Participants.Select(p => new ConversationParticipantDto(
p.UserId,
userDict.TryGetValue(p.UserId, out var user) ? user.DisplayName : "Unknown",
userDict.TryGetValue(p.UserId, out var u) ? u.AvatarUrl : null,
p.IsAdmin ? "admin" : "member",
p.LastReadAt
)).ToList();
// EN: Get unread count
// VI: Lấy số tin chưa đọc
var unreadCount = await _conversationRepository.GetUnreadMessageCountAsync(
conversation.Id, request.UserId, cancellationToken);
// EN: Get last message info
// VI: Lấy thông tin tin nhắn cuối
LastMessageDto? lastMessage = null;
if (conversation.LastMessageId.HasValue)
{
var messages = await _conversationRepository.GetMessagesAsync(
conversation.Id, 0, 1, null, cancellationToken);
var lastMsg = messages.FirstOrDefault();
if (lastMsg != null)
{
var senderName = userDict.TryGetValue(lastMsg.SenderId, out var sender)
? sender.DisplayName
: "Unknown";
lastMessage = new LastMessageDto(
lastMsg.Id,
lastMsg.SenderId,
senderName,
lastMsg.Type.Name,
lastMsg.CreatedAt
);
}
}
conversationDtos.Add(new ConversationDto(
conversation.Id,
conversation.Type.Name,
conversation.Name,
conversation.AvatarUrl,
participantDtos,
lastMessage,
unreadCount,
conversation.CreatedAt,
conversation.UpdatedAt
));
}
return new GetConversationsResult(
conversationDtos,
totalCount,
request.Page,
request.PageSize
);
}
}

View File

@@ -0,0 +1,23 @@
using MediatR;
namespace ChatService.API.Application.Queries.Keys;
/// <summary>
/// EN: Query to get current user's own key bundle status.
/// VI: Query để lấy trạng thái key bundle của user hiện tại.
/// </summary>
public record GetMyKeyBundleQuery(
string IdentityUserId
) : IRequest<MyKeyBundleDto?>;
/// <summary>
/// EN: User's own key bundle status.
/// VI: Trạng thái key bundle của user.
/// </summary>
public record MyKeyBundleDto(
Guid ChatUserId,
bool HasKeyBundle,
DateTime? SignedPreKeyTimestamp,
bool NeedsKeyRotation,
int AvailableOneTimeKeys
);

View File

@@ -0,0 +1,45 @@
using MediatR;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Queries.Keys;
/// <summary>
/// EN: Handler for GetMyKeyBundleQuery.
/// VI: Handler cho GetMyKeyBundleQuery.
/// </summary>
public class GetMyKeyBundleQueryHandler : IRequestHandler<GetMyKeyBundleQuery, MyKeyBundleDto?>
{
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<GetMyKeyBundleQueryHandler> _logger;
public GetMyKeyBundleQueryHandler(
IChatUserRepository chatUserRepository,
ILogger<GetMyKeyBundleQueryHandler> logger)
{
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<MyKeyBundleDto?> Handle(GetMyKeyBundleQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation("Getting my key bundle for {IdentityUserId}", request.IdentityUserId);
var user = await _chatUserRepository.GetByIdentityUserIdAsync(request.IdentityUserId, cancellationToken);
if (user == null)
{
_logger.LogInformation("Chat user not found for {IdentityUserId}", request.IdentityUserId);
return null;
}
var availableKeys = await _chatUserRepository.GetAvailablePreKeyCountAsync(user.Id, cancellationToken);
return new MyKeyBundleDto(
user.Id,
user.KeyBundle != null,
user.KeyBundle?.SignedPreKeyTimestamp,
user.KeyBundle?.NeedsRotation() ?? true,
availableKeys
);
}
}

View File

@@ -0,0 +1,29 @@
using MediatR;
namespace ChatService.API.Application.Queries.Keys;
/// <summary>
/// EN: Query to get a user's key bundle for initiating E2EE session.
/// VI: Query để lấy key bundle của user để khởi tạo E2EE session.
/// </summary>
public record GetUserKeyBundleQuery(
Guid TargetUserId
) : IRequest<UserKeyBundleDto?>;
/// <summary>
/// EN: Key bundle DTO returned for key exchange.
/// VI: Key bundle DTO trả về cho key exchange.
/// </summary>
public record UserKeyBundleDto(
Guid UserId,
string IdentityPublicKey,
string SignedPreKey,
string SignedPreKeySignature,
DateTime SignedPreKeyTimestamp,
OneTimePreKeyResultDto? OneTimePreKey
);
public record OneTimePreKeyResultDto(
int KeyId,
string PublicKey
);

View File

@@ -0,0 +1,58 @@
using MediatR;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Queries.Keys;
/// <summary>
/// EN: Handler for GetUserKeyBundleQuery.
/// VI: Handler cho GetUserKeyBundleQuery.
/// </summary>
public class GetUserKeyBundleQueryHandler : IRequestHandler<GetUserKeyBundleQuery, UserKeyBundleDto?>
{
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<GetUserKeyBundleQueryHandler> _logger;
public GetUserKeyBundleQueryHandler(
IChatUserRepository chatUserRepository,
ILogger<GetUserKeyBundleQueryHandler> logger)
{
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<UserKeyBundleDto?> Handle(GetUserKeyBundleQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation("Getting key bundle for user {UserId}", request.TargetUserId);
// EN: Get key bundle and consume one-time pre-key
// VI: Lấy key bundle và consume one-time pre-key
var (identityPublicKey, signedPreKey, signedPreKeySignature, oneTimePreKey) =
await _chatUserRepository.GetKeyBundleAsync(request.TargetUserId, cancellationToken);
if (identityPublicKey == null || signedPreKey == null || signedPreKeySignature == null)
{
_logger.LogWarning("Key bundle not found for user {UserId}", request.TargetUserId);
return null;
}
// EN: Save the consumed one-time pre-key
// VI: Lưu one-time pre-key đã consume
await _chatUserRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Key bundle retrieved for user {UserId}, one-time key consumed: {Consumed}",
request.TargetUserId, oneTimePreKey != null);
var user = await _chatUserRepository.GetByIdAsync(request.TargetUserId, cancellationToken);
return new UserKeyBundleDto(
request.TargetUserId,
identityPublicKey,
signedPreKey,
signedPreKeySignature,
user?.KeyBundle?.SignedPreKeyTimestamp ?? DateTime.UtcNow,
oneTimePreKey != null
? new OneTimePreKeyResultDto(oneTimePreKey.KeyId, oneTimePreKey.PublicKey)
: null
);
}
}

View File

@@ -0,0 +1,40 @@
using MediatR;
namespace ChatService.API.Application.Queries.Messages;
/// <summary>
/// EN: Query to get messages in a conversation.
/// VI: Query để lấy tin nhắn trong cuộc hội thoại.
/// </summary>
public record GetMessagesQuery(
Guid ConversationId,
Guid UserId,
int Page = 1,
int PageSize = 50,
DateTime? Before = null
) : IRequest<GetMessagesResult>;
public record GetMessagesResult(
IEnumerable<MessageDto> Messages,
int TotalCount,
int Page,
int PageSize,
bool HasMore
);
public record MessageDto(
Guid Id,
Guid ConversationId,
Guid SenderId,
string SenderName,
string EncryptedContent,
string Nonce,
string? AuthTag,
string Type,
string Status,
string? Metadata,
Guid? ReplyToMessageId,
DateTime CreatedAt,
DateTime? DeliveredAt,
DateTime? ReadAt
);

View File

@@ -0,0 +1,81 @@
using MediatR;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.API.Application.Queries.Messages;
/// <summary>
/// EN: Handler for GetMessagesQuery.
/// VI: Handler cho GetMessagesQuery.
/// </summary>
public class GetMessagesQueryHandler : IRequestHandler<GetMessagesQuery, GetMessagesResult>
{
private readonly IConversationRepository _conversationRepository;
private readonly IChatUserRepository _chatUserRepository;
private readonly ILogger<GetMessagesQueryHandler> _logger;
public GetMessagesQueryHandler(
IConversationRepository conversationRepository,
IChatUserRepository chatUserRepository,
ILogger<GetMessagesQueryHandler> logger)
{
_conversationRepository = conversationRepository;
_chatUserRepository = chatUserRepository;
_logger = logger;
}
public async Task<GetMessagesResult> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation("Getting messages for conversation {ConversationId}, user {UserId}",
request.ConversationId, request.UserId);
// EN: Verify user is participant
// VI: Xác thực user là participant
var isParticipant = await _conversationRepository.IsUserParticipantAsync(
request.ConversationId, request.UserId, cancellationToken);
if (!isParticipant)
{
throw new UnauthorizedAccessException("User is not a participant in this conversation");
}
var (messages, totalCount) = await _conversationRepository.GetMessagesPaginatedAsync(
request.ConversationId, request.Page, request.PageSize, request.Before, cancellationToken);
var messageList = messages.ToList();
// EN: Get sender info for all messages
// VI: Lấy thông tin người gửi cho tất cả tin nhắn
var senderIds = messageList.Select(m => m.SenderId).Distinct().ToList();
var users = await _chatUserRepository.GetByIdsAsync(senderIds, cancellationToken);
var userDict = users.ToDictionary(u => u.Id);
var messageDtos = messageList.Select(m => new MessageDto(
m.Id,
m.ConversationId,
m.SenderId,
userDict.TryGetValue(m.SenderId, out var user) ? user.DisplayName : "Unknown",
m.EncryptedContent,
m.Nonce,
m.AuthTag,
m.Type.Name,
m.Status.Name,
m.Metadata,
m.ReplyToMessageId,
m.CreatedAt,
m.DeliveredAt,
m.ReadAt
)).ToList();
var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize);
var hasMore = request.Page < totalPages;
return new GetMessagesResult(
messageDtos,
totalCount,
request.Page,
request.PageSize,
hasMore
);
}
}

View File

@@ -0,0 +1,158 @@
using FluentValidation;
using ChatService.API.Application.Commands.Keys;
namespace ChatService.API.Application.Validations.Keys;
/// <summary>
/// EN: Validator for RegisterUserKeysCommand.
/// VI: Validator cho RegisterUserKeysCommand.
/// </summary>
public class RegisterUserKeysCommandValidator : AbstractValidator<RegisterUserKeysCommand>
{
public RegisterUserKeysCommandValidator()
{
RuleFor(x => x.IdentityUserId)
.NotEmpty()
.WithMessage("Identity user ID is required");
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(255)
.WithMessage("Display name must not exceed 255 characters");
RuleFor(x => x.IdentityPublicKey)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("Identity public key must be valid Base64");
RuleFor(x => x.SignedPreKey)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("Signed pre-key must be valid Base64");
RuleFor(x => x.SignedPreKeySignature)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("Signed pre-key signature must be valid Base64");
RuleForEach(x => x.OneTimePreKeys)
.ChildRules(preKey =>
{
preKey.RuleFor(k => k.KeyId)
.GreaterThanOrEqualTo(0)
.WithMessage("Key ID must be non-negative");
preKey.RuleFor(k => k.PublicKey)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("Public key must be valid Base64");
});
}
private static bool BeValidBase64(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
Convert.FromBase64String(value);
return true;
}
catch
{
return false;
}
}
}
/// <summary>
/// EN: Validator for RotatePreKeyCommand.
/// VI: Validator cho RotatePreKeyCommand.
/// </summary>
public class RotatePreKeyCommandValidator : AbstractValidator<RotatePreKeyCommand>
{
public RotatePreKeyCommandValidator()
{
RuleFor(x => x.ChatUserId)
.NotEmpty()
.WithMessage("Chat user ID is required");
RuleFor(x => x.NewSignedPreKey)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("New signed pre-key must be valid Base64");
RuleFor(x => x.NewSignedPreKeySignature)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("New signed pre-key signature must be valid Base64");
}
private static bool BeValidBase64(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
Convert.FromBase64String(value);
return true;
}
catch
{
return false;
}
}
}
/// <summary>
/// EN: Validator for UploadOneTimeKeysCommand.
/// VI: Validator cho UploadOneTimeKeysCommand.
/// </summary>
public class UploadOneTimeKeysCommandValidator : AbstractValidator<UploadOneTimeKeysCommand>
{
public UploadOneTimeKeysCommandValidator()
{
RuleFor(x => x.ChatUserId)
.NotEmpty()
.WithMessage("Chat user ID is required");
RuleFor(x => x.OneTimePreKeys)
.NotEmpty()
.WithMessage("At least one pre-key is required");
RuleFor(x => x.OneTimePreKeys.Count())
.LessThanOrEqualTo(100)
.WithMessage("Cannot upload more than 100 pre-keys at once");
RuleForEach(x => x.OneTimePreKeys)
.ChildRules(preKey =>
{
preKey.RuleFor(k => k.KeyId)
.GreaterThanOrEqualTo(0)
.WithMessage("Key ID must be non-negative");
preKey.RuleFor(k => k.PublicKey)
.NotEmpty()
.Must(BeValidBase64)
.WithMessage("Public key must be valid Base64");
});
}
private static bool BeValidBase64(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
try
{
Convert.FromBase64String(value);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,108 @@
using FluentValidation;
using ChatService.API.Application.Commands.Conversations;
using ChatService.API.Application.Commands.Messages;
namespace ChatService.API.Application.Validations.Messaging;
/// <summary>
/// EN: Validator for CreateConversationCommand.
/// VI: Validator cho CreateConversationCommand.
/// </summary>
public class CreateConversationCommandValidator : AbstractValidator<CreateConversationCommand>
{
public CreateConversationCommandValidator()
{
RuleFor(x => x.CreatorId)
.NotEmpty().WithMessage("CreatorId is required");
RuleFor(x => x.ParticipantIds)
.NotEmpty().WithMessage("At least one participant is required")
.Must(x => x.Distinct().Count() == x.Count())
.WithMessage("Participant IDs must be unique");
When(x => x.IsGroup, () =>
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Group name is required for group conversations")
.MaximumLength(100).WithMessage("Group name must be at most 100 characters");
RuleFor(x => x.ParticipantIds)
.Must(x => x.Count() >= 1)
.WithMessage("Group conversation must have at least 1 participant besides the creator");
});
When(x => !x.IsGroup, () =>
{
RuleFor(x => x.ParticipantIds)
.Must(x => x.Count() == 1)
.WithMessage("Direct conversation must have exactly 1 participant besides the creator");
});
RuleFor(x => x.AvatarUrl)
.MaximumLength(500).WithMessage("Avatar URL must be at most 500 characters")
.When(x => !string.IsNullOrEmpty(x.AvatarUrl));
}
}
/// <summary>
/// EN: Validator for SendMessageCommand.
/// VI: Validator cho SendMessageCommand.
/// </summary>
public class SendMessageCommandValidator : AbstractValidator<SendMessageCommand>
{
private static readonly HashSet<string> ValidMessageTypes = new()
{
"text", "image", "video", "audio", "file", "location", "contact", "sticker", "system"
};
public SendMessageCommandValidator()
{
RuleFor(x => x.ConversationId)
.NotEmpty().WithMessage("ConversationId is required");
RuleFor(x => x.SenderId)
.NotEmpty().WithMessage("SenderId is required");
RuleFor(x => x.EncryptedContent)
.NotEmpty().WithMessage("EncryptedContent is required")
.MaximumLength(100000).WithMessage("EncryptedContent must be at most 100000 characters");
RuleFor(x => x.Nonce)
.NotEmpty().WithMessage("Nonce is required")
.MaximumLength(100).WithMessage("Nonce must be at most 100 characters");
RuleFor(x => x.AuthTag)
.MaximumLength(100).WithMessage("AuthTag must be at most 100 characters")
.When(x => !string.IsNullOrEmpty(x.AuthTag));
RuleFor(x => x.MessageType)
.Must(type => ValidMessageTypes.Contains(type.ToLowerInvariant()))
.WithMessage($"MessageType must be one of: {string.Join(", ", ValidMessageTypes)}");
RuleFor(x => x.Metadata)
.MaximumLength(10000).WithMessage("Metadata must be at most 10000 characters")
.When(x => !string.IsNullOrEmpty(x.Metadata));
}
}
/// <summary>
/// EN: Validator for MarkMessagesReadCommand.
/// VI: Validator cho MarkMessagesReadCommand.
/// </summary>
public class MarkMessagesReadCommandValidator : AbstractValidator<MarkMessagesReadCommand>
{
public MarkMessagesReadCommandValidator()
{
RuleFor(x => x.ConversationId)
.NotEmpty().WithMessage("ConversationId is required");
RuleFor(x => x.UserId)
.NotEmpty().WithMessage("UserId is required");
// EN: At least one of LastReadMessageId or ReadUpTo should be provided, or both can be null (mark all)
// VI: Ít nhất một trong LastReadMessageId hoặc ReadUpTo nên được cung cấp, hoặc cả hai có thể null (đánh dấu tất cả)
RuleFor(x => x)
.Must(x => true) // EN: Always valid - all scenarios are handled / VI: Luôn hợp lệ - tất cả scenarios được xử lý
.WithMessage("Either LastReadMessageId, ReadUpTo, or neither (to mark all) should be provided");
}
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>ChatService.API</AssemblyName>
<RootNamespace>ChatService.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" />
<!-- EN: SignalR for real-time communication / VI: SignalR cho real-time communication -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ChatService.Domain\ChatService.Domain.csproj" />
<ProjectReference Include="..\ChatService.Infrastructure\ChatService.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using ChatService.API.Application.Commands.Conversations;
using ChatService.API.Application.Queries.Conversations;
namespace ChatService.API.Controllers;
/// <summary>
/// EN: Controller for conversation management.
/// VI: Controller để quản lý cuộc hội thoại.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ConversationsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ConversationsController> _logger;
public ConversationsController(IMediator mediator, ILogger<ConversationsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Create a new conversation.
/// VI: Tạo cuộc hội thoại mới.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(CreateConversationResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateConversation([FromBody] CreateConversationRequest request)
{
var command = new CreateConversationCommand(
request.CreatorId,
request.ParticipantIds,
request.Name,
request.AvatarUrl,
request.IsGroup);
try
{
var result = await _mediator.Send(command);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// EN: Get user's conversations.
/// VI: Lấy các cuộc hội thoại của user.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(GetConversationsResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetConversations(
[FromQuery] Guid userId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var query = new GetConversationsQuery(userId, page, pageSize);
var result = await _mediator.Send(query);
return Ok(result);
}
/// <summary>
/// EN: Get a specific conversation by ID.
/// VI: Lấy cuộc hội thoại theo ID.
/// </summary>
[HttpGet("{conversationId:guid}")]
[ProducesResponseType(typeof(ConversationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetConversation(Guid conversationId, [FromQuery] Guid userId)
{
var query = new GetConversationsQuery(userId, 1, 1);
var result = await _mediator.Send(query);
var conversation = result.Conversations.FirstOrDefault(c => c.Id == conversationId);
if (conversation == null)
{
return NotFound($"Conversation {conversationId} not found");
}
return Ok(conversation);
}
}
#region Request DTOs
public record CreateConversationRequest(
Guid CreatorId,
List<Guid> ParticipantIds,
string? Name = null,
string? AvatarUrl = null,
bool IsGroup = false
);
#endregion

View File

@@ -0,0 +1,187 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using ChatService.API.Application.Commands.Keys;
using ChatService.API.Application.Queries.Keys;
namespace ChatService.API.Controllers;
/// <summary>
/// EN: Controller for E2EE key management.
/// VI: Controller để quản lý E2EE keys.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class KeysController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<KeysController> _logger;
public KeysController(IMediator mediator, ILogger<KeysController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Register or update user's E2EE key bundle.
/// VI: Đăng ký hoặc cập nhật E2EE key bundle của user.
/// </summary>
[HttpPost("register")]
[ProducesResponseType(typeof(RegisterUserKeysResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> RegisterKeys([FromBody] RegisterUserKeysRequest request)
{
var identityUserId = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(identityUserId))
{
return Unauthorized("User ID not found in token");
}
var command = new RegisterUserKeysCommand(
identityUserId,
request.DisplayName,
request.AvatarUrl,
request.IdentityPublicKey,
request.SignedPreKey,
request.SignedPreKeySignature,
request.OneTimePreKeys?.Select(k => new OneTimePreKeyDto(k.KeyId, k.PublicKey))
);
var result = await _mediator.Send(command);
return Ok(result);
}
/// <summary>
/// EN: Rotate the signed pre-key.
/// VI: Xoay vòng signed pre-key.
/// </summary>
[HttpPost("rotate")]
[ProducesResponseType(typeof(RotatePreKeyResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RotatePreKey([FromBody] RotatePreKeyRequest request)
{
var command = new RotatePreKeyCommand(
request.ChatUserId,
request.NewSignedPreKey,
request.NewSignedPreKeySignature
);
try
{
var result = await _mediator.Send(command);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
/// <summary>
/// EN: Upload one-time pre-keys.
/// VI: Upload one-time pre-keys.
/// </summary>
[HttpPost("prekeys")]
[ProducesResponseType(typeof(UploadOneTimeKeysResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UploadPreKeys([FromBody] UploadPreKeysRequest request)
{
var command = new UploadOneTimeKeysCommand(
request.ChatUserId,
request.OneTimePreKeys.Select(k => new OneTimePreKeyDto(k.KeyId, k.PublicKey))
);
try
{
var result = await _mediator.Send(command);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
/// <summary>
/// EN: Get a user's key bundle for initiating E2EE session.
/// VI: Lấy key bundle của user để khởi tạo E2EE session.
/// </summary>
[HttpGet("bundle/{userId:guid}")]
[ProducesResponseType(typeof(UserKeyBundleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUserKeyBundle(Guid userId)
{
var query = new GetUserKeyBundleQuery(userId);
var result = await _mediator.Send(query);
if (result == null)
{
return NotFound($"Key bundle not found for user {userId}");
}
return Ok(result);
}
/// <summary>
/// EN: Get current user's own key bundle status.
/// VI: Lấy trạng thái key bundle của user hiện tại.
/// </summary>
[HttpGet("my-bundle")]
[ProducesResponseType(typeof(MyKeyBundleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMyKeyBundle()
{
var identityUserId = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(identityUserId))
{
return Unauthorized("User ID not found in token");
}
var query = new GetMyKeyBundleQuery(identityUserId);
var result = await _mediator.Send(query);
if (result == null)
{
return NotFound("Key bundle not registered. Please register your keys first.");
}
return Ok(result);
}
}
#region Request DTOs
public record RegisterUserKeysRequest(
string DisplayName,
string? AvatarUrl,
string IdentityPublicKey,
string SignedPreKey,
string SignedPreKeySignature,
List<PreKeyRequest>? OneTimePreKeys = null
);
public record RotatePreKeyRequest(
Guid ChatUserId,
string NewSignedPreKey,
string NewSignedPreKeySignature
);
public record UploadPreKeysRequest(
Guid ChatUserId,
List<PreKeyRequest> OneTimePreKeys
);
public record PreKeyRequest(
int KeyId,
string PublicKey
);
#endregion

View File

@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using ChatService.API.Application.Commands.Messages;
using ChatService.API.Application.Queries.Messages;
namespace ChatService.API.Controllers;
/// <summary>
/// EN: Controller for message management.
/// VI: Controller để quản lý tin nhắn.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class MessagesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<MessagesController> _logger;
public MessagesController(IMediator mediator, ILogger<MessagesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Send an encrypted message.
/// VI: Gửi tin nhắn đã mã hóa.
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(SendMessageResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SendMessage([FromBody] SendMessageRequest request)
{
var command = new SendMessageCommand(
request.ConversationId,
request.SenderId,
request.EncryptedContent,
request.Nonce,
request.AuthTag,
request.MessageType,
request.Metadata,
request.ReplyToMessageId);
try
{
var result = await _mediator.Send(command);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
/// <summary>
/// EN: Get messages in a conversation.
/// VI: Lấy tin nhắn trong cuộc hội thoại.
/// </summary>
[HttpGet("conversation/{conversationId:guid}")]
[ProducesResponseType(typeof(GetMessagesResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetMessages(
Guid conversationId,
[FromQuery] Guid userId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] DateTime? before = null)
{
var query = new GetMessagesQuery(conversationId, userId, page, pageSize, before);
try
{
var result = await _mediator.Send(query);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
return Forbid(ex.Message);
}
}
/// <summary>
/// EN: Mark messages as read.
/// VI: Đánh dấu tin nhắn đã đọc.
/// </summary>
[HttpPost("read")]
[ProducesResponseType(typeof(MarkMessagesReadResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> MarkAsRead([FromBody] MarkReadRequest request)
{
var command = new MarkMessagesReadCommand(
request.ConversationId,
request.UserId,
request.LastReadMessageId,
request.ReadUpTo);
try
{
var result = await _mediator.Send(command);
return Ok(result);
}
catch (InvalidOperationException ex)
{
return NotFound(ex.Message);
}
}
}
#region Request DTOs
public record SendMessageRequest(
Guid ConversationId,
Guid SenderId,
string EncryptedContent,
string Nonce,
string? AuthTag = null,
string MessageType = "text",
string? Metadata = null,
Guid? ReplyToMessageId = null
);
public record MarkReadRequest(
Guid ConversationId,
Guid UserId,
Guid? LastReadMessageId = null,
DateTime? ReadUpTo = null
);
#endregion

View File

@@ -0,0 +1,144 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using ChatService.API.Application.Behaviors;
using ChatService.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 ChatService API / Khởi động ChatService 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 = "ChatService API",
Version = "v1",
Description = "ChatService microservice API / API microservice ChatService"
});
});
// 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", "ChatService 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,300 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.Events;
using ChatService.Domain.Exceptions;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Conversation aggregate root - represents a chat conversation.
/// VI: Conversation aggregate root - đại diện một cuộc hội thoại chat.
/// </summary>
public class Conversation : Entity, IAggregateRoot
{
#pragma warning disable CS0169 // Field is used by EF Core
private int _typeId;
#pragma warning restore CS0169
private ConversationType _type;
private string? _name;
private string? _avatarUrl;
private readonly List<ConversationParticipant> _participants;
private readonly List<Message> _messages;
private DateTime _createdAt;
private DateTime _updatedAt;
private Guid? _lastMessageId;
private DateTime? _lastMessageAt;
/// <summary>
/// EN: Type of conversation (Direct or Group).
/// VI: Loại cuộc hội thoại (Direct hoặc Group).
/// </summary>
public ConversationType Type => _type;
/// <summary>
/// EN: Conversation name (optional for direct, required for group).
/// VI: Tên cuộc hội thoại (tùy chọn cho direct, bắt buộc cho group).
/// </summary>
public string? Name => _name;
/// <summary>
/// EN: Conversation avatar URL (for group chats).
/// VI: URL avatar cuộc hội thoại (cho group chats).
/// </summary>
public string? AvatarUrl => _avatarUrl;
/// <summary>
/// EN: Participants in this conversation.
/// VI: Các thành viên trong cuộc hội thoại này.
/// </summary>
public IReadOnlyCollection<ConversationParticipant> Participants => _participants.AsReadOnly();
/// <summary>
/// EN: Messages in this conversation (should be loaded explicitly).
/// VI: Các tin nhắn trong cuộc hội thoại này (nên load rõ ràng).
/// </summary>
public IReadOnlyCollection<Message> Messages => _messages.AsReadOnly();
/// <summary>
/// EN: ID of the last message in this conversation.
/// VI: ID của tin nhắn cuối cùng trong cuộc hội thoại này.
/// </summary>
public Guid? LastMessageId => _lastMessageId;
/// <summary>
/// EN: Timestamp of the last message.
/// VI: Thời điểm của tin nhắn cuối cùng.
/// </summary>
public DateTime? LastMessageAt => _lastMessageAt;
public DateTime CreatedAt => _createdAt;
public DateTime UpdatedAt => _updatedAt;
protected Conversation()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
_type = ConversationType.Direct;
_participants = new List<ConversationParticipant>();
_messages = new List<Message>();
}
/// <summary>
/// EN: Create a direct (1:1) conversation.
/// VI: Tạo cuộc hội thoại trực tiếp (1:1).
/// </summary>
public static Conversation CreateDirect(Guid user1Id, Guid user2Id)
{
if (user1Id == user2Id)
throw new ChatDomainException("Cannot create conversation with yourself");
var conversation = new Conversation
{
Id = Guid.NewGuid(),
_type = ConversationType.Direct,
_createdAt = DateTime.UtcNow,
_updatedAt = DateTime.UtcNow
};
conversation._participants.Add(new ConversationParticipant(conversation.Id, user1Id));
conversation._participants.Add(new ConversationParticipant(conversation.Id, user2Id));
conversation.AddDomainEvent(new ConversationCreatedDomainEvent(conversation));
return conversation;
}
/// <summary>
/// EN: Create a group conversation.
/// VI: Tạo cuộc hội thoại nhóm.
/// </summary>
public static Conversation CreateGroup(string name, Guid creatorId, IEnumerable<Guid> participantIds, string? avatarUrl = null)
{
if (string.IsNullOrWhiteSpace(name))
throw new ChatDomainException("Group name is required");
var allParticipants = participantIds.Append(creatorId).Distinct().ToList();
if (allParticipants.Count < 2)
throw new ChatDomainException("Group must have at least 2 participants");
var conversation = new Conversation
{
Id = Guid.NewGuid(),
_type = ConversationType.Group,
_name = name,
_avatarUrl = avatarUrl,
_createdAt = DateTime.UtcNow,
_updatedAt = DateTime.UtcNow
};
// EN: Creator is admin
// VI: Creator là admin
conversation._participants.Add(new ConversationParticipant(conversation.Id, creatorId, isAdmin: true));
foreach (var participantId in allParticipants.Where(p => p != creatorId))
{
conversation._participants.Add(new ConversationParticipant(conversation.Id, participantId));
}
conversation.AddDomainEvent(new ConversationCreatedDomainEvent(conversation));
return conversation;
}
/// <summary>
/// EN: Send a new message.
/// VI: Gửi một tin nhắn mới.
/// </summary>
public Message SendMessage(
Guid senderId,
string encryptedContent,
string nonce,
MessageType? type = null,
string? authTag = null,
string? metadata = null,
Guid? replyToMessageId = null)
{
// EN: Validate sender is a participant
// VI: Xác thực người gửi là thành viên
var participant = _participants.FirstOrDefault(p => p.UserId == senderId && p.IsActive);
if (participant == null)
throw new ChatDomainException("Sender is not a participant of this conversation");
var message = new Message(
Id,
senderId,
encryptedContent,
nonce,
type,
authTag,
metadata,
replyToMessageId);
_messages.Add(message);
_lastMessageId = message.Id;
_lastMessageAt = message.CreatedAt;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new MessageSentDomainEvent(message, this));
return message;
}
/// <summary>
/// EN: Add a participant to group conversation.
/// VI: Thêm thành viên vào cuộc hội thoại nhóm.
/// </summary>
public void AddParticipant(Guid userId, Guid addedByUserId)
{
if (_type == ConversationType.Direct)
throw new ChatDomainException("Cannot add participants to direct conversation");
// EN: Check if adder is admin
// VI: Kiểm tra người thêm có phải admin không
var adder = _participants.FirstOrDefault(p => p.UserId == addedByUserId && p.IsActive);
if (adder == null || !adder.IsAdmin)
throw new ChatDomainException("Only admins can add participants");
// EN: Check if user already exists
// VI: Kiểm tra user đã tồn tại chưa
var existing = _participants.FirstOrDefault(p => p.UserId == userId);
if (existing != null && existing.IsActive)
throw new ChatDomainException("User is already a participant");
_participants.Add(new ConversationParticipant(Id, userId));
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Remove a participant from group conversation.
/// VI: Xóa thành viên khỏi cuộc hội thoại nhóm.
/// </summary>
public void RemoveParticipant(Guid userId, Guid removedByUserId)
{
if (_type == ConversationType.Direct)
throw new ChatDomainException("Cannot remove participants from direct conversation");
var participant = _participants.FirstOrDefault(p => p.UserId == userId && p.IsActive);
if (participant == null)
throw new ChatDomainException("User is not a participant");
// EN: User can remove themselves, or admin can remove others
// VI: User có thể tự rời, hoặc admin có thể xóa người khác
if (userId != removedByUserId)
{
var remover = _participants.FirstOrDefault(p => p.UserId == removedByUserId && p.IsActive);
if (remover == null || !remover.IsAdmin)
throw new ChatDomainException("Only admins can remove other participants");
}
participant.Leave();
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update group name.
/// VI: Cập nhật tên nhóm.
/// </summary>
public void UpdateName(string name, Guid updatedByUserId)
{
if (_type == ConversationType.Direct)
throw new ChatDomainException("Cannot update name of direct conversation");
var updater = _participants.FirstOrDefault(p => p.UserId == updatedByUserId && p.IsActive);
if (updater == null || !updater.IsAdmin)
throw new ChatDomainException("Only admins can update group name");
if (string.IsNullOrWhiteSpace(name))
throw new ChatDomainException("Group name is required");
_name = name;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update group avatar.
/// VI: Cập nhật avatar nhóm.
/// </summary>
public void UpdateAvatar(string? avatarUrl, Guid updatedByUserId)
{
if (_type == ConversationType.Direct)
throw new ChatDomainException("Cannot update avatar of direct conversation");
var updater = _participants.FirstOrDefault(p => p.UserId == updatedByUserId && p.IsActive);
if (updater == null || !updater.IsAdmin)
throw new ChatDomainException("Only admins can update group avatar");
_avatarUrl = avatarUrl;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Get active participant count.
/// VI: Lấy số lượng thành viên đang hoạt động.
/// </summary>
public int GetActiveParticipantCount()
{
return _participants.Count(p => p.IsActive);
}
/// <summary>
/// EN: Check if user is a participant.
/// VI: Kiểm tra user có phải thành viên không.
/// </summary>
public bool IsParticipant(Guid userId)
{
return _participants.Any(p => p.UserId == userId && p.IsActive);
}
/// <summary>
/// EN: Get the other participant in a direct conversation.
/// VI: Lấy thành viên kia trong cuộc hội thoại trực tiếp.
/// </summary>
public Guid? GetOtherParticipant(Guid userId)
{
if (_type != ConversationType.Direct)
return null;
return _participants
.FirstOrDefault(p => p.UserId != userId && p.IsActive)
?.UserId;
}
}

View File

@@ -0,0 +1,117 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Participant in a conversation.
/// VI: Thành viên trong cuộc hội thoại.
/// </summary>
public class ConversationParticipant : Entity
{
/// <summary>
/// EN: The conversation this participant belongs to.
/// VI: Cuộc hội thoại mà thành viên này thuộc về.
/// </summary>
public Guid ConversationId { get; private set; }
/// <summary>
/// EN: The chat user ID of this participant.
/// VI: Chat user ID của thành viên này.
/// </summary>
public Guid UserId { get; private set; }
/// <summary>
/// EN: When the user joined this conversation.
/// VI: Thời điểm user tham gia cuộc hội thoại.
/// </summary>
public DateTime JoinedAt { get; private set; }
/// <summary>
/// EN: Last time this user read messages in this conversation.
/// VI: Lần cuối user đọc tin nhắn trong cuộc hội thoại này.
/// </summary>
public DateTime? LastReadAt { get; private set; }
/// <summary>
/// EN: ID of the last message read by this user.
/// VI: ID của tin nhắn cuối cùng user đã đọc.
/// </summary>
public Guid? LastReadMessageId { get; private set; }
/// <summary>
/// EN: Whether this participant is an admin (for group chats).
/// VI: Thành viên này có phải admin không (cho group chats).
/// </summary>
public bool IsAdmin { get; private set; }
/// <summary>
/// EN: Whether notifications are muted for this user.
/// VI: Thông báo có bị tắt cho user này không.
/// </summary>
public bool IsMuted { get; private set; }
/// <summary>
/// EN: When the user left this conversation (null if still active).
/// VI: Thời điểm user rời khỏi cuộc hội thoại (null nếu còn hoạt động).
/// </summary>
public DateTime? LeftAt { get; private set; }
protected ConversationParticipant()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
}
public ConversationParticipant(Guid conversationId, Guid userId, bool isAdmin = false)
{
Id = Guid.NewGuid();
ConversationId = conversationId;
UserId = userId;
IsAdmin = isAdmin;
IsMuted = false;
JoinedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update last read information.
/// VI: Cập nhật thông tin đã đọc cuối cùng.
/// </summary>
public void UpdateLastRead(Guid messageId)
{
LastReadMessageId = messageId;
LastReadAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Toggle mute status.
/// VI: Bật/tắt trạng thái mute.
/// </summary>
public void SetMuted(bool muted)
{
IsMuted = muted;
}
/// <summary>
/// EN: Set admin status.
/// VI: Đặt trạng thái admin.
/// </summary>
public void SetAdmin(bool isAdmin)
{
IsAdmin = isAdmin;
}
/// <summary>
/// EN: Mark participant as left.
/// VI: Đánh dấu thành viên đã rời đi.
/// </summary>
public void Leave()
{
LeftAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Check if participant is still active.
/// VI: Kiểm tra thành viên còn hoạt động không.
/// </summary>
public bool IsActive => LeftAt == null;
}

View File

@@ -0,0 +1,54 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Conversation type enumeration.
/// VI: Enumeration loại cuộc hội thoại.
/// </summary>
public class ConversationType : Enumeration
{
/// <summary>
/// EN: Direct 1:1 conversation between two users.
/// VI: Hội thoại trực tiếp 1:1 giữa hai users.
/// </summary>
public static readonly ConversationType Direct = new(1, nameof(Direct).ToLowerInvariant());
/// <summary>
/// EN: Group conversation with multiple users.
/// VI: Hội thoại nhóm với nhiều users.
/// </summary>
public static readonly ConversationType Group = new(2, nameof(Group).ToLowerInvariant());
public ConversationType(int id, string name) : base(id, name)
{
}
public static IEnumerable<ConversationType> List() =>
new[] { Direct, Group };
public static ConversationType FromName(string name)
{
var state = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (state == null)
{
throw new ArgumentException($"Possible values for ConversationType: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
public static ConversationType From(int id)
{
var state = List().SingleOrDefault(s => s.Id == id);
if (state == null)
{
throw new ArgumentException($"Possible values for ConversationType: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
}

View File

@@ -0,0 +1,112 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Repository interface for Conversation aggregate.
/// VI: Interface repository cho Conversation aggregate.
/// </summary>
public interface IConversationRepository : IRepository<Conversation>
{
/// <summary>
/// EN: Get conversation by ID with participants.
/// VI: Lấy conversation theo ID với danh sách thành viên.
/// </summary>
Task<Conversation?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get conversation by ID with participants and recent messages.
/// VI: Lấy conversation theo ID với thành viên và tin nhắn gần đây.
/// </summary>
Task<Conversation?> GetWithMessagesAsync(Guid id, int messageCount = 50, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all conversations for a user.
/// VI: Lấy tất cả conversations của một user.
/// </summary>
Task<IEnumerable<Conversation>> GetUserConversationsAsync(
Guid userId,
int skip = 0,
int take = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Find existing direct conversation between two users.
/// VI: Tìm conversation trực tiếp đã tồn tại giữa hai users.
/// </summary>
Task<Conversation?> FindDirectConversationAsync(Guid user1Id, Guid user2Id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get messages for a conversation with pagination.
/// VI: Lấy tin nhắn cho một conversation với phân trang.
/// </summary>
Task<IEnumerable<Message>> GetMessagesAsync(
Guid conversationId,
int skip = 0,
int take = 50,
DateTime? before = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get unread message count for a user in a conversation.
/// VI: Lấy số tin nhắn chưa đọc cho user trong một conversation.
/// </summary>
Task<int> GetUnreadCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get total unread count across all conversations for a user.
/// VI: Lấy tổng số tin chưa đọc trên tất cả conversations cho một user.
/// </summary>
Task<int> GetTotalUnreadCountAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new conversation.
/// VI: Thêm một conversation mới.
/// </summary>
Conversation Add(Conversation conversation);
/// <summary>
/// EN: Update an existing conversation.
/// VI: Cập nhật một conversation.
/// </summary>
void Update(Conversation conversation);
/// <summary>
/// EN: Get conversations for user with pagination and total count.
/// VI: Lấy conversations cho user với phân trang và tổng số.
/// </summary>
Task<(IEnumerable<Conversation> Conversations, int TotalCount)> GetConversationsForUserAsync(
Guid userId,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get messages for a conversation with pagination and total count.
/// VI: Lấy tin nhắn với phân trang và tổng số.
/// </summary>
Task<(IEnumerable<Message> Messages, int TotalCount)> GetMessagesPaginatedAsync(
Guid conversationId,
int page = 1,
int pageSize = 50,
DateTime? before = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get unread message count for a user in a conversation.
/// VI: Lấy số tin nhắn chưa đọc cho user trong một conversation.
/// </summary>
Task<int> GetUnreadMessageCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user is participant in conversation.
/// VI: Kiểm tra user có phải participant trong conversation.
/// </summary>
Task<bool> IsUserParticipantAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get conversation by ID with all messages.
/// VI: Lấy conversation theo ID với tất cả messages.
/// </summary>
Task<Conversation?> GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,193 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.Exceptions;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Message entity - stores encrypted message content.
/// VI: Message entity - lưu trữ nội dung tin nhắn đã mã hóa.
/// </summary>
/// <remarks>
/// EN: The server stores ONLY encrypted content - cannot decrypt without client's private key.
/// VI: Server CHỈ lưu nội dung đã mã hóa - không thể giải mã mà không có private key của client.
/// </remarks>
public class Message : Entity
{
private Guid _conversationId;
private Guid _senderId;
private string _encryptedContent;
private string _nonce;
private string? _authTag;
#pragma warning disable CS0169 // Fields are used by EF Core
private int _typeId;
#pragma warning restore CS0169
private MessageType _type;
#pragma warning disable CS0169 // Fields are used by EF Core
private int _statusId;
#pragma warning restore CS0169
private MessageStatus _status;
private DateTime _createdAt;
private DateTime? _updatedAt;
private DateTime? _deliveredAt;
private DateTime? _readAt;
private string? _metadata;
private Guid? _replyToMessageId;
/// <summary>
/// EN: The conversation this message belongs to.
/// VI: Cuộc hội thoại mà tin nhắn này thuộc về.
/// </summary>
public Guid ConversationId => _conversationId;
/// <summary>
/// EN: The sender's chat user ID.
/// VI: Chat user ID của người gửi.
/// </summary>
public Guid SenderId => _senderId;
/// <summary>
/// EN: Encrypted message content (Base64 encoded).
/// VI: Nội dung tin nhắn đã mã hóa (mã hóa Base64).
/// </summary>
/// <remarks>
/// EN: Encrypted using AES-256-GCM with session key derived from X3DH.
/// VI: Mã hóa bằng AES-256-GCM với session key được derive từ X3DH.
/// </remarks>
public string EncryptedContent => _encryptedContent;
/// <summary>
/// EN: Nonce/IV for AES-GCM decryption (Base64 encoded).
/// VI: Nonce/IV cho giải mã AES-GCM (mã hóa Base64).
/// </summary>
public string Nonce => _nonce;
/// <summary>
/// EN: Authentication tag for AES-GCM (Base64 encoded).
/// VI: Authentication tag cho AES-GCM (mã hóa Base64).
/// </summary>
public string? AuthTag => _authTag;
/// <summary>
/// EN: Type of message content.
/// VI: Loại nội dung tin nhắn.
/// </summary>
public MessageType Type => _type;
/// <summary>
/// EN: Current status of the message.
/// VI: Trạng thái hiện tại của tin nhắn.
/// </summary>
public MessageStatus Status => _status;
/// <summary>
/// EN: When the message was sent.
/// VI: Thời điểm tin nhắn được gửi.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: When the message was last updated.
/// VI: Thời điểm tin nhắn được cập nhật lần cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: When the message was delivered.
/// VI: Thời điểm tin nhắn được gửi đến.
/// </summary>
public DateTime? DeliveredAt => _deliveredAt;
/// <summary>
/// EN: When the message was read.
/// VI: Thời điểm tin nhắn được đọc.
/// </summary>
public DateTime? ReadAt => _readAt;
/// <summary>
/// EN: Optional metadata (e.g., file name, size for file messages).
/// VI: Metadata tùy chọn (vd: tên file, kích thước cho tin nhắn file).
/// </summary>
public string? Metadata => _metadata;
/// <summary>
/// EN: ID of the message this is replying to (if any).
/// VI: ID của tin nhắn đang trả lời (nếu có).
/// </summary>
public Guid? ReplyToMessageId => _replyToMessageId;
protected Message()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
_encryptedContent = string.Empty;
_nonce = string.Empty;
_type = MessageType.Text;
_status = MessageStatus.Sent;
}
public Message(
Guid conversationId,
Guid senderId,
string encryptedContent,
string nonce,
MessageType? type = null,
string? authTag = null,
string? metadata = null,
Guid? replyToMessageId = null)
{
if (string.IsNullOrWhiteSpace(encryptedContent))
throw new ChatDomainException("Encrypted content is required");
if (string.IsNullOrWhiteSpace(nonce))
throw new ChatDomainException("Nonce is required for decryption");
Id = Guid.NewGuid();
_conversationId = conversationId;
_senderId = senderId;
_encryptedContent = encryptedContent;
_nonce = nonce;
_authTag = authTag;
_type = type ?? MessageType.Text;
_status = MessageStatus.Sent;
_metadata = metadata;
_replyToMessageId = replyToMessageId;
_createdAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Mark message as delivered.
/// VI: Đánh dấu tin nhắn đã được gửi đến.
/// </summary>
public void MarkAsDelivered()
{
if (_status == MessageStatus.Read)
return; // EN: Already read, no need to mark delivered / VI: Đã đọc, không cần đánh dấu đã gửi
_status = MessageStatus.Delivered;
_deliveredAt = DateTime.UtcNow;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Mark message as read.
/// VI: Đánh dấu tin nhắn đã được đọc.
/// </summary>
public void MarkAsRead()
{
_status = MessageStatus.Read;
_readAt = DateTime.UtcNow;
_updatedAt = DateTime.UtcNow;
if (_deliveredAt == null)
_deliveredAt = _readAt;
}
/// <summary>
/// EN: Mark message as failed.
/// VI: Đánh dấu tin nhắn gửi thất bại.
/// </summary>
public void MarkAsFailed()
{
_status = MessageStatus.Failed;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,66 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Message status enumeration.
/// VI: Enumeration trạng thái tin nhắn.
/// </summary>
public class MessageStatus : Enumeration
{
/// <summary>
/// EN: Message has been sent by sender.
/// VI: Tin nhắn đã được gửi bởi người gửi.
/// </summary>
public static readonly MessageStatus Sent = new(1, nameof(Sent).ToLowerInvariant());
/// <summary>
/// EN: Message has been delivered to recipient's device.
/// VI: Tin nhắn đã được gửi đến thiết bị người nhận.
/// </summary>
public static readonly MessageStatus Delivered = new(2, nameof(Delivered).ToLowerInvariant());
/// <summary>
/// EN: Message has been read by recipient.
/// VI: Tin nhắn đã được đọc bởi người nhận.
/// </summary>
public static readonly MessageStatus Read = new(3, nameof(Read).ToLowerInvariant());
/// <summary>
/// EN: Message sending failed.
/// VI: Gửi tin nhắn thất bại.
/// </summary>
public static readonly MessageStatus Failed = new(4, nameof(Failed).ToLowerInvariant());
public MessageStatus(int id, string name) : base(id, name)
{
}
public static IEnumerable<MessageStatus> List() =>
new[] { Sent, Delivered, Read, Failed };
public static MessageStatus FromName(string name)
{
var state = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (state == null)
{
throw new ArgumentException($"Possible values for MessageStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
public static MessageStatus From(int id)
{
var state = List().SingleOrDefault(s => s.Id == id);
if (state == null)
{
throw new ArgumentException($"Possible values for MessageStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
}

View File

@@ -0,0 +1,72 @@
namespace ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Message type enumeration.
/// VI: Enumeration loại tin nhắn.
/// </summary>
public class MessageType : Enumeration
{
/// <summary>
/// EN: Text message.
/// VI: Tin nhắn văn bản.
/// </summary>
public static readonly MessageType Text = new(1, nameof(Text).ToLowerInvariant());
/// <summary>
/// EN: Image message.
/// VI: Tin nhắn hình ảnh.
/// </summary>
public static readonly MessageType Image = new(2, nameof(Image).ToLowerInvariant());
/// <summary>
/// EN: File attachment message.
/// VI: Tin nhắn file đính kèm.
/// </summary>
public static readonly MessageType File = new(3, nameof(File).ToLowerInvariant());
/// <summary>
/// EN: Voice message.
/// VI: Tin nhắn thoại.
/// </summary>
public static readonly MessageType Voice = new(4, nameof(Voice).ToLowerInvariant());
/// <summary>
/// EN: System message (e.g., user joined).
/// VI: Tin nhắn hệ thống (vd: user tham gia).
/// </summary>
public static readonly MessageType System = new(5, nameof(System).ToLowerInvariant());
public MessageType(int id, string name) : base(id, name)
{
}
public static IEnumerable<MessageType> List() =>
new[] { Text, Image, File, Voice, System };
public static MessageType FromName(string name)
{
var state = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (state == null)
{
throw new ArgumentException($"Possible values for MessageType: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
public static MessageType From(int id)
{
var state = List().SingleOrDefault(s => s.Id == id);
if (state == null)
{
throw new ArgumentException($"Possible values for MessageType: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
}

View File

@@ -0,0 +1,226 @@
namespace ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.Events;
using ChatService.Domain.Exceptions;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Chat user aggregate root - represents a user in the chat system.
/// VI: Chat user aggregate root - đại diện một user trong hệ thống chat.
/// </summary>
/// <remarks>
/// EN: Links to IAM Service user via IdentityUserId. Contains E2EE public keys.
/// VI: Liên kết với IAM Service user qua IdentityUserId. Chứa E2EE public keys.
/// </remarks>
public class ChatUser : Entity, IAggregateRoot
{
private string _identityUserId;
private string _displayName;
private string? _avatarUrl;
#pragma warning disable CS0169 // Field is used by EF Core
private int _statusId;
#pragma warning restore CS0169
private UserStatus _status;
private UserKeyBundle? _keyBundle;
private readonly List<OneTimePreKey> _oneTimePreKeys;
private DateTime _lastSeenAt;
private DateTime _createdAt;
private DateTime _updatedAt;
/// <summary>
/// EN: Reference to IAM Service user ID.
/// VI: Tham chiếu đến IAM Service user ID.
/// </summary>
public string IdentityUserId => _identityUserId;
/// <summary>
/// EN: Display name shown in chat.
/// VI: Tên hiển thị trong chat.
/// </summary>
public string DisplayName => _displayName;
/// <summary>
/// EN: URL to user's avatar image.
/// VI: URL ảnh avatar của user.
/// </summary>
public string? AvatarUrl => _avatarUrl;
/// <summary>
/// EN: Current online status.
/// VI: Trạng thái online hiện tại.
/// </summary>
public UserStatus Status => _status;
/// <summary>
/// EN: E2EE public key bundle.
/// VI: Bundle E2EE public keys.
/// </summary>
public UserKeyBundle? KeyBundle => _keyBundle;
/// <summary>
/// EN: Available one-time pre-keys for X3DH.
/// VI: Các one-time pre-keys khả dụng cho X3DH.
/// </summary>
public IReadOnlyCollection<OneTimePreKey> OneTimePreKeys => _oneTimePreKeys.AsReadOnly();
/// <summary>
/// EN: Last time user was active.
/// VI: Lần cuối user hoạt động.
/// </summary>
public DateTime LastSeenAt => _lastSeenAt;
public DateTime CreatedAt => _createdAt;
public DateTime UpdatedAt => _updatedAt;
protected ChatUser()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
_identityUserId = string.Empty;
_displayName = string.Empty;
_status = UserStatus.Offline;
_oneTimePreKeys = new List<OneTimePreKey>();
}
public ChatUser(string identityUserId, string displayName, string? avatarUrl = null) : this()
{
if (string.IsNullOrWhiteSpace(identityUserId))
throw new ChatDomainException("Identity user ID is required");
if (string.IsNullOrWhiteSpace(displayName))
throw new ChatDomainException("Display name is required");
Id = Guid.NewGuid();
_identityUserId = identityUserId;
_displayName = displayName;
_avatarUrl = avatarUrl;
_status = UserStatus.Offline;
_lastSeenAt = DateTime.UtcNow;
_createdAt = DateTime.UtcNow;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new ChatUserCreatedDomainEvent(this));
}
/// <summary>
/// EN: Register user's E2EE key bundle.
/// VI: Đăng ký E2EE key bundle của user.
/// </summary>
public void RegisterKeyBundle(
string identityPublicKey,
string signedPreKey,
string signedPreKeySignature)
{
_keyBundle = new UserKeyBundle(
identityPublicKey,
signedPreKey,
signedPreKeySignature);
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new UserKeyBundleUpdatedDomainEvent(this));
}
/// <summary>
/// EN: Rotate signed pre-key.
/// VI: Xoay vòng signed pre-key.
/// </summary>
public void RotateSignedPreKey(string newSignedPreKey, string newSignature)
{
if (_keyBundle == null)
throw new ChatDomainException("Cannot rotate pre-key: no key bundle registered");
_keyBundle = _keyBundle.RotateSignedPreKey(newSignedPreKey, newSignature);
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new UserKeyBundleUpdatedDomainEvent(this));
}
/// <summary>
/// EN: Upload one-time pre-keys.
/// VI: Upload các one-time pre-keys.
/// </summary>
public void UploadOneTimePreKeys(IEnumerable<(int keyId, string publicKey)> keys)
{
foreach (var (keyId, publicKey) in keys)
{
// EN: Check for duplicate key IDs
// VI: Kiểm tra trùng lặp key ID
if (_oneTimePreKeys.Any(k => k.KeyId == keyId && !k.IsUsed))
continue;
var preKey = new OneTimePreKey(Id, keyId, publicKey);
_oneTimePreKeys.Add(preKey);
}
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Consume a one-time pre-key (for key exchange).
/// VI: Sử dụng một one-time pre-key (cho key exchange).
/// </summary>
public OneTimePreKey? ConsumeOneTimePreKey()
{
var availableKey = _oneTimePreKeys.FirstOrDefault(k => !k.IsUsed);
if (availableKey != null)
{
availableKey.MarkAsUsed();
_updatedAt = DateTime.UtcNow;
}
return availableKey;
}
/// <summary>
/// EN: Get count of available one-time pre-keys.
/// VI: Lấy số lượng one-time pre-keys khả dụng.
/// </summary>
public int GetAvailableOneTimePreKeyCount()
{
return _oneTimePreKeys.Count(k => !k.IsUsed);
}
/// <summary>
/// EN: Update display name.
/// VI: Cập nhật tên hiển thị.
/// </summary>
public void UpdateDisplayName(string displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
throw new ChatDomainException("Display name is required");
_displayName = displayName;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update avatar URL.
/// VI: Cập nhật URL avatar.
/// </summary>
public void UpdateAvatarUrl(string? avatarUrl)
{
_avatarUrl = avatarUrl;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Set online status.
/// VI: Đặt trạng thái online.
/// </summary>
public void SetOnlineStatus(UserStatus status)
{
_status = status;
_lastSeenAt = DateTime.UtcNow;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update last seen timestamp.
/// VI: Cập nhật thời gian hoạt động cuối.
/// </summary>
public void UpdateLastSeen()
{
_lastSeenAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,65 @@
namespace ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: Repository interface for ChatUser aggregate.
/// VI: Interface repository cho ChatUser aggregate.
/// </summary>
public interface IChatUserRepository : IRepository<ChatUser>
{
/// <summary>
/// EN: Get user by their IAM Service identity ID.
/// VI: Lấy user theo IAM Service identity ID.
/// </summary>
Task<ChatUser?> GetByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user by their chat user ID.
/// VI: Lấy user theo chat user ID.
/// </summary>
Task<ChatUser?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user with their key bundle and one-time pre-keys.
/// VI: Lấy user với key bundle và one-time pre-keys.
/// </summary>
Task<ChatUser?> GetWithKeysAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get multiple users by their IDs.
/// VI: Lấy nhiều users theo danh sách ID.
/// </summary>
Task<IEnumerable<ChatUser>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user exists by identity ID.
/// VI: Kiểm tra user tồn tại theo identity ID.
/// </summary>
Task<bool> ExistsByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new chat user.
/// VI: Thêm một chat user mới.
/// </summary>
ChatUser Add(ChatUser user);
/// <summary>
/// EN: Update an existing chat user.
/// VI: Cập nhật một chat user.
/// </summary>
void Update(ChatUser user);
/// <summary>
/// EN: Get key bundle for X3DH key exchange and consume one-time pre-key.
/// VI: Lấy key bundle cho X3DH key exchange và consume one-time pre-key.
/// </summary>
Task<(string? IdentityPublicKey, string? SignedPreKey, string? SignedPreKeySignature, OneTimePreKey? OneTimePreKey)>
GetKeyBundleAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get count of available one-time pre-keys.
/// VI: Lấy số lượng one-time pre-keys còn khả dụng.
/// </summary>
Task<int> GetAvailablePreKeyCountAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,79 @@
namespace ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: One-time pre-key for X3DH protocol - consumed after first use.
/// VI: One-time pre-key cho X3DH protocol - được xóa sau khi sử dụng.
/// </summary>
public class OneTimePreKey : Entity
{
/// <summary>
/// EN: Key identifier used by client to reference this key.
/// VI: Mã định danh key được client sử dụng để tham chiếu.
/// </summary>
public int KeyId { get; private set; }
/// <summary>
/// EN: The public key (Curve25519).
/// VI: Public key (Curve25519).
/// </summary>
public string PublicKey { get; private set; }
/// <summary>
/// EN: Whether this key has been consumed.
/// VI: Key này đã được sử dụng chưa.
/// </summary>
public bool IsUsed { get; private set; }
/// <summary>
/// EN: When this key was created.
/// VI: Thời điểm key được tạo.
/// </summary>
public DateTime CreatedAt { get; private set; }
/// <summary>
/// EN: When this key was consumed (if applicable).
/// VI: Thời điểm key được sử dụng (nếu có).
/// </summary>
public DateTime? UsedAt { get; private set; }
/// <summary>
/// EN: The user who owns this key.
/// VI: User sở hữu key này.
/// </summary>
public Guid UserId { get; private set; }
protected OneTimePreKey()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
PublicKey = string.Empty;
}
public OneTimePreKey(Guid userId, int keyId, string publicKey)
{
if (string.IsNullOrWhiteSpace(publicKey))
throw new ArgumentException("Public key is required", nameof(publicKey));
Id = Guid.NewGuid();
UserId = userId;
KeyId = keyId;
PublicKey = publicKey;
IsUsed = false;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Mark this key as consumed.
/// VI: Đánh dấu key này đã được sử dụng.
/// </summary>
public void MarkAsUsed()
{
if (IsUsed)
throw new InvalidOperationException("One-time pre-key has already been used");
IsUsed = true;
UsedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,96 @@
namespace ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: User's public key bundle for E2EE key exchange (X3DH protocol).
/// VI: Bundle public keys của user cho E2EE key exchange (X3DH protocol).
/// </summary>
/// <remarks>
/// EN: Contains only PUBLIC keys - private keys are stored client-side only.
/// VI: Chỉ chứa PUBLIC keys - private keys được lưu trữ ở client.
/// </remarks>
public class UserKeyBundle : ValueObject
{
/// <summary>
/// EN: Long-term identity public key (Curve25519).
/// VI: Public key định danh lâu dài (Curve25519).
/// </summary>
public string IdentityPublicKey { get; private set; }
/// <summary>
/// EN: Signed pre-key (rotated periodically, e.g., every 30 days).
/// VI: Signed pre-key (xoay vòng định kỳ, vd: mỗi 30 ngày).
/// </summary>
public string SignedPreKey { get; private set; }
/// <summary>
/// EN: Signature of the signed pre-key using identity private key.
/// VI: Chữ ký của signed pre-key sử dụng identity private key.
/// </summary>
public string SignedPreKeySignature { get; private set; }
/// <summary>
/// EN: Timestamp when signed pre-key was generated.
/// VI: Thời điểm signed pre-key được tạo.
/// </summary>
public DateTime SignedPreKeyTimestamp { get; private set; }
protected UserKeyBundle()
{
// EN: Required by EF Core
// VI: Yêu cầu bởi EF Core
IdentityPublicKey = string.Empty;
SignedPreKey = string.Empty;
SignedPreKeySignature = string.Empty;
}
public UserKeyBundle(
string identityPublicKey,
string signedPreKey,
string signedPreKeySignature,
DateTime? signedPreKeyTimestamp = null)
{
if (string.IsNullOrWhiteSpace(identityPublicKey))
throw new ArgumentException("Identity public key is required", nameof(identityPublicKey));
if (string.IsNullOrWhiteSpace(signedPreKey))
throw new ArgumentException("Signed pre-key is required", nameof(signedPreKey));
if (string.IsNullOrWhiteSpace(signedPreKeySignature))
throw new ArgumentException("Signed pre-key signature is required", nameof(signedPreKeySignature));
IdentityPublicKey = identityPublicKey;
SignedPreKey = signedPreKey;
SignedPreKeySignature = signedPreKeySignature;
SignedPreKeyTimestamp = signedPreKeyTimestamp ?? DateTime.UtcNow;
}
/// <summary>
/// EN: Update signed pre-key (key rotation).
/// VI: Cập nhật signed pre-key (xoay vòng key).
/// </summary>
public UserKeyBundle RotateSignedPreKey(string newSignedPreKey, string newSignature)
{
return new UserKeyBundle(
IdentityPublicKey,
newSignedPreKey,
newSignature,
DateTime.UtcNow);
}
/// <summary>
/// EN: Check if signed pre-key needs rotation (older than 30 days).
/// VI: Kiểm tra xem signed pre-key có cần xoay vòng không (cũ hơn 30 ngày).
/// </summary>
public bool NeedsRotation(int maxAgeDays = 30)
{
return (DateTime.UtcNow - SignedPreKeyTimestamp).TotalDays > maxAgeDays;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return IdentityPublicKey;
yield return SignedPreKey;
yield return SignedPreKeySignature;
yield return SignedPreKeyTimestamp;
}
}

View File

@@ -0,0 +1,47 @@
namespace ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.SeedWork;
/// <summary>
/// EN: User online status enumeration.
/// VI: Enumeration trạng thái online của user.
/// </summary>
public class UserStatus : Enumeration
{
public static readonly UserStatus Offline = new(1, nameof(Offline).ToLowerInvariant());
public static readonly UserStatus Online = new(2, nameof(Online).ToLowerInvariant());
public static readonly UserStatus Away = new(3, nameof(Away).ToLowerInvariant());
public static readonly UserStatus DoNotDisturb = new(4, nameof(DoNotDisturb).ToLowerInvariant());
public UserStatus(int id, string name) : base(id, name)
{
}
public static IEnumerable<UserStatus> List() =>
new[] { Offline, Online, Away, DoNotDisturb };
public static UserStatus FromName(string name)
{
var state = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (state == null)
{
throw new ArgumentException($"Possible values for UserStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
public static UserStatus From(int id)
{
var state = List().SingleOrDefault(s => s.Id == id);
if (state == null)
{
throw new ArgumentException($"Possible values for UserStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return state;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>ChatService.Domain</AssemblyName>
<RootNamespace>ChatService.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,74 @@
namespace ChatService.Domain.Events;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using MediatR;
/// <summary>
/// EN: Domain event raised when a new conversation is created.
/// VI: Domain event được phát ra khi một conversation mới được tạo.
/// </summary>
public class ConversationCreatedDomainEvent : INotification
{
public Conversation Conversation { get; }
public ConversationCreatedDomainEvent(Conversation conversation)
{
Conversation = conversation;
}
}
/// <summary>
/// EN: Domain event raised when a message is sent.
/// VI: Domain event được phát ra khi một tin nhắn được gửi.
/// </summary>
public class MessageSentDomainEvent : INotification
{
public Message Message { get; }
public Conversation Conversation { get; }
public MessageSentDomainEvent(Message message, Conversation conversation)
{
Message = message;
Conversation = conversation;
}
}
/// <summary>
/// EN: Domain event raised when a message is delivered.
/// VI: Domain event được phát ra khi tin nhắn được gửi đến.
/// </summary>
public class MessageDeliveredDomainEvent : INotification
{
public Guid MessageId { get; }
public Guid ConversationId { get; }
public Guid RecipientId { get; }
public DateTime DeliveredAt { get; }
public MessageDeliveredDomainEvent(Guid messageId, Guid conversationId, Guid recipientId)
{
MessageId = messageId;
ConversationId = conversationId;
RecipientId = recipientId;
DeliveredAt = DateTime.UtcNow;
}
}
/// <summary>
/// EN: Domain event raised when a message is read.
/// VI: Domain event được phát ra khi tin nhắn được đọc.
/// </summary>
public class MessageReadDomainEvent : INotification
{
public Guid MessageId { get; }
public Guid ConversationId { get; }
public Guid ReaderId { get; }
public DateTime ReadAt { get; }
public MessageReadDomainEvent(Guid messageId, Guid conversationId, Guid readerId)
{
MessageId = messageId;
ConversationId = conversationId;
ReaderId = readerId;
ReadAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,32 @@
namespace ChatService.Domain.Events;
using ChatService.Domain.AggregatesModel.UserAggregate;
using MediatR;
/// <summary>
/// EN: Domain event raised when a new chat user is created.
/// VI: Domain event được phát ra khi một chat user mới được tạo.
/// </summary>
public class ChatUserCreatedDomainEvent : INotification
{
public ChatUser User { get; }
public ChatUserCreatedDomainEvent(ChatUser user)
{
User = user;
}
}
/// <summary>
/// EN: Domain event raised when user's key bundle is updated.
/// VI: Domain event được phát ra khi key bundle của user được cập nhật.
/// </summary>
public class UserKeyBundleUpdatedDomainEvent : INotification
{
public ChatUser User { get; }
public UserKeyBundleUpdatedDomainEvent(ChatUser user)
{
User = user;
}
}

View File

@@ -0,0 +1,22 @@
namespace ChatService.Domain.Exceptions;
/// <summary>
/// EN: Base exception for chat domain errors.
/// VI: Exception cơ sở cho các lỗi trong chat domain.
/// </summary>
public class ChatDomainException : Exception
{
public ChatDomainException()
{
}
public ChatDomainException(string message)
: base(message)
{
}
public ChatDomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,21 @@
namespace ChatService.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,102 @@
using MediatR;
namespace ChatService.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 ChatService.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 ChatService.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 ChatService.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 ChatService.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 ChatService.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,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>ChatService.Infrastructure</AssemblyName>
<RootNamespace>ChatService.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="..\ChatService.Domain\ChatService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,200 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
using ChatService.Infrastructure.EntityConfigurations;
namespace ChatService.Infrastructure;
/// <summary>
/// EN: EF Core DbContext for ChatService.
/// VI: EF Core DbContext cho ChatService.
/// </summary>
public class ChatServiceContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
#region DbSets
/// <summary>
/// EN: Chat users table.
/// VI: Bảng chat users.
/// </summary>
public DbSet<ChatUser> ChatUsers => Set<ChatUser>();
/// <summary>
/// EN: One-time pre-keys table.
/// VI: Bảng one-time pre-keys.
/// </summary>
public DbSet<OneTimePreKey> OneTimePreKeys => Set<OneTimePreKey>();
/// <summary>
/// EN: Conversations table.
/// VI: Bảng conversations.
/// </summary>
public DbSet<Conversation> Conversations => Set<Conversation>();
/// <summary>
/// EN: Conversation participants table.
/// VI: Bảng thành viên cuộc hội thoại.
/// </summary>
public DbSet<ConversationParticipant> ConversationParticipants => Set<ConversationParticipant>();
/// <summary>
/// EN: Messages table.
/// VI: Bảng tin nhắn.
/// </summary>
public DbSet<Message> Messages => Set<Message>();
#endregion
/// <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 ChatServiceContext(DbContextOptions<ChatServiceContext> options) : base(options)
{
_mediator = null!;
}
public ChatServiceContext(DbContextOptions<ChatServiceContext> options, IMediator mediator) : base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
System.Diagnostics.Debug.WriteLine("ChatServiceContext::ctor - " + GetHashCode());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
// User Aggregate
modelBuilder.ApplyConfiguration(new ChatUserEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new OneTimePreKeyEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new UserStatusEntityTypeConfiguration());
// Conversation Aggregate
modelBuilder.ApplyConfiguration(new ConversationEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ConversationParticipantEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new ConversationTypeEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new MessageStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new MessageTypeEntityTypeConfiguration());
}
/// <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,59 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Infrastructure.Idempotency;
using ChatService.Infrastructure.Repositories;
namespace ChatService.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<ChatServiceContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
?? throw new InvalidOperationException("Connection string not configured");
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(ChatServiceContext).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<IChatUserRepository, ChatUserRepository>();
services.AddScoped<IConversationRepository, ConversationRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();
return services;
}
}

View File

@@ -0,0 +1,302 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
namespace ChatService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for Conversation aggregate root.
/// VI: Cấu hình entity cho Conversation aggregate root.
/// </summary>
public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration<Conversation>
{
public void Configure(EntityTypeBuilder<Conversation> builder)
{
builder.ToTable("conversations");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(c => c.Name)
.HasColumnName("name")
.HasMaxLength(256);
builder.Property(c => c.AvatarUrl)
.HasColumnName("avatar_url")
.HasMaxLength(2048);
builder.Property(c => c.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(c => c.UpdatedAt)
.HasColumnName("updated_at")
.IsRequired();
builder.Property(c => c.LastMessageId)
.HasColumnName("last_message_id");
builder.Property(c => c.LastMessageAt)
.HasColumnName("last_message_at");
// EN: Configure Type as navigation to Enumeration
// VI: Cấu hình Type như navigation đến Enumeration
builder.Property<int>("_typeId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("type_id")
.IsRequired();
builder.HasOne(c => c.Type)
.WithMany()
.HasForeignKey("_typeId");
// EN: Configure Participants collection
// VI: Cấu hình collection Participants
var participantsNav = builder.Metadata.FindNavigation(nameof(Conversation.Participants));
participantsNav?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(c => c.Participants)
.WithOne()
.HasForeignKey(p => p.ConversationId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Configure Messages collection
// VI: Cấu hình collection Messages
var messagesNav = builder.Metadata.FindNavigation(nameof(Conversation.Messages));
messagesNav?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(c => c.Messages)
.WithOne()
.HasForeignKey(m => m.ConversationId)
.OnDelete(DeleteBehavior.Cascade);
// EN: Index for ordering conversations
// VI: Index để sắp xếp conversations
builder.HasIndex(c => c.LastMessageAt);
}
}
/// <summary>
/// EN: Entity configuration for ConversationParticipant entity.
/// VI: Cấu hình entity cho ConversationParticipant entity.
/// </summary>
public class ConversationParticipantEntityTypeConfiguration : IEntityTypeConfiguration<ConversationParticipant>
{
public void Configure(EntityTypeBuilder<ConversationParticipant> builder)
{
builder.ToTable("conversation_participants");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(p => p.ConversationId)
.HasColumnName("conversation_id")
.IsRequired();
builder.Property(p => p.UserId)
.HasColumnName("user_id")
.IsRequired();
builder.Property(p => p.JoinedAt)
.HasColumnName("joined_at")
.IsRequired();
builder.Property(p => p.LastReadAt)
.HasColumnName("last_read_at");
builder.Property(p => p.IsAdmin)
.HasColumnName("is_admin")
.IsRequired();
builder.Property(p => p.IsMuted)
.HasColumnName("is_muted")
.IsRequired();
builder.Property(p => p.LeftAt)
.HasColumnName("left_at");
// EN: Unique index for user per conversation
// VI: Index unique cho user trong mỗi conversation
builder.HasIndex(p => new { p.ConversationId, p.UserId });
// EN: Index for finding user's conversations
// VI: Index để tìm conversations của user
builder.HasIndex(p => p.UserId);
}
}
/// <summary>
/// EN: Entity configuration for Message entity.
/// VI: Cấu hình entity cho Message entity.
/// </summary>
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
{
public void Configure(EntityTypeBuilder<Message> builder)
{
builder.ToTable("messages");
builder.HasKey(m => m.Id);
builder.Property(m => m.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(m => m.ConversationId)
.HasColumnName("conversation_id")
.IsRequired();
builder.Property(m => m.SenderId)
.HasColumnName("sender_id")
.IsRequired();
// EN: Encrypted content - server cannot read this
// VI: Nội dung đã mã hóa - server không thể đọc được
builder.Property(m => m.EncryptedContent)
.HasColumnName("encrypted_content")
.IsRequired();
builder.Property(m => m.Nonce)
.HasColumnName("nonce")
.HasMaxLength(256)
.IsRequired();
builder.Property(m => m.AuthTag)
.HasColumnName("auth_tag")
.HasMaxLength(256);
builder.Property(m => m.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(m => m.UpdatedAt)
.HasColumnName("updated_at");
builder.Property(m => m.Metadata)
.HasColumnName("metadata");
builder.Property(m => m.ReplyToMessageId)
.HasColumnName("reply_to_message_id");
// EN: Configure Type as navigation to Enumeration
// VI: Cấu hình Type như navigation đến Enumeration
builder.Property<int>("_typeId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("type_id")
.IsRequired();
builder.HasOne(m => m.Type)
.WithMany()
.HasForeignKey("_typeId");
// EN: Configure Status as navigation to Enumeration
// VI: Cấu hình Status như navigation đến Enumeration
builder.Property<int>("_statusId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("status_id")
.IsRequired();
builder.HasOne(m => m.Status)
.WithMany()
.HasForeignKey("_statusId");
// EN: Indexes for querying messages
// VI: Indexes để query messages
builder.HasIndex(m => new { m.ConversationId, m.CreatedAt });
builder.HasIndex(m => m.SenderId);
}
}
/// <summary>
/// EN: Entity configuration for ConversationType enumeration.
/// VI: Cấu hình entity cho ConversationType enumeration.
/// </summary>
public class ConversationTypeEntityTypeConfiguration : IEntityTypeConfiguration<ConversationType>
{
public void Configure(EntityTypeBuilder<ConversationType> builder)
{
builder.ToTable("conversation_types");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(t => t.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.HasData(
ConversationType.Direct,
ConversationType.Group
);
}
}
/// <summary>
/// EN: Entity configuration for MessageStatus enumeration.
/// VI: Cấu hình entity cho MessageStatus enumeration.
/// </summary>
public class MessageStatusEntityTypeConfiguration : IEntityTypeConfiguration<MessageStatus>
{
public void Configure(EntityTypeBuilder<MessageStatus> builder)
{
builder.ToTable("message_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.HasData(
MessageStatus.Sent,
MessageStatus.Delivered,
MessageStatus.Read,
MessageStatus.Failed
);
}
}
/// <summary>
/// EN: Entity configuration for MessageType enumeration.
/// VI: Cấu hình entity cho MessageType enumeration.
/// </summary>
public class MessageTypeEntityTypeConfiguration : IEntityTypeConfiguration<MessageType>
{
public void Configure(EntityTypeBuilder<MessageType> builder)
{
builder.ToTable("message_types");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(t => t.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.HasData(
MessageType.Text,
MessageType.Image,
MessageType.File,
MessageType.Voice,
MessageType.System
);
}
}

View File

@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ChatService.Domain.AggregatesModel.UserAggregate;
namespace ChatService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for ChatUser aggregate root.
/// VI: Cấu hình entity cho ChatUser aggregate root.
/// </summary>
public class ChatUserEntityTypeConfiguration : IEntityTypeConfiguration<ChatUser>
{
public void Configure(EntityTypeBuilder<ChatUser> builder)
{
builder.ToTable("chat_users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(u => u.IdentityUserId)
.HasColumnName("identity_user_id")
.HasMaxLength(256)
.IsRequired();
builder.HasIndex(u => u.IdentityUserId)
.IsUnique();
builder.Property(u => u.DisplayName)
.HasColumnName("display_name")
.HasMaxLength(256)
.IsRequired();
builder.Property(u => u.AvatarUrl)
.HasColumnName("avatar_url")
.HasMaxLength(2048);
builder.Property(u => u.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(u => u.UpdatedAt)
.HasColumnName("updated_at")
.IsRequired();
builder.Property(u => u.LastSeenAt)
.HasColumnName("last_seen_at");
// EN: Configure Status as navigation to Enumeration
// VI: Cấu hình Status như navigation đến Enumeration
builder.Property<int>("_statusId")
.UsePropertyAccessMode(PropertyAccessMode.Field)
.HasColumnName("status_id")
.IsRequired();
builder.HasOne(u => u.Status)
.WithMany()
.HasForeignKey("_statusId");
// EN: Configure KeyBundle as owned entity
// VI: Cấu hình KeyBundle như owned entity
builder.OwnsOne(u => u.KeyBundle, kb =>
{
kb.Property(k => k.IdentityPublicKey)
.HasColumnName("identity_public_key")
.HasMaxLength(1024);
kb.Property(k => k.SignedPreKey)
.HasColumnName("signed_pre_key")
.HasMaxLength(1024);
kb.Property(k => k.SignedPreKeySignature)
.HasColumnName("signed_pre_key_signature")
.HasMaxLength(1024);
kb.Property(k => k.SignedPreKeyTimestamp)
.HasColumnName("signed_pre_key_timestamp");
});
// EN: Configure OneTimePreKeys collection
// VI: Cấu hình collection OneTimePreKeys
var navigation = builder.Metadata.FindNavigation(nameof(ChatUser.OneTimePreKeys));
navigation?.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(u => u.OneTimePreKeys)
.WithOne()
.HasForeignKey(k => k.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
/// <summary>
/// EN: Entity configuration for OneTimePreKey entity.
/// VI: Cấu hình entity cho OneTimePreKey entity.
/// </summary>
public class OneTimePreKeyEntityTypeConfiguration : IEntityTypeConfiguration<OneTimePreKey>
{
public void Configure(EntityTypeBuilder<OneTimePreKey> builder)
{
builder.ToTable("one_time_pre_keys");
builder.HasKey(k => k.Id);
builder.Property(k => k.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(k => k.UserId)
.HasColumnName("user_id")
.IsRequired();
builder.Property(k => k.KeyId)
.HasColumnName("key_id")
.IsRequired();
builder.Property(k => k.PublicKey)
.HasColumnName("public_key")
.HasMaxLength(1024)
.IsRequired();
builder.Property(k => k.IsUsed)
.HasColumnName("is_used")
.IsRequired();
builder.Property(k => k.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(k => k.UsedAt)
.HasColumnName("used_at");
// EN: Index for finding available keys
// VI: Index để tìm các keys còn khả dụng
builder.HasIndex(k => new { k.UserId, k.IsUsed });
}
}
/// <summary>
/// EN: Entity configuration for UserStatus enumeration.
/// VI: Cấu hình entity cho UserStatus enumeration.
/// </summary>
public class UserStatusEntityTypeConfiguration : IEntityTypeConfiguration<UserStatus>
{
public void Configure(EntityTypeBuilder<UserStatus> builder)
{
builder.ToTable("user_statuses");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
// EN: Seed data
// VI: Dữ liệu seed
builder.HasData(
UserStatus.Offline,
UserStatus.Online,
UserStatus.Away,
UserStatus.DoNotDisturb
);
}
}

View File

@@ -0,0 +1,26 @@
namespace ChatService.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 ChatService.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 ChatService.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 ChatServiceContext _context;
public RequestManager(ChatServiceContext 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,102 @@
using Microsoft.EntityFrameworkCore;
using ChatService.Domain.AggregatesModel.UserAggregate;
using ChatService.Domain.SeedWork;
namespace ChatService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for ChatUser aggregate.
/// VI: Repository implementation cho ChatUser aggregate.
/// </summary>
public class ChatUserRepository : IChatUserRepository
{
private readonly ChatServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public ChatUserRepository(ChatServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<ChatUser?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers
.Include(u => u.Status)
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<ChatUser?> GetByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers
.Include(u => u.Status)
.FirstOrDefaultAsync(u => u.IdentityUserId == identityUserId, cancellationToken);
}
public async Task<ChatUser?> GetWithKeysAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers
.Include(u => u.Status)
.Include(u => u.OneTimePreKeys.Where(k => !k.IsUsed))
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<IEnumerable<ChatUser>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers
.Include(u => u.Status)
.Where(u => ids.Contains(u.Id))
.ToListAsync(cancellationToken);
}
public async Task<(string? IdentityPublicKey, string? SignedPreKey, string? SignedPreKeySignature, OneTimePreKey? OneTimePreKey)>
GetKeyBundleAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await _context.ChatUsers
.Include(u => u.OneTimePreKeys.Where(k => !k.IsUsed).Take(1))
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user?.KeyBundle == null)
return (null, null, null, null);
var oneTimePreKey = user.OneTimePreKeys.FirstOrDefault();
if (oneTimePreKey != null)
{
oneTimePreKey.MarkAsUsed();
}
return (
user.KeyBundle.IdentityPublicKey,
user.KeyBundle.SignedPreKey,
user.KeyBundle.SignedPreKeySignature,
oneTimePreKey
);
}
public async Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers.AnyAsync(u => u.Id == id, cancellationToken);
}
public async Task<bool> ExistsByIdentityUserIdAsync(string identityUserId, CancellationToken cancellationToken = default)
{
return await _context.ChatUsers.AnyAsync(u => u.IdentityUserId == identityUserId, cancellationToken);
}
public async Task<int> GetAvailablePreKeyCountAsync(Guid userId, CancellationToken cancellationToken = default)
{
return await _context.OneTimePreKeys
.Where(k => k.UserId == userId && !k.IsUsed)
.CountAsync(cancellationToken);
}
public ChatUser Add(ChatUser user)
{
return _context.ChatUsers.Add(user).Entity;
}
public void Update(ChatUser user)
{
_context.Entry(user).State = EntityState.Modified;
}
}

View File

@@ -0,0 +1,229 @@
using Microsoft.EntityFrameworkCore;
using ChatService.Domain.AggregatesModel.ConversationAggregate;
using ChatService.Domain.SeedWork;
namespace ChatService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Conversation aggregate.
/// VI: Repository implementation cho Conversation aggregate.
/// </summary>
public class ConversationRepository : IConversationRepository
{
private readonly ChatServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public ConversationRepository(ChatServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Conversation?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants.Where(p => p.LeftAt == null))
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
public async Task<Conversation?> GetWithMessagesAsync(Guid id, int messageCount = 50, CancellationToken cancellationToken = default)
{
var conversation = await _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants.Where(p => p.LeftAt == null))
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
if (conversation == null)
return null;
// EN: Load messages separately for pagination
// VI: Load messages riêng để phân trang
var messages = await _context.Messages
.Include(m => m.Type)
.Include(m => m.Status)
.Where(m => m.ConversationId == id)
.OrderByDescending(m => m.CreatedAt)
.Take(messageCount)
.ToListAsync(cancellationToken);
return conversation;
}
public async Task<IEnumerable<Conversation>> GetUserConversationsAsync(
Guid userId,
int skip = 0,
int take = 20,
CancellationToken cancellationToken = default)
{
return await _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants.Where(p => p.LeftAt == null))
.Where(c => c.Participants.Any(p => p.UserId == userId && p.LeftAt == null))
.OrderByDescending(c => c.LastMessageAt ?? c.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<Conversation?> FindDirectConversationAsync(Guid user1Id, Guid user2Id, CancellationToken cancellationToken = default)
{
// EN: Find direct conversation between two users
// VI: Tìm conversation trực tiếp giữa hai users
return await _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants)
.Where(c => c.Type == ConversationType.Direct)
.Where(c => c.Participants.Any(p => p.UserId == user1Id && p.LeftAt == null))
.Where(c => c.Participants.Any(p => p.UserId == user2Id && p.LeftAt == null))
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<IEnumerable<Message>> GetMessagesAsync(
Guid conversationId,
int skip = 0,
int take = 50,
DateTime? before = null,
CancellationToken cancellationToken = default)
{
var query = _context.Messages
.Include(m => m.Type)
.Include(m => m.Status)
.Where(m => m.ConversationId == conversationId);
if (before.HasValue)
{
query = query.Where(m => m.CreatedAt < before.Value);
}
return await query
.OrderByDescending(m => m.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<int> GetUnreadCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default)
{
var participant = await _context.ConversationParticipants
.FirstOrDefaultAsync(p => p.ConversationId == conversationId && p.UserId == userId, cancellationToken);
if (participant == null)
return 0;
var lastReadAt = participant.LastReadAt ?? participant.JoinedAt;
return await _context.Messages
.Where(m => m.ConversationId == conversationId)
.Where(m => m.SenderId != userId)
.Where(m => m.CreatedAt > lastReadAt)
.CountAsync(cancellationToken);
}
public async Task<int> GetTotalUnreadCountAsync(Guid userId, CancellationToken cancellationToken = default)
{
var userConversations = await _context.ConversationParticipants
.Where(p => p.UserId == userId && p.LeftAt == null)
.ToListAsync(cancellationToken);
var totalUnread = 0;
foreach (var participant in userConversations)
{
var lastReadAt = participant.LastReadAt ?? participant.JoinedAt;
var unreadCount = await _context.Messages
.Where(m => m.ConversationId == participant.ConversationId)
.Where(m => m.SenderId != userId)
.Where(m => m.CreatedAt > lastReadAt)
.CountAsync(cancellationToken);
totalUnread += unreadCount;
}
return totalUnread;
}
public Conversation Add(Conversation conversation)
{
return _context.Conversations.Add(conversation).Entity;
}
public void Update(Conversation conversation)
{
_context.Entry(conversation).State = EntityState.Modified;
}
public async Task<(IEnumerable<Conversation> Conversations, int TotalCount)> GetConversationsForUserAsync(
Guid userId,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants.Where(p => p.LeftAt == null))
.Where(c => c.Participants.Any(p => p.UserId == userId && p.LeftAt == null));
var totalCount = await query.CountAsync(cancellationToken);
var conversations = await query
.OrderByDescending(c => c.LastMessageAt ?? c.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (conversations, totalCount);
}
public async Task<(IEnumerable<Message> Messages, int TotalCount)> GetMessagesPaginatedAsync(
Guid conversationId,
int page = 1,
int pageSize = 50,
DateTime? before = null,
CancellationToken cancellationToken = default)
{
var query = _context.Messages
.Include(m => m.Type)
.Include(m => m.Status)
.Where(m => m.ConversationId == conversationId);
if (before.HasValue)
{
query = query.Where(m => m.CreatedAt < before.Value);
}
var totalCount = await query.CountAsync(cancellationToken);
var messages = await query
.OrderByDescending(m => m.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (messages, totalCount);
}
public async Task<int> GetUnreadMessageCountAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default)
{
return await GetUnreadCountAsync(conversationId, userId, cancellationToken);
}
public async Task<bool> IsUserParticipantAsync(Guid conversationId, Guid userId, CancellationToken cancellationToken = default)
{
return await _context.ConversationParticipants
.AnyAsync(p => p.ConversationId == conversationId && p.UserId == userId && p.LeftAt == null, cancellationToken);
}
public async Task<Conversation?> GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Conversations
.Include(c => c.Type)
.Include(c => c.Participants.Where(p => p.LeftAt == null))
.Include(c => c.Messages)
.ThenInclude(m => m.Type)
.Include(c => c.Messages)
.ThenInclude(m => m.Status)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>ChatService.FunctionalTests</AssemblyName>
<RootNamespace>ChatService.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\ChatService.API\ChatService.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace ChatService.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 ChatService.Infrastructure;
namespace ChatService.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<ChatServiceContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(ChatServiceContext));
if (dbContextDescriptor != null)
{
services.Remove(dbContextDescriptor);
}
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<ChatServiceContext>(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<ChatServiceContext>();
db.Database.EnsureCreated();
});
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>ChatService.UnitTests</AssemblyName>
<RootNamespace>ChatService.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\ChatService.Domain\ChatService.Domain.csproj" />
<ProjectReference Include="..\..\src\ChatService.API\ChatService.API.csproj" />
</ItemGroup>
</Project>

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/MembershipService.API/MembershipService.API.csproj" />
<Project Path="src/MembershipService.Domain/MembershipService.Domain.csproj" />
<Project Path="src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/MembershipService.FunctionalTests/MembershipService.FunctionalTests.csproj" />
<Project Path="tests/MembershipService.UnitTests/MembershipService.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,378 @@
# Membership Service Architecture
## System Overview
The Membership Service extends user profiles from the IAM Service with additional information like contact details, addresses, and membership tiers.
```mermaid
graph TB
subgraph Client
WEB[Web App]
MOBILE[Mobile App]
end
subgraph API_Gateway
TRAEFIK[Traefik]
end
subgraph Services
IAM[IAM Service]
MEMBER[Membership Service]
STORAGE[Storage Service]
end
subgraph Data
PG[(PostgreSQL)]
end
WEB --> TRAEFIK
MOBILE --> TRAEFIK
TRAEFIK --> |/api/v1/auth| IAM
TRAEFIK --> |/api/v1/members| MEMBER
TRAEFIK --> |/api/v1/files| STORAGE
IAM --> |JWT Token| MEMBER
MEMBER --> PG
MEMBER --> |Avatar URL| STORAGE
```
## Clean Architecture Layers
```
┌────────────────────────────────────────────────────────────┐
│ API Layer (Presentation) │
│ Controllers, Commands, Queries, Validators, DTOs │
├────────────────────────────────────────────────────────────┤
│ Domain Layer (Core) │
│ Entities, Aggregates, Value Objects, Domain Events │
├────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ DbContext, Repositories, Entity Configurations │
└────────────────────────────────────────────────────────────┘
```
## Domain Model
### Aggregate Structure
```mermaid
classDiagram
class Member {
+Guid Id
+Guid UserId
+string? PhoneNumber
+string? AvatarUrl
+string? AddressLine1
+string? AddressLine2
+string? City
+string? State
+string? PostalCode
+string CountryCode
+DateOnly? DateOfBirth
+string? Gender
+MembershipLevel MembershipLevel
+string? Preferences
+bool IsDeleted
+DateTime CreatedAt
+DateTime UpdatedAt
+UpdateProfile()
+UpdateAddress()
+ChangeMembershipLevel()
+Delete()
}
class MembershipLevel {
<<enumeration>>
+int Id
+string Name
+Free
+Basic
+Premium
}
Member --> MembershipLevel
```
### Domain Events
| Event | Trigger | Purpose |
|-------|---------|---------|
| `MemberCreatedDomainEvent` | New member created | Welcome email, analytics |
| `MemberUpdatedDomainEvent` | Profile updated | Sync to other services |
| `MembershipLevelChangedDomainEvent` | Level changed | Billing, benefits update |
## CQRS Pattern
```mermaid
flowchart LR
subgraph Commands[Write Side]
CMD1[CreateMemberCommand]
CMD2[UpdateMemberProfileCommand]
CMD3[ChangeMembershipLevelCommand]
end
subgraph Handlers[Command Handlers]
H1[CreateMemberHandler]
H2[UpdateProfileHandler]
H3[ChangeLevelHandler]
end
subgraph Queries[Read Side]
Q1[GetMemberByIdQuery]
Q2[GetMembersQuery]
end
subgraph QueryHandlers[Query Handlers]
QH1[GetMemberByIdHandler]
QH2[GetMembersHandler]
end
CMD1 --> H1 --> DB[(Database)]
CMD2 --> H2 --> DB
CMD3 --> H3 --> DB
Q1 --> QH1 --> DB
Q2 --> QH2 --> DB
```
## Request Flow
### Create Member Flow
```mermaid
sequenceDiagram
participant Client
participant Controller
participant MediatR
participant Handler
participant Repository
participant DB
Client->>Controller: POST /api/v1/members
Controller->>MediatR: Send(CreateMemberCommand)
MediatR->>Handler: Handle(command)
Handler->>Repository: ExistsByUserIdAsync(userId)
Repository->>DB: SELECT EXISTS
DB-->>Repository: false
Handler->>Repository: Add(member)
Handler->>Repository: SaveEntitiesAsync()
Repository->>DB: INSERT + Domain Events
DB-->>Repository: OK
Repository-->>Handler: member
Handler-->>MediatR: CreateMemberResult
MediatR-->>Controller: result
Controller-->>Client: 201 Created
```
## Database Schema
```mermaid
erDiagram
MEMBERS {
uuid id PK
varchar phone_number
varchar avatar_url
varchar address_line_1
varchar address_line_2
varchar city
varchar state
varchar postal_code
varchar country_code
date date_of_birth
varchar gender
int membership_level_id FK
jsonb preferences
boolean is_deleted
timestamp created_at
timestamp updated_at
}
MEMBERSHIP_LEVELS {
int id PK
varchar name
}
MEMBERS }o--|| MEMBERSHIP_LEVELS : has
```
## API Design
### RESTful Endpoints
```
GET /api/v1/members # List (paginated)
GET /api/v1/members/{id} # Get by ID
GET /api/v1/members/me # Get current user
POST /api/v1/members # Create
PUT /api/v1/members/{id} # Update profile
PUT /api/v1/members/{id}/level # Change level
```
### Response Format
```json
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"phoneNumber": "+84901234567",
"avatarUrl": "https://storage.goodgo.com/avatars/xxx.jpg",
"countryCode": "VN",
"dateOfBirth": "1990-01-15",
"gender": "male",
"membershipLevel": {
"id": 1,
"name": "Free"
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Security
### Authentication Flow
```mermaid
sequenceDiagram
participant Client
participant Traefik
participant Membership
participant IAM
Client->>IAM: Login
IAM-->>Client: JWT Token
Client->>Traefik: Request + Bearer Token
Traefik->>Membership: Forward request
Membership->>Membership: Validate JWT
Membership-->>Client: Response
```
### Authorization
- All endpoints require JWT Bearer authentication
- Token validated against IAM Service authority
- UserId extracted from JWT claims
## Infrastructure
### Docker Compose Setup
```yaml
membership-service-net:
build:
context: ../..
dockerfile: services/membership-service-net/Dockerfile
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/membership
- Jwt__Authority=http://iam-service-net:8080
ports:
- "5002:8080"
labels:
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)"
```
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: membership-service
spec:
replicas: 3
template:
spec:
containers:
- name: membership-service
image: goodgo/membership-service:latest
ports:
- containerPort: 8080
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
```
## Scalability
### Horizontal Scaling
- Stateless service design
- Database connection pooling
- Read replicas for queries
### Caching Strategy (Future)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Redis │────▶│ PostgreSQL │
└─────────────┘ └─────────────┘ └─────────────┘
Cache Layer Persistence
```
## Monitoring
### Health Checks
| Endpoint | Check | Purpose |
|----------|-------|---------|
| `/health` | All dependencies | Full status |
| `/health/live` | App running | Kubernetes liveness |
| `/health/ready` | DB connected | Kubernetes readiness |
### Metrics (Prometheus)
- `membership_requests_total` - Request count
- `membership_request_duration_seconds` - Latency
- `membership_errors_total` - Error count
### Logging
Structured Serilog logging with:
- Request/Response logging
- Domain event logging
- Error tracking with stack traces
## Error Handling
### Error Response Format
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"detail": "Member with ID 'xxx' was not found.",
"traceId": "00-1234567890abcdef-1234567890abcdef-00"
}
```
### Error Codes
| HTTP Status | Scenario |
|-------------|----------|
| 400 | Validation error |
| 401 | Missing/invalid token |
| 404 | Member not found |
| 409 | Member already exists |
| 500 | Internal server error |
## Future Enhancements
- [ ] Redis caching for member lookups
- [ ] Event sourcing for audit trail
- [ ] GraphQL endpoint
- [ ] Batch member import/export
- [ ] Member search with Elasticsearch
- [ ] Webhook notifications on level changes

View File

@@ -0,0 +1,322 @@
# Membership Service
> Extended user profile and membership management service for GoodGo platform.
## Overview
The Membership Service manages extended user profiles beyond basic IAM data. It handles:
- **Member Profiles** - Extended user information (phone, address, avatar, preferences)
- **Membership Levels** - Free, Basic, Premium tier management
- **Address Management** - Multi-field address storage with country codes
This service receives UserId from IAM Service and stores additional profile data using the same ID for consistency.
## Prerequisites
| Requirement | Version |
|-------------|---------|
| .NET SDK | 10.0.101+ |
| Docker | 24.0+ |
| PostgreSQL | 15+ (Neon recommended) |
```bash
# Check .NET version
dotnet --version
# Should output: 10.0.xxx
```
## Quick Start
### 1. Configure Environment
```bash
# Copy environment template
cp .env.example .env
# Edit with your configuration
nano .env
```
### 2. Run with Docker Compose
```bash
# From deployments/local directory
cd deployments/local
docker-compose up -d membership-service-net
# View logs
docker-compose logs -f membership-service-net
```
### 3. Run Locally
```bash
# Navigate to service directory
cd services/membership-service-net
# Restore dependencies
dotnet restore
# Build all projects
dotnet build
# Run the API
dotnet run --project src/MembershipService.API
```
## Project Structure
```
membership-service-net/
├── src/
│ ├── MembershipService.API/ # Presentation Layer
│ │ ├── Controllers/
│ │ │ └── MembersController.cs # Member CRUD endpoints
│ │ ├── Application/
│ │ │ ├── Commands/ # CreateMember, UpdateProfile, ChangeLevel
│ │ │ ├── Queries/ # GetMemberById, GetMembers
│ │ │ ├── Behaviors/ # Transaction, Validation
│ │ │ └── Validations/ # FluentValidation validators
│ │ └── Program.cs # App entry point
│ │
│ ├── MembershipService.Domain/ # Domain Layer
│ │ ├── AggregatesModel/
│ │ │ └── MemberAggregate/
│ │ │ ├── Member.cs # Aggregate root
│ │ │ ├── MembershipLevel.cs # Enumeration
│ │ │ └── IMemberRepository.cs # Repository interface
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes
│ │
│ └── MembershipService.Infrastructure/
│ ├── EntityConfigurations/ # EF Core configs
│ ├── Repositories/ # Repository implementations
│ └── MembershipServiceContext.cs # DbContext
├── tests/
│ ├── MembershipService.UnitTests/
│ └── MembershipService.FunctionalTests/
├── docs/
│ ├── en/ # English documentation
│ └── vi/ # Vietnamese documentation
├── Dockerfile
└── docker-compose.yml
```
## API Endpoints
### Member Management
| Method | Endpoint | Description | Auth Required |
|--------|----------|-------------|---------------|
| `GET` | `/api/v1/members` | Get paginated members | Yes |
| `GET` | `/api/v1/members/{id}` | Get member by ID | Yes |
| `GET` | `/api/v1/members/me` | Get current user's profile | Yes |
| `POST` | `/api/v1/members` | Create new member | Yes |
| `PUT` | `/api/v1/members/{id}` | Update member profile | Yes |
| `PUT` | `/api/v1/members/{id}/level` | Change membership level | Yes |
### Health Endpoints
| Endpoint | Purpose |
|----------|---------|
| `/health` | Full health status |
| `/health/live` | Liveness probe (Kubernetes) |
| `/health/ready` | Readiness probe (Kubernetes) |
### Swagger Documentation
Access Swagger UI at: `http://localhost:5002/swagger`
## Domain Model
### Member Aggregate
```csharp
public class Member : Entity, IAggregateRoot
{
public Guid UserId => Id; // Same as IAM Service UserId
public string? PhoneNumber { get; }
public string? AvatarUrl { get; }
public string? AddressLine1 { get; }
public string? City { get; }
public string CountryCode { get; }
public DateOnly? DateOfBirth { get; }
public string? Gender { get; }
public MembershipLevel MembershipLevel { get; }
public string? Preferences { get; } // JSON
public bool IsDeleted { get; } // Soft delete
public void UpdateProfile(...);
public void UpdateAddress(...);
public void ChangeMembershipLevel(MembershipLevel newLevel);
public void Delete();
}
```
### Membership Levels
| Level | ID | Description |
|-------|-----|-------------|
| Free | 1 | Default free tier |
| Basic | 2 | Basic paid membership |
| Premium | 3 | Premium membership |
### Domain Events
- `MemberCreatedDomainEvent` - When new member is created
- `MemberUpdatedDomainEvent` - When profile is updated
- `MembershipLevelChangedDomainEvent` - When level changes
## CQRS Pattern
### Commands
```csharp
// Create member
public record CreateMemberCommand(Guid UserId, string CountryCode, ...)
: IRequest<CreateMemberResult>;
// Update profile
public record UpdateMemberProfileCommand(Guid MemberId, string? PhoneNumber, ...)
: IRequest<UpdateMemberProfileResult>;
// Change membership level
public record ChangeMembershipLevelCommand(Guid MemberId, int NewLevelId)
: IRequest<ChangeMembershipLevelResult>;
```
### Queries
```csharp
// Get by ID
public record GetMemberByIdQuery(Guid Id) : IRequest<MemberDto?>;
// Get paginated list
public record GetMembersQuery(int Page, int PageSize) : IRequest<GetMembersResult>;
```
## Testing
```bash
# Run all tests
dotnet test
# Run unit tests only
dotnet test tests/MembershipService.UnitTests
# Run functional tests only
dotnet test tests/MembershipService.FunctionalTests
# Run with coverage
dotnet test /p:CollectCoverage=true
```
**Test Summary:**
- Unit Tests: 9 tests (Domain + MembershipLevel)
- Functional Tests: 3 tests (Controller authorization)
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` |
| `DATABASE_URL` | PostgreSQL connection string | - |
| `Jwt__Authority` | JWT Authority URL | - |
| `Jwt__Audience` | JWT Audience | - |
### appsettings.json
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=membership;Username=postgres;Password=postgres"
},
"Jwt": {
"Authority": "https://iam.goodgo.com",
"Audience": "membership-api"
}
}
```
## Database
### Run Migrations
```bash
cd services/membership-service-net
# Create new migration
dotnet ef migrations add InitialCreate -p src/MembershipService.Infrastructure -s src/MembershipService.API
# Apply migrations
dotnet ef database update -p src/MembershipService.Infrastructure -s src/MembershipService.API
```
### Database Schema
```sql
-- Members table
CREATE TABLE members (
id UUID PRIMARY KEY,
phone_number VARCHAR(20),
avatar_url VARCHAR(500),
address_line_1 VARCHAR(200),
address_line_2 VARCHAR(200),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
country_code VARCHAR(2) NOT NULL DEFAULT 'VN',
date_of_birth DATE,
gender VARCHAR(10),
membership_level_id INT NOT NULL DEFAULT 1,
preferences JSONB,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Indexes
CREATE INDEX ix_members_country_code ON members(country_code);
CREATE INDEX ix_members_level ON members(membership_level_id);
CREATE INDEX ix_members_created ON members(created_at);
```
## Deployment
### Docker Build
```bash
# Build Docker image
docker build -t membership-service:latest .
# Run container
docker run -p 5002:8080 --env-file .env membership-service:latest
```
### Kubernetes
See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests.
## Related Services
- **IAM Service** - Authentication and user identity (`iam-service-net`)
- **Storage Service** - File/avatar storage (`storage-service-net`)
- **Wallet Service** - Payment and points (`wallet-service-net`)
## Resources
- [.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/)
- [MediatR](https://github.com/jbogard/MediatR)
- [FluentValidation](https://docs.fluentvalidation.net/)
## License
Proprietary - GoodGo Platform

View File

@@ -0,0 +1,378 @@
# Kiến Trúc Membership Service
## Tổng Quan Hệ Thống
Membership Service mở rộng hồ sơ người dùng từ IAM Service với thông tin bổ sung như chi tiết liên hệ, địa chỉ và cấp độ thành viên.
```mermaid
graph TB
subgraph Client
WEB[Web App]
MOBILE[Mobile App]
end
subgraph API_Gateway
TRAEFIK[Traefik]
end
subgraph Services
IAM[IAM Service]
MEMBER[Membership Service]
STORAGE[Storage Service]
end
subgraph Data
PG[(PostgreSQL)]
end
WEB --> TRAEFIK
MOBILE --> TRAEFIK
TRAEFIK --> |/api/v1/auth| IAM
TRAEFIK --> |/api/v1/members| MEMBER
TRAEFIK --> |/api/v1/files| STORAGE
IAM --> |JWT Token| MEMBER
MEMBER --> PG
MEMBER --> |Avatar URL| STORAGE
```
## Các Lớp Clean Architecture
```
┌────────────────────────────────────────────────────────────┐
│ Lớp API (Presentation) │
│ Controllers, Commands, Queries, Validators, DTOs │
├────────────────────────────────────────────────────────────┤
│ Lớp Domain (Core) │
│ Entities, Aggregates, Value Objects, Domain Events │
├────────────────────────────────────────────────────────────┤
│ Lớp Infrastructure │
│ DbContext, Repositories, Entity Configurations │
└────────────────────────────────────────────────────────────┘
```
## Domain Model
### Cấu Trúc Aggregate
```mermaid
classDiagram
class Member {
+Guid Id
+Guid UserId
+string? PhoneNumber
+string? AvatarUrl
+string? AddressLine1
+string? AddressLine2
+string? City
+string? State
+string? PostalCode
+string CountryCode
+DateOnly? DateOfBirth
+string? Gender
+MembershipLevel MembershipLevel
+string? Preferences
+bool IsDeleted
+DateTime CreatedAt
+DateTime UpdatedAt
+UpdateProfile()
+UpdateAddress()
+ChangeMembershipLevel()
+Delete()
}
class MembershipLevel {
<<enumeration>>
+int Id
+string Name
+Free
+Basic
+Premium
}
Member --> MembershipLevel
```
### Domain Events
| Event | Trigger | Mục đích |
|-------|---------|----------|
| `MemberCreatedDomainEvent` | Tạo thành viên mới | Email chào mừng, analytics |
| `MemberUpdatedDomainEvent` | Cập nhật hồ sơ | Đồng bộ với services khác |
| `MembershipLevelChangedDomainEvent` | Thay đổi cấp độ | Thanh toán, cập nhật quyền lợi |
## CQRS Pattern
```mermaid
flowchart LR
subgraph Commands[Write Side]
CMD1[CreateMemberCommand]
CMD2[UpdateMemberProfileCommand]
CMD3[ChangeMembershipLevelCommand]
end
subgraph Handlers[Command Handlers]
H1[CreateMemberHandler]
H2[UpdateProfileHandler]
H3[ChangeLevelHandler]
end
subgraph Queries[Read Side]
Q1[GetMemberByIdQuery]
Q2[GetMembersQuery]
end
subgraph QueryHandlers[Query Handlers]
QH1[GetMemberByIdHandler]
QH2[GetMembersHandler]
end
CMD1 --> H1 --> DB[(Database)]
CMD2 --> H2 --> DB
CMD3 --> H3 --> DB
Q1 --> QH1 --> DB
Q2 --> QH2 --> DB
```
## Luồng Request
### Luồng Tạo Thành Viên
```mermaid
sequenceDiagram
participant Client
participant Controller
participant MediatR
participant Handler
participant Repository
participant DB
Client->>Controller: POST /api/v1/members
Controller->>MediatR: Send(CreateMemberCommand)
MediatR->>Handler: Handle(command)
Handler->>Repository: ExistsByUserIdAsync(userId)
Repository->>DB: SELECT EXISTS
DB-->>Repository: false
Handler->>Repository: Add(member)
Handler->>Repository: SaveEntitiesAsync()
Repository->>DB: INSERT + Domain Events
DB-->>Repository: OK
Repository-->>Handler: member
Handler-->>MediatR: CreateMemberResult
MediatR-->>Controller: result
Controller-->>Client: 201 Created
```
## Database Schema
```mermaid
erDiagram
MEMBERS {
uuid id PK
varchar phone_number
varchar avatar_url
varchar address_line_1
varchar address_line_2
varchar city
varchar state
varchar postal_code
varchar country_code
date date_of_birth
varchar gender
int membership_level_id FK
jsonb preferences
boolean is_deleted
timestamp created_at
timestamp updated_at
}
MEMBERSHIP_LEVELS {
int id PK
varchar name
}
MEMBERS }o--|| MEMBERSHIP_LEVELS : has
```
## Thiết Kế API
### RESTful Endpoints
```
GET /api/v1/members # Danh sách (phân trang)
GET /api/v1/members/{id} # Lấy theo ID
GET /api/v1/members/me # Lấy user hiện tại
POST /api/v1/members # Tạo mới
PUT /api/v1/members/{id} # Cập nhật hồ sơ
PUT /api/v1/members/{id}/level # Thay đổi cấp độ
```
### Định Dạng Response
```json
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"phoneNumber": "+84901234567",
"avatarUrl": "https://storage.goodgo.com/avatars/xxx.jpg",
"countryCode": "VN",
"dateOfBirth": "1990-01-15",
"gender": "male",
"membershipLevel": {
"id": 1,
"name": "Free"
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Bảo Mật
### Luồng Xác Thực
```mermaid
sequenceDiagram
participant Client
participant Traefik
participant Membership
participant IAM
Client->>IAM: Login
IAM-->>Client: JWT Token
Client->>Traefik: Request + Bearer Token
Traefik->>Membership: Forward request
Membership->>Membership: Validate JWT
Membership-->>Client: Response
```
### Authorization
- Tất cả endpoints yêu cầu xác thực JWT Bearer
- Token được xác thực với IAM Service authority
- UserId được trích xuất từ JWT claims
## Hạ Tầng
### Docker Compose Setup
```yaml
membership-service-net:
build:
context: ../..
dockerfile: services/membership-service-net/Dockerfile
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/membership
- Jwt__Authority=http://iam-service-net:8080
ports:
- "5002:8080"
labels:
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)"
```
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: membership-service
spec:
replicas: 3
template:
spec:
containers:
- name: membership-service
image: goodgo/membership-service:latest
ports:
- containerPort: 8080
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
```
## Khả Năng Mở Rộng
### Mở Rộng Theo Chiều Ngang
- Thiết kế service stateless
- Connection pooling cho database
- Read replicas cho queries
### Chiến Lược Caching (Tương Lai)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Redis │────▶│ PostgreSQL │
└─────────────┘ └─────────────┘ └─────────────┘
Cache Layer Persistence
```
## Giám Sát
### Health Checks
| Endpoint | Kiểm tra | Mục đích |
|----------|----------|----------|
| `/health` | Tất cả dependencies | Trạng thái đầy đủ |
| `/health/live` | App đang chạy | Kubernetes liveness |
| `/health/ready` | DB kết nối | Kubernetes readiness |
### Metrics (Prometheus)
- `membership_requests_total` - Số lượng request
- `membership_request_duration_seconds` - Độ trễ
- `membership_errors_total` - Số lỗi
### Logging
Structured Serilog logging với:
- Request/Response logging
- Domain event logging
- Error tracking với stack traces
## Xử Lý Lỗi
### Định Dạng Error Response
```json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"detail": "Không tìm thấy thành viên với ID 'xxx'.",
"traceId": "00-1234567890abcdef-1234567890abcdef-00"
}
```
### Mã Lỗi
| HTTP Status | Tình huống |
|-------------|------------|
| 400 | Lỗi validation |
| 401 | Thiếu/sai token |
| 404 | Không tìm thấy thành viên |
| 409 | Thành viên đã tồn tại |
| 500 | Lỗi server nội bộ |
## Cải Tiến Tương Lai
- [ ] Redis caching cho member lookups
- [ ] Event sourcing cho audit trail
- [ ] GraphQL endpoint
- [ ] Batch member import/export
- [ ] Member search với Elasticsearch
- [ ] Webhook notifications khi level thay đổi

View File

@@ -0,0 +1,322 @@
# Membership Service
> Dịch vụ quản lý hồ sơ người dùng mở rộng và thành viên cho nền tảng GoodGo.
## Tổng Quan
Membership Service quản lý hồ sơ người dùng mở rộng ngoài dữ liệu IAM cơ bản. Service xử lý:
- **Hồ sơ thành viên** - Thông tin người dùng mở rộng (điện thoại, địa chỉ, avatar, preferences)
- **Cấp độ thành viên** - Quản lý các cấp Free, Basic, Premium
- **Quản lý địa chỉ** - Lưu trữ địa chỉ đa trường với mã quốc gia
Service nhận UserId từ IAM Service và lưu trữ dữ liệu profile bổ sung sử dụng cùng ID để đảm bảo tính nhất quán.
## Yêu Cầu
| Yêu Cầu | Phiên Bản |
|---------|-----------|
| .NET SDK | 10.0.101+ |
| Docker | 24.0+ |
| PostgreSQL | 15+ (khuyến nghị Neon) |
```bash
# Kiểm tra phiên bản .NET
dotnet --version
# Kết quả: 10.0.xxx
```
## Bắt Đầu Nhanh
### 1. Cấu Hình Môi Trường
```bash
# Copy template môi trường
cp .env.example .env
# Chỉnh sửa cấu hình
nano .env
```
### 2. Chạy Với Docker Compose
```bash
# Từ thư mục deployments/local
cd deployments/local
docker-compose up -d membership-service-net
# Xem logs
docker-compose logs -f membership-service-net
```
### 3. Chạy Cục Bộ
```bash
# Di chuyển đến thư mục service
cd services/membership-service-net
# Restore dependencies
dotnet restore
# Build tất cả projects
dotnet build
# Chạy API
dotnet run --project src/MembershipService.API
```
## Cấu Trúc Dự Án
```
membership-service-net/
├── src/
│ ├── MembershipService.API/ # Lớp Presentation
│ │ ├── Controllers/
│ │ │ └── MembersController.cs # Các endpoint CRUD
│ │ ├── Application/
│ │ │ ├── Commands/ # CreateMember, UpdateProfile, ChangeLevel
│ │ │ ├── Queries/ # GetMemberById, GetMembers
│ │ │ ├── Behaviors/ # Transaction, Validation
│ │ │ └── Validations/ # FluentValidation validators
│ │ └── Program.cs # Điểm khởi động app
│ │
│ ├── MembershipService.Domain/ # Lớp Domain
│ │ ├── AggregatesModel/
│ │ │ └── MemberAggregate/
│ │ │ ├── Member.cs # Aggregate root
│ │ │ ├── MembershipLevel.cs # Enumeration
│ │ │ └── IMemberRepository.cs # Interface repository
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes
│ │
│ └── MembershipService.Infrastructure/
│ ├── EntityConfigurations/ # EF Core configs
│ ├── Repositories/ # Repository implementations
│ └── MembershipServiceContext.cs # DbContext
├── tests/
│ ├── MembershipService.UnitTests/
│ └── MembershipService.FunctionalTests/
├── docs/
│ ├── en/ # Tài liệu tiếng Anh
│ └── vi/ # Tài liệu tiếng Việt
├── Dockerfile
└── docker-compose.yml
```
## API Endpoints
### Quản Lý Thành Viên
| Phương thức | Endpoint | Mô tả | Yêu cầu Auth |
|-------------|----------|-------|--------------|
| `GET` | `/api/v1/members` | Lấy danh sách thành viên (phân trang) | Có |
| `GET` | `/api/v1/members/{id}` | Lấy thành viên theo ID | Có |
| `GET` | `/api/v1/members/me` | Lấy hồ sơ người dùng hiện tại | Có |
| `POST` | `/api/v1/members` | Tạo thành viên mới | Có |
| `PUT` | `/api/v1/members/{id}` | Cập nhật hồ sơ thành viên | Có |
| `PUT` | `/api/v1/members/{id}/level` | Thay đổi cấp thành viên | Có |
### Health Endpoints
| Endpoint | Mục đích |
|----------|----------|
| `/health` | Trạng thái health đầy đủ |
| `/health/live` | Liveness probe (Kubernetes) |
| `/health/ready` | Readiness probe (Kubernetes) |
### Swagger Documentation
Truy cập Swagger UI tại: `http://localhost:5002/swagger`
## Domain Model
### Member Aggregate
```csharp
public class Member : Entity, IAggregateRoot
{
public Guid UserId => Id; // Giống UserId từ IAM Service
public string? PhoneNumber { get; }
public string? AvatarUrl { get; }
public string? AddressLine1 { get; }
public string? City { get; }
public string CountryCode { get; }
public DateOnly? DateOfBirth { get; }
public string? Gender { get; }
public MembershipLevel MembershipLevel { get; }
public string? Preferences { get; } // JSON
public bool IsDeleted { get; } // Xóa mềm
public void UpdateProfile(...);
public void UpdateAddress(...);
public void ChangeMembershipLevel(MembershipLevel newLevel);
public void Delete();
}
```
### Cấp Độ Thành Viên
| Cấp độ | ID | Mô tả |
|--------|-----|-------|
| Free | 1 | Cấp miễn phí mặc định |
| Basic | 2 | Thành viên cơ bản trả phí |
| Premium | 3 | Thành viên cao cấp |
### Domain Events
- `MemberCreatedDomainEvent` - Khi thành viên mới được tạo
- `MemberUpdatedDomainEvent` - Khi hồ sơ được cập nhật
- `MembershipLevelChangedDomainEvent` - Khi cấp độ thay đổi
## CQRS Pattern
### Commands
```csharp
// Tạo thành viên
public record CreateMemberCommand(Guid UserId, string CountryCode, ...)
: IRequest<CreateMemberResult>;
// Cập nhật hồ sơ
public record UpdateMemberProfileCommand(Guid MemberId, string? PhoneNumber, ...)
: IRequest<UpdateMemberProfileResult>;
// Thay đổi cấp thành viên
public record ChangeMembershipLevelCommand(Guid MemberId, int NewLevelId)
: IRequest<ChangeMembershipLevelResult>;
```
### Queries
```csharp
// Lấy theo ID
public record GetMemberByIdQuery(Guid Id) : IRequest<MemberDto?>;
// Lấy danh sách phân trang
public record GetMembersQuery(int Page, int PageSize) : IRequest<GetMembersResult>;
```
## Testing
```bash
# Chạy tất cả tests
dotnet test
# Chạy unit tests
dotnet test tests/MembershipService.UnitTests
# Chạy functional tests
dotnet test tests/MembershipService.FunctionalTests
# Chạy với coverage
dotnet test /p:CollectCoverage=true
```
**Tóm tắt Test:**
- Unit Tests: 9 tests (Domain + MembershipLevel)
- Functional Tests: 3 tests (Controller authorization)
## 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` | Chuỗi kết nối PostgreSQL | - |
| `Jwt__Authority` | JWT Authority URL | - |
| `Jwt__Audience` | JWT Audience | - |
### appsettings.json
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=membership;Username=postgres;Password=postgres"
},
"Jwt": {
"Authority": "https://iam.goodgo.com",
"Audience": "membership-api"
}
}
```
## Database
### Chạy Migrations
```bash
cd services/membership-service-net
# Tạo migration mới
dotnet ef migrations add InitialCreate -p src/MembershipService.Infrastructure -s src/MembershipService.API
# Áp dụng migrations
dotnet ef database update -p src/MembershipService.Infrastructure -s src/MembershipService.API
```
### Database Schema
```sql
-- Bảng Members
CREATE TABLE members (
id UUID PRIMARY KEY,
phone_number VARCHAR(20),
avatar_url VARCHAR(500),
address_line_1 VARCHAR(200),
address_line_2 VARCHAR(200),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
country_code VARCHAR(2) NOT NULL DEFAULT 'VN',
date_of_birth DATE,
gender VARCHAR(10),
membership_level_id INT NOT NULL DEFAULT 1,
preferences JSONB,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Indexes
CREATE INDEX ix_members_country_code ON members(country_code);
CREATE INDEX ix_members_level ON members(membership_level_id);
CREATE INDEX ix_members_created ON members(created_at);
```
## Triển Khai
### Docker Build
```bash
# Build Docker image
docker build -t membership-service:latest .
# Chạy container
docker run -p 5002:8080 --env-file .env membership-service:latest
```
### Kubernetes
Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết thêm về Kubernetes deployment manifests.
## Services Liên Quan
- **IAM Service** - Xác thực và danh tính người dùng (`iam-service-net`)
- **Storage Service** - Lưu trữ file/avatar (`storage-service-net`)
- **Wallet Service** - Thanh toán và điểm (`wallet-service-net`)
## Tài Liệu Tham Khảo
- [.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/)
- [MediatR](https://github.com/jbogard/MediatR)
- [FluentValidation](https://docs.fluentvalidation.net/)
## 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 MembershipService.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 MembershipService.Infrastructure;
namespace MembershipService.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 MembershipServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
MembershipServiceContext 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);
await _dbContext.RollbackTransactionAsync();
throw;
}
});
}
}

View File

@@ -0,0 +1,63 @@
using FluentValidation;
using MediatR;
namespace MembershipService.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,34 @@
using MediatR;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Command to change membership level.
/// VI: Command để thay đổi cấp thành viên.
/// </summary>
public class ChangeMembershipLevelCommand : IRequest<ChangeMembershipLevelResult>
{
/// <summary>
/// EN: Member ID.
/// VI: ID member.
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: New membership level ID.
/// VI: ID cấp thành viên mới.
/// </summary>
public int NewLevelId { get; set; }
}
/// <summary>
/// EN: Result of change membership level command.
/// VI: Kết quả của change membership level command.
/// </summary>
public class ChangeMembershipLevelResult
{
public Guid MemberId { get; set; }
public string OldLevel { get; set; } = null!;
public string NewLevel { get; set; } = null!;
public DateTime ChangedAt { get; set; }
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Handler for changing membership level.
/// VI: Handler để thay đổi cấp thành viên.
/// </summary>
public class ChangeMembershipLevelCommandHandler : IRequestHandler<ChangeMembershipLevelCommand, ChangeMembershipLevelResult>
{
private readonly IMemberRepository _memberRepository;
private readonly ILogger<ChangeMembershipLevelCommandHandler> _logger;
public ChangeMembershipLevelCommandHandler(
IMemberRepository memberRepository,
ILogger<ChangeMembershipLevelCommandHandler> logger)
{
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ChangeMembershipLevelResult> Handle(ChangeMembershipLevelCommand request, CancellationToken cancellationToken)
{
var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken);
if (member == null)
{
throw new KeyNotFoundException($"Member {request.MemberId} not found");
}
var newLevel = MembershipLevel.FromValue<MembershipLevel>(request.NewLevelId);
if (newLevel == null)
{
throw new ArgumentException($"Invalid membership level ID: {request.NewLevelId}");
}
var oldLevel = member.MembershipLevel;
member.ChangeMembershipLevel(newLevel);
_memberRepository.Update(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Changed membership level for member {MemberId} from {OldLevel} to {NewLevel}",
request.MemberId, oldLevel.Name, newLevel.Name);
return new ChangeMembershipLevelResult
{
MemberId = member.Id,
OldLevel = oldLevel.Name,
NewLevel = newLevel.Name,
ChangedAt = member.UpdatedAt
};
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new member profile.
/// VI: Command để tạo member profile mới.
/// </summary>
public class CreateMemberCommand : IRequest<CreateMemberResult>
{
/// <summary>
/// EN: User ID from IAM Service.
/// VI: User ID từ IAM Service.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// EN: Country code (default: VN).
/// VI: Mã quốc gia (mặc định: VN).
/// </summary>
public string CountryCode { get; set; } = "VN";
/// <summary>
/// EN: Phone number (optional).
/// VI: Số điện thoại (tùy chọn).
/// </summary>
public string? PhoneNumber { get; set; }
/// <summary>
/// EN: Avatar URL (optional).
/// VI: URL avatar (tùy chọn).
/// </summary>
public string? AvatarUrl { get; set; }
}
/// <summary>
/// EN: Result of create member command.
/// VI: Kết quả của create member command.
/// </summary>
public class CreateMemberResult
{
public Guid MemberId { get; set; }
public Guid UserId { get; set; }
public string MembershipLevel { get; set; } = null!;
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,57 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Handler for creating a new member.
/// VI: Handler để tạo member mới.
/// </summary>
public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, CreateMemberResult>
{
private readonly IMemberRepository _memberRepository;
private readonly ILogger<CreateMemberCommandHandler> _logger;
public CreateMemberCommandHandler(
IMemberRepository memberRepository,
ILogger<CreateMemberCommandHandler> logger)
{
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateMemberResult> Handle(CreateMemberCommand request, CancellationToken cancellationToken)
{
// EN: Check if member already exists for this user
// VI: Kiểm tra member đã tồn tại cho user này chưa
var exists = await _memberRepository.ExistsForUserAsync(request.UserId, cancellationToken);
if (exists)
{
throw new InvalidOperationException($"Member already exists for user {request.UserId}");
}
// EN: Create new member
// VI: Tạo member mới
var member = new Member(request.UserId, request.CountryCode);
// EN: Update optional profile fields
// VI: Cập nhật các trường profile tùy chọn
if (!string.IsNullOrEmpty(request.PhoneNumber) || !string.IsNullOrEmpty(request.AvatarUrl))
{
member.UpdateProfile(request.PhoneNumber, request.AvatarUrl, null, null);
}
_memberRepository.Add(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Created member {MemberId} for user {UserId}", member.Id, request.UserId);
return new CreateMemberResult
{
MemberId = member.Id,
UserId = member.UserId,
MembershipLevel = member.MembershipLevel.Name,
CreatedAt = member.CreatedAt
};
}
}

Some files were not shown because too many files have changed in this diff Show More