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:
@@ -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)
|
||||
# ===========================================================================
|
||||
|
||||
40
services/chat-service-net/.env.example
Normal file
40
services/chat-service-net/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Environment / Môi Trường
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
# Database / Cơ Sở Dữ Liệu
|
||||
# PostgreSQL connection string (Neon or local)
|
||||
DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Authentication / Xác Thực JWT
|
||||
JWT_SECRET=your-secret-key-min-32-characters-long-here
|
||||
JWT_ISSUER=goodgo-platform
|
||||
JWT_AUDIENCE=goodgo-services
|
||||
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_EXPIRY_DAYS=7
|
||||
|
||||
# API Configuration / Cấu Hình API
|
||||
API_PORT=5000
|
||||
API_BASE_PATH=/api/v1/myservice
|
||||
|
||||
# Observability / Quan Sát
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=myservice
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=Information
|
||||
SEQ_URL=http://localhost:5341
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_SWAGGER_ENABLED=true
|
||||
FEATURE_DETAILED_ERRORS=true
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PERMITS_PER_MINUTE=100
|
||||
RATE_LIMIT_QUEUE_LIMIT=10
|
||||
|
||||
# Health Checks
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
75
services/chat-service-net/.gitignore
vendored
Normal file
75
services/chat-service-net/.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# Coverage
|
||||
TestResults/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Secrets
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
!appsettings.Development.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# JetBrains
|
||||
*.resharper
|
||||
|
||||
# dotnet tools
|
||||
.config/dotnet-tools.json
|
||||
|
||||
# Migration scripts (only keep structure)
|
||||
Migrations/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
11
services/chat-service-net/ChatService.slnx
Normal file
11
services/chat-service-net/ChatService.slnx
Normal 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>
|
||||
22
services/chat-service-net/Directory.Build.props
Normal file
22
services/chat-service-net/Directory.Build.props
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Authors>GoodGo Team</Authors>
|
||||
<Company>GoodGo</Company>
|
||||
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
services/chat-service-net/Dockerfile
Normal file
66
services/chat-service-net/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Build stage / Giai đoạn build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/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"]
|
||||
73
services/chat-service-net/docker-compose.yml
Normal file
73
services/chat-service-net/docker-compose.yml
Normal 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
|
||||
271
services/chat-service-net/docs/en/ARCHITECTURE.md
Normal file
271
services/chat-service-net/docs/en/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Architecture Documentation
|
||||
|
||||
> Detailed architecture documentation for the .NET 10 Microservice Template.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "API Layer"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Domain Layer"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure Layer"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### 1. Domain Layer (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)
|
||||
265
services/chat-service-net/docs/en/README.md
Normal file
265
services/chat-service-net/docs/en/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# .NET 10 Microservice Template
|
||||
|
||||
> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR
|
||||
- **Clean Architecture** - Domain, Infrastructure, API layered separation
|
||||
- **EF Core 10** - PostgreSQL with connection resilience
|
||||
- **FluentValidation** - Request validation
|
||||
- **API Versioning** - URL segment versioning
|
||||
- **Health Checks** - Kubernetes-ready probes
|
||||
- **Structured Logging** - Serilog with console and Seq
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (or use Docker) |
|
||||
|
||||
```bash
|
||||
# Check .NET version
|
||||
dotnet --version
|
||||
# Should output: 10.0.xxx
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create New Service
|
||||
|
||||
```bash
|
||||
# Copy template to new service
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Navigate to service directory
|
||||
cd services/your-service-name
|
||||
|
||||
# Rename all occurrences of "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
|
||||
271
services/chat-service-net/docs/vi/ARCHITECTURE.md
Normal file
271
services/chat-service-net/docs/vi/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Tài Liệu Kiến Trúc
|
||||
|
||||
> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10.
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Lớp API"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Lớp Domain"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Lớp Infrastructure"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Trách Nhiệm Các Lớp
|
||||
|
||||
### 1. Lớp Domain (ChatService.Domain)
|
||||
|
||||
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
|
||||
- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events)
|
||||
- Chỉ chứa các class POCO
|
||||
- Triển khai các tactical patterns của DDD
|
||||
|
||||
#### Thành Phần
|
||||
|
||||
| Thành phần | Mục Đích |
|
||||
|------------|----------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots với entities và value objects |
|
||||
| **Events** | Domain events cho giao tiếp cross-aggregate |
|
||||
| **Exceptions** | Domain exceptions cho vi phạm business rules |
|
||||
|
||||
### 2. Lớp Infrastructure (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)
|
||||
265
services/chat-service-net/docs/vi/README.md
Normal file
265
services/chat-service-net/docs/vi/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Template Microservice .NET 10
|
||||
|
||||
> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture.
|
||||
|
||||
## Tổng Quan
|
||||
|
||||
Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR
|
||||
- **Clean Architecture** - Phân tầng Domain, Infrastructure, API
|
||||
- **EF Core 10** - PostgreSQL với connection resilience
|
||||
- **FluentValidation** - Validation request
|
||||
- **API Versioning** - Versioning theo URL segment
|
||||
- **Health Checks** - Probes sẵn sàng cho Kubernetes
|
||||
- **Structured Logging** - Serilog với console và Seq
|
||||
|
||||
## Yêu Cầu
|
||||
|
||||
| Yêu cầu | Phiên bản |
|
||||
|---------|-----------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (hoặc dùng Docker) |
|
||||
|
||||
```bash
|
||||
# Kiểm tra phiên bản .NET
|
||||
dotnet --version
|
||||
# Kết quả nên là: 10.0.xxx
|
||||
```
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### 1. Tạo Service Mới
|
||||
|
||||
```bash
|
||||
# Sao chép template sang service mới
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Di chuyển đến thư mục service
|
||||
cd services/your-service-name
|
||||
|
||||
# Đổi tên tất cả "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
|
||||
7
services/chat-service-net/global.json
Normal file
7
services/chat-service-net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
144
services/chat-service-net/src/ChatService.API/Program.cs
Normal file
144
services/chat-service-net/src/ChatService.API/Program.cs
Normal 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 { }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
"Issuer": "goodgo-platform",
|
||||
"Audience": "goodgo-services",
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
40
services/membership-service-net/.env.example
Normal file
40
services/membership-service-net/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Environment / Môi Trường
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
# Database / Cơ Sở Dữ Liệu
|
||||
# PostgreSQL connection string (Neon or local)
|
||||
DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Authentication / Xác Thực JWT
|
||||
JWT_SECRET=your-secret-key-min-32-characters-long-here
|
||||
JWT_ISSUER=goodgo-platform
|
||||
JWT_AUDIENCE=goodgo-services
|
||||
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_EXPIRY_DAYS=7
|
||||
|
||||
# API Configuration / Cấu Hình API
|
||||
API_PORT=5000
|
||||
API_BASE_PATH=/api/v1/myservice
|
||||
|
||||
# Observability / Quan Sát
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=myservice
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=Information
|
||||
SEQ_URL=http://localhost:5341
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_SWAGGER_ENABLED=true
|
||||
FEATURE_DETAILED_ERRORS=true
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PERMITS_PER_MINUTE=100
|
||||
RATE_LIMIT_QUEUE_LIMIT=10
|
||||
|
||||
# Health Checks
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
75
services/membership-service-net/.gitignore
vendored
Normal file
75
services/membership-service-net/.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# Coverage
|
||||
TestResults/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Secrets
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
!appsettings.Development.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# JetBrains
|
||||
*.resharper
|
||||
|
||||
# dotnet tools
|
||||
.config/dotnet-tools.json
|
||||
|
||||
# Migration scripts (only keep structure)
|
||||
Migrations/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
22
services/membership-service-net/Directory.Build.props
Normal file
22
services/membership-service-net/Directory.Build.props
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Authors>GoodGo Team</Authors>
|
||||
<Company>GoodGo</Company>
|
||||
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
services/membership-service-net/Dockerfile
Normal file
66
services/membership-service-net/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Build stage / Giai đoạn build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/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"]
|
||||
11
services/membership-service-net/MembershipService.slnx
Normal file
11
services/membership-service-net/MembershipService.slnx
Normal 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>
|
||||
72
services/membership-service-net/docker-compose.yml
Normal file
72
services/membership-service-net/docker-compose.yml
Normal 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
|
||||
378
services/membership-service-net/docs/en/ARCHITECTURE.md
Normal file
378
services/membership-service-net/docs/en/ARCHITECTURE.md
Normal 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
|
||||
322
services/membership-service-net/docs/en/README.md
Normal file
322
services/membership-service-net/docs/en/README.md
Normal 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
|
||||
378
services/membership-service-net/docs/vi/ARCHITECTURE.md
Normal file
378
services/membership-service-net/docs/vi/ARCHITECTURE.md
Normal 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
|
||||
322
services/membership-service-net/docs/vi/README.md
Normal file
322
services/membership-service-net/docs/vi/README.md
Normal 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
|
||||
7
services/membership-service-net/global.json
Normal file
7
services/membership-service-net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user