feat: Triển khai hệ thống cấp độ và kinh nghiệm (EXP) cho thành viên, bao gồm LevelDefinition aggregate và tích hợp sự kiện lên cấp với Wallet Service.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 17:31:05 +07:00
parent bb0137289c
commit fe910a13bb
2 changed files with 805 additions and 287 deletions

View File

@@ -2,7 +2,7 @@
## System Overview
Membership Service manages user membership data. Profile information is stored in IAM Service.
Membership Service manages user membership data including **Level**, **Experience (EXP)**, and **Preferences**. Profile information is stored in IAM Service.
```mermaid
graph TB
@@ -17,8 +17,9 @@ graph TB
subgraph Services["Microservices"]
IAM["🔐 IAM Service<br/>(User & Profile)"]
MEMBER["👥 Membership Service<br/>(Membership Data)"]
MEMBER["👥 Membership Service<br/>(Level, EXP, Preferences)"]
STORAGE["📦 Storage Service"]
WALLET["💰 Wallet Service"]
end
subgraph Data["Data Layer"]
@@ -34,17 +35,20 @@ graph TB
IAM --> |"JWT Token"| MEMBER
MEMBER --> |"HTTP Client"| IAM
MEMBER --> PG
MEMBER -.-> |"Level Up Event"| WALLET
style IAM fill:#3b82f6,stroke:#1e40af,color:#fff
style MEMBER fill:#22c55e,stroke:#15803d,color:#fff
style STORAGE fill:#eab308,stroke:#a16207,color:#000
style WALLET fill:#a855f7,stroke:#7e22ce,color:#fff
style TRAEFIK fill:#6366f1,stroke:#4338ca,color:#fff
```
> [!IMPORTANT]
> **Responsibility Separation:**
> - **IAM Service**: Manages User identity and UserProfile (avatar, phone, address, DOB)
> - **Membership Service**: Manages membership data (membership level, preferences, country code, gender)
> - **Membership Service**: Manages membership data (level, experience, preferences, country code, gender)
> - **Wallet Service**: Receives Level Up events to process rewards
## Clean Architecture Layers
@@ -55,6 +59,7 @@ graph TB
├────────────────────────────────────────────────────────────┤
│ Domain Layer (Core) │
│ Entities, Aggregates, Value Objects, Domain Events │
│ Member Aggregate, LevelDefinition Aggregate │
├────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ DbContext, Repositories, Entity Configurations │
@@ -64,7 +69,7 @@ graph TB
## Domain Model
### Aggregate Structure
### Aggregates Overview
```mermaid
classDiagram
@@ -73,37 +78,115 @@ classDiagram
+Guid UserId
+string CountryCode
+string? Gender
+int MembershipLevelId
+MembershipLevel MembershipLevel
+int CurrentLevel
+int CurrentExp
+int TotalExpEarned
+string? Preferences
+bool IsDeleted
+DateTime CreatedAt
+DateTime UpdatedAt
+AddExperience(points, levelRules)
+UpdateGender()
+UpdateCountryCode()
+UpdatePreferences()
+ChangeMembershipLevel()
+Delete()
+Restore()
}
class MembershipLevel {
<<enumeration>>
+int Id
class LevelDefinition {
+Guid Id
+int LevelNumber
+string Name
+Free = 1
+Basic = 2
+Premium = 3
+int RequiredExp
+string? Description
+string? IconUrl
+string? Benefits
+bool IsActive
+DateTime CreatedAt
+DateTime UpdatedAt
}
Member --> MembershipLevel
Member ..> LevelDefinition : uses rules
style Member fill:#22c55e,stroke:#15803d,color:#fff
style MembershipLevel fill:#3b82f6,stroke:#1e40af,color:#fff
style LevelDefinition fill:#3b82f6,stroke:#1e40af,color:#fff
```
### Member Aggregate (Rich Domain Model)
```csharp
public class Member : Entity, IAggregateRoot
{
// Private fields for encapsulation
private int _currentLevel;
private int _currentExp;
private int _totalExpEarned;
public int CurrentLevel => _currentLevel;
public int CurrentExp => _currentExp;
public int TotalExpEarned => _totalExpEarned;
/// <summary>
/// EN: Add experience and automatically level up if thresholds are met.
/// VI: Thêm exp và tự động lên level nếu đạt ngưỡng.
/// </summary>
public void AddExperience(int points, IReadOnlyList<LevelDefinition> levelRules)
{
if (points <= 0)
throw new DomainException("EXP points must be positive");
int oldLevel = _currentLevel;
_currentExp += points;
_totalExpEarned += points;
_updatedAt = DateTime.UtcNow;
// Business logic: Calculate new level
var newLevel = CalculateLevel(_currentExp, levelRules);
if (newLevel > oldLevel)
{
_currentLevel = newLevel;
// Raise domain event for side effects
AddDomainEvent(new MemberLevelUpDomainEvent(this, oldLevel, newLevel));
}
AddDomainEvent(new MemberExperienceAddedDomainEvent(this, points));
}
private static int CalculateLevel(int exp, IReadOnlyList<LevelDefinition> rules)
{
return rules
.Where(r => r.IsActive && r.RequiredExp <= exp)
.OrderByDescending(r => r.LevelNumber)
.FirstOrDefault()?.LevelNumber ?? 1;
}
}
```
> [!NOTE]
> Member entity does not store profile fields (phone, avatar, address, DOB). These fields are managed by IAM Service's UserProfile.
> **Rich Domain Model**: Business logic for EXP and Level is encapsulated inside the Member entity, not in the Service Layer. This avoids the "Anemic Domain Model" anti-pattern.
### LevelDefinition Aggregate (Customizable Levels)
```csharp
public class LevelDefinition : Entity, IAggregateRoot
{
public int LevelNumber { get; private set; } // 1, 2, 3...
public string Name { get; private set; } // "Bronze", "Silver", "Gold"
public int RequiredExp { get; private set; } // 0, 100, 300, 600...
public string? Description { get; private set; }
public string? IconUrl { get; private set; }
public string? Benefits { get; private set; } // JSON benefits
public bool IsActive { get; private set; }
public void UpdateRequiredExp(int newRequiredExp)
{
if (newRequiredExp < 0)
throw new DomainException("Required EXP cannot be negative");
RequiredExp = newRequiredExp;
}
}
```
### Domain Events
@@ -111,53 +194,102 @@ classDiagram
|-------|---------|---------|
| `MemberCreatedDomainEvent` | New member created | Welcome email, analytics |
| `MemberUpdatedDomainEvent` | Profile updated | Sync to other services |
| `MembershipLevelChangedDomainEvent` | Level changed | Billing, benefits update |
| `MemberExperienceAddedDomainEvent` | EXP received | Logging, analytics |
| `MemberLevelUpDomainEvent` | Level up | **Rewards, notifications, unlock features** |
```mermaid
sequenceDiagram
participant Client
participant Handler
participant Member
participant EventDispatcher
participant WalletService
Client->>Handler: AddExperienceCommand
Handler->>Member: AddExperience(100, rules)
Member->>Member: _currentExp += 100
Member->>Member: CalculateLevel()
alt Level Up
Member->>Member: AddDomainEvent(LevelUpEvent)
end
Handler->>EventDispatcher: SaveEntitiesAsync()
EventDispatcher->>WalletService: Publish LevelUpEvent
WalletService-->>WalletService: Grant Rewards
```
## CQRS Pattern
### Write Side (Commands)
```mermaid
flowchart LR
subgraph Commands["✏️ Write Side"]
CMD1["CreateMemberCommand"]
CMD2["UpdateMemberProfileCommand"]
CMD3["ChangeMembershipLevelCommand"]
subgraph Commands["✏️ Commands"]
CMD1["AddExperienceCommand"]
CMD2["CreateMemberCommand"]
CMD3["UpdateMemberProfileCommand"]
CMD4["CreateLevelDefinitionCommand"]
end
subgraph Handlers["⚙️ Command Handlers"]
H1["CreateMemberHandler"]
H2["UpdateProfileHandler"]
H3["ChangeLevelHandler"]
H1["AddExperienceHandler"]
H2["CreateMemberHandler"]
H3["UpdateProfileHandler"]
H4["CreateLevelHandler"]
end
subgraph Queries["📖 Read Side"]
Q1["GetMemberByIdQuery"]
Q2["GetMembersQuery"]
end
subgraph QueryHandlers["🔍 Query Handlers"]
QH1["GetMemberByIdHandler"]
QH2["GetMembersHandler"]
subgraph Domain["🏛️ Domain Model"]
DM["Rich Domain Model<br/>EF Core"]
end
DB[("🐘 Database")]
CMD1 --> H1 --> DB
CMD2 --> H2 --> DB
CMD3 --> H3 --> DB
Q1 --> QH1 --> DB
Q2 --> QH2 --> DB
CMD1 --> H1 --> DM --> DB
CMD2 --> H2 --> DM
CMD3 --> H3 --> DM
CMD4 --> H4 --> DM
style CMD1 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD2 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD3 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD4 fill:#ef4444,stroke:#b91c1c,color:#fff
```
### Read Side (Queries)
```mermaid
flowchart LR
subgraph Queries["📖 Queries"]
Q1["GetMemberByIdQuery"]
Q2["GetMemberProgressQuery"]
Q3["GetLevelDefinitionsQuery"]
end
subgraph Handlers["🔍 Query Handlers"]
QH1["GetMemberByIdHandler"]
QH2["GetMemberProgressHandler"]
QH3["GetLevelDefinitionsHandler"]
end
subgraph ReadModel["📊 Read Model"]
RM["Dapper / Raw SQL"]
end
DB[("🐘 Database")]
Q1 --> QH1 --> RM --> DB
Q2 --> QH2 --> RM
Q3 --> QH3 --> RM
style Q1 fill:#22c55e,stroke:#15803d,color:#fff
style Q2 fill:#22c55e,stroke:#15803d,color:#fff
style Q3 fill:#22c55e,stroke:#15803d,color:#fff
```
## Request Flow
### Create Member Flow
### Add Experience Flow
```mermaid
sequenceDiagram
@@ -165,23 +297,58 @@ sequenceDiagram
participant Controller
participant MediatR
participant Handler
participant Repository
participant MemberRepo
participant LevelRepo
participant DB
Client->>Controller: POST /api/v1/members
Controller->>MediatR: Send(CreateMemberCommand)
Client->>Controller: POST /api/v1/members/{id}/experience
Note over Client,Controller: { "points": 100, "source": "purchase" }
Controller->>MediatR: Send(AddExperienceCommand)
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
Handler->>MemberRepo: GetAsync(memberId)
MemberRepo->>DB: SELECT member
DB-->>MemberRepo: member data
Handler->>LevelRepo: GetAllActiveAsync()
LevelRepo->>DB: SELECT level_definitions
DB-->>LevelRepo: level rules
Handler->>Handler: member.AddExperience(100, rules)
Note over Handler: Business logic in Domain
Handler->>MemberRepo: UnitOfWork.SaveEntitiesAsync()
MemberRepo->>DB: UPDATE + Dispatch Domain Events
DB-->>Handler: OK
Handler-->>Controller: AddExperienceResult
Controller-->>Client: 200 OK
Note over Client,Controller: { "currentExp": 150, "currentLevel": 2, "leveledUp": true }
```
### Get Member Progress Flow
```mermaid
sequenceDiagram
participant Client
participant Controller
participant Handler
participant Dapper
participant DB
Client->>Controller: GET /api/v1/members/{id}/progress
Controller->>Handler: GetMemberProgressQuery
Handler->>Dapper: Raw SQL Query
Note over Dapper,DB: SELECT m.current_exp, m.current_level,<br/>l.name, l.required_exp, next_level.required_exp<br/>FROM members m<br/>JOIN level_definitions l ON ...
Dapper->>DB: Execute
DB-->>Dapper: Result
Dapper-->>Handler: MemberProgressDto
Handler-->>Controller: Result
Controller-->>Client: 200 OK
Note over Client,Controller: { "currentLevel": 2, "levelName": "Silver",<br/>"currentExp": 150, "expToNextLevel": 150,<br/>"progressPercent": 50 }
```
## Database Schema
@@ -192,35 +359,107 @@ erDiagram
uuid id PK "Same as IAM UserId"
varchar country_code "ISO 3166-1 alpha-2"
varchar gender "male/female/other"
int membership_level_id FK
int current_level "Current member level"
int current_exp "Current experience points"
int total_exp_earned "Total EXP ever earned"
jsonb preferences "User preferences"
boolean is_deleted "Soft delete flag"
timestamp created_at
timestamp updated_at
}
MEMBERSHIP_LEVELS {
int id PK
varchar name
LEVEL_DEFINITIONS {
uuid id PK
int level_number UK "Level 1, 2, 3..."
varchar name "Bronze, Silver, Gold..."
int required_exp "EXP needed for this level"
text description
varchar icon_url
jsonb benefits "Level benefits/rewards"
boolean is_active
timestamp created_at
timestamp updated_at
}
MEMBERS }o--|| MEMBERSHIP_LEVELS : has
MEMBERS }o..|| LEVEL_DEFINITIONS : "current_level references"
```
### SQL Schema
```sql
-- Level Definitions table (Admin configurable)
CREATE TABLE level_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
level_number INT NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
required_exp INT NOT NULL DEFAULT 0,
description TEXT,
icon_url VARCHAR(500),
benefits JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Members table
CREATE TABLE members (
id UUID PRIMARY KEY,
country_code VARCHAR(2) NOT NULL DEFAULT 'VN',
gender VARCHAR(10),
current_level INT NOT NULL DEFAULT 1,
current_exp INT NOT NULL DEFAULT 0,
total_exp_earned INT NOT NULL DEFAULT 0,
preferences JSONB,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX ix_members_current_level ON members(current_level);
CREATE INDEX ix_members_current_exp ON members(current_exp);
CREATE INDEX ix_level_definitions_level_number ON level_definitions(level_number);
CREATE INDEX ix_level_definitions_is_active ON level_definitions(is_active);
-- Default Level Definitions
INSERT INTO level_definitions (level_number, name, required_exp, description, is_active)
VALUES
(1, 'Bronze', 0, 'Starting level', true),
(2, 'Silver', 100, 'Reach 100 EXP', true),
(3, 'Gold', 300, 'Reach 300 EXP', true),
(4, 'Platinum', 600, 'Reach 600 EXP', true),
(5, 'Diamond', 1000, 'Reach 1000 EXP', true);
```
## API Design
### RESTful Endpoints
#### Member Endpoints
```
GET /api/v1/members # List (paginated, searchable)
GET /api/v1/members/{id} # Get by ID
GET /api/v1/members/me # Get current user
GET /api/v1/members/{id}/progress # Get level progress
POST /api/v1/members # Create
POST /api/v1/members/{id}/experience # Add EXP
PUT /api/v1/members/{id} # Update profile
PUT /api/v1/members/{id}/level # Change level
```
### Response Format
#### Level Endpoints (Admin)
```
GET /api/v1/levels # List level definitions
GET /api/v1/levels/{id} # Get level definition
POST /api/v1/levels # Create level definition (Admin)
PUT /api/v1/levels/{id} # Update level definition (Admin)
DELETE /api/v1/levels/{id} # Deactivate level (Admin)
```
### Response Formats
#### Member Response
```json
{
@@ -228,76 +467,113 @@ PUT /api/v1/members/{id}/level # Change level
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"countryCode": "VN",
"gender": "male",
"preferences": "{\"theme\": \"dark\", \"language\": \"vi\"}",
"membershipLevel": {
"id": 1,
"name": "Free"
},
"currentLevel": 2,
"currentExp": 150,
"totalExpEarned": 150,
"preferences": "{\"theme\": \"dark\"}",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
#### Member Progress Response
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"currentLevel": 2,
"levelName": "Silver",
"levelIconUrl": "/icons/silver.png",
"currentExp": 150,
"expToNextLevel": 150,
"nextLevelRequiredExp": 300,
"progressPercent": 50.0
}
```
#### Add Experience Response
```json
{
"currentExp": 250,
"currentLevel": 2,
"totalExpEarned": 250,
"leveledUp": false,
"newLevel": null
}
```
#### Level Definition Response
```json
{
"id": "a1b2c3d4-...",
"levelNumber": 3,
"name": "Gold",
"requiredExp": 300,
"description": "Reach 300 EXP to unlock Gold status",
"iconUrl": "/icons/gold.png",
"benefits": {
"discountPercent": 10,
"prioritySupport": true,
"freeShipping": false
},
"isActive": true
}
```
## Inter-Service Communication
### IAM Service Client
Membership Service communicates with IAM Service through HTTP Client:
Membership Service communicates with IAM Service through HTTP Client to validate users.
### Integration Events (Planned)
When a member levels up, publish Integration Event for other services to process:
```mermaid
sequenceDiagram
participant Membership
participant IamClient["IIamServiceClient"]
participant Cache["In-Memory Cache"]
participant IAM["IAM Service"]
flowchart LR
MEMBER["👥 Membership Service"]
BUS["📬 Event Bus<br/>(RabbitMQ)"]
WALLET["💰 Wallet Service"]
NOTIF["🔔 Notification Service"]
Membership->>IamClient: ValidateUserAsync(token)
IamClient->>Cache: TryGetFromCache(cacheKey)
MEMBER -->|MemberLevelUpIntegrationEvent| BUS
BUS -->|Subscribe| WALLET
BUS -->|Subscribe| NOTIF
alt Cache Hit
Cache-->>IamClient: cached user info
else Cache Miss
IamClient->>IAM: GET /api/v1/users/me
IAM-->>IamClient: user info
IamClient->>Cache: AddToCache(user, 5min)
end
IamClient-->>Membership: IamUserInfo
WALLET -->|Grant Rewards| WALLET
NOTIF -->|Send Notification| NOTIF
style MEMBER fill:#22c55e,stroke:#15803d,color:#fff
style BUS fill:#ef4444,stroke:#b91c1c,color:#fff
style WALLET fill:#a855f7,stroke:#7e22ce,color:#fff
style NOTIF fill:#eab308,stroke:#a16207,color:#000
```
### IAM Client Features
| Feature | Description |
|---------|-------------|
| User Validation | Validate token and get user info |
| Role/Permission Check | Check user roles and permissions |
| Health Check | Check IAM Service availability |
| Caching | In-memory cache with 5-minute TTL |
## 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 (`sub` or `id`)
- **Admin endpoints** (`/api/v1/levels` POST/PUT/DELETE) require `admin` role
### Experience Source Validation
```csharp
// Validate experience source to prevent abuse
public enum ExperienceSource
{
Purchase, // From orders
Referral, // Friend referral
Activity, // App activity
Promotion, // Promotions
Admin // Admin manual grant
}
```
## Infrastructure
@@ -315,7 +591,7 @@ membership-service-net:
ports:
- "5002:8080"
labels:
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)"
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`)"
```
### Kubernetes Deployment
@@ -349,26 +625,6 @@ spec:
httpGet:
path: /health/ready
port: 8080
env:
- name: IamService__BaseUrl
value: "http://iam-service:8080"
```
## Scalability
### Horizontal Scaling
- Stateless service design
- Database connection pooling
- Read replicas for queries
### Caching Strategy (Future)
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────▶│ Redis │────▶│ PostgreSQL │
└─────────────┘ └─────────────┘ └─────────────┘
Cache Layer Persistence
```
## Monitoring
@@ -383,15 +639,16 @@ spec:
### Metrics (Prometheus)
- `membership_experience_added_total` - Total EXP added
- `membership_level_ups_total` - Number of level ups
- `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
- Domain event logging (Level Up, EXP Added)
- Error tracking with stack traces
## Error Handling
@@ -401,9 +658,9 @@ Structured Serilog logging with:
```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.",
"title": "Bad Request",
"status": 400,
"detail": "EXP points must be positive.",
"traceId": "00-1234567890abcdef-1234567890abcdef-00"
}
```
@@ -412,17 +669,19 @@ Structured Serilog logging with:
| HTTP Status | Scenario |
|-------------|----------|
| 400 | Validation error |
| 400 | Validation error (negative EXP, etc.) |
| 401 | Missing/invalid token |
| 404 | Member not found |
| 409 | Member already exists |
| 403 | No permission (Admin endpoints) |
| 404 | Member/level not found |
| 409 | Level number 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
- [ ] Redis caching for level definitions
- [ ] Event sourcing for EXP history audit trail
- [ ] Leaderboard with EXP ranking
- [ ] Achievement/Badge system
- [ ] EXP decay (reduce EXP if inactive)
- [ ] Seasonal levels (reset by season)
- [ ] Integration Events with RabbitMQ

View File

@@ -2,7 +2,7 @@
## Tổng Quan Hệ Thống
Membership Service quản lý dữ liệu membership của user. Profile information được lưu tại IAM Service.
Membership Service quản lý dữ liệu membership của user bao gồm **Level**, **Experience (EXP)**, và **Preferences**. Profile information được lưu tại IAM Service.
```mermaid
graph TB
@@ -17,8 +17,9 @@ graph TB
subgraph Services["Microservices"]
IAM["🔐 IAM Service<br/>(User & Profile)"]
MEMBER["👥 Membership Service<br/>(Membership Data)"]
MEMBER["👥 Membership Service<br/>(Level, EXP, Preferences)"]
STORAGE["📦 Storage Service"]
WALLET["💰 Wallet Service"]
end
subgraph Data["Data Layer"]
@@ -34,17 +35,20 @@ graph TB
IAM --> |"JWT Token"| MEMBER
MEMBER --> |"HTTP Client"| IAM
MEMBER --> PG
MEMBER -.-> |"Level Up Event"| WALLET
style IAM fill:#3b82f6,stroke:#1e40af,color:#fff
style MEMBER fill:#22c55e,stroke:#15803d,color:#fff
style STORAGE fill:#eab308,stroke:#a16207,color:#000
style WALLET fill:#a855f7,stroke:#7e22ce,color:#fff
style TRAEFIK fill:#6366f1,stroke:#4338ca,color:#fff
```
> [!IMPORTANT]
> **Phân chia trách nhiệm:**
> - **IAM Service**: Quản lý User identity và UserProfile (avatar, phone, address, DOB)
> - **Membership Service**: Quản lý membership data (membership level, preferences, country code, gender)
> - **Membership Service**: Quản lý membership data (level, experience, preferences, country code, gender)
> - **Wallet Service**: Nhận event khi Level Up để xử lý rewards
## Các Lớp Clean Architecture
@@ -55,6 +59,7 @@ graph TB
├────────────────────────────────────────────────────────────┤
│ Lớp Domain (Core) │
│ Entities, Aggregates, Value Objects, Domain Events │
│ Member Aggregate, LevelDefinition Aggregate │
├────────────────────────────────────────────────────────────┤
│ Lớp Infrastructure │
│ DbContext, Repositories, Entity Configurations │
@@ -64,7 +69,7 @@ graph TB
## Domain Model
### Cấu Trúc Aggregate
### Tổng Quan Aggregates
```mermaid
classDiagram
@@ -73,37 +78,115 @@ classDiagram
+Guid UserId
+string CountryCode
+string? Gender
+int MembershipLevelId
+MembershipLevel MembershipLevel
+int CurrentLevel
+int CurrentExp
+int TotalExpEarned
+string? Preferences
+bool IsDeleted
+DateTime CreatedAt
+DateTime UpdatedAt
+AddExperience(points, levelRules)
+UpdateGender()
+UpdateCountryCode()
+UpdatePreferences()
+ChangeMembershipLevel()
+Delete()
+Restore()
}
class MembershipLevel {
<<enumeration>>
+int Id
class LevelDefinition {
+Guid Id
+int LevelNumber
+string Name
+Free = 1
+Basic = 2
+Premium = 3
+int RequiredExp
+string? Description
+string? IconUrl
+string? Benefits
+bool IsActive
+DateTime CreatedAt
+DateTime UpdatedAt
}
Member --> MembershipLevel
Member ..> LevelDefinition : uses rules
style Member fill:#22c55e,stroke:#15803d,color:#fff
style MembershipLevel fill:#3b82f6,stroke:#1e40af,color:#fff
style LevelDefinition fill:#3b82f6,stroke:#1e40af,color:#fff
```
### Member Aggregate (Rich Domain Model)
```csharp
public class Member : Entity, IAggregateRoot
{
// Private fields để encapsulate state
private int _currentLevel;
private int _currentExp;
private int _totalExpEarned;
public int CurrentLevel => _currentLevel;
public int CurrentExp => _currentExp;
public int TotalExpEarned => _totalExpEarned;
/// <summary>
/// EN: Add experience and automatically level up if thresholds are met.
/// VI: Thêm exp và tự động lên level nếu đạt ngưỡng.
/// </summary>
public void AddExperience(int points, IReadOnlyList<LevelDefinition> levelRules)
{
if (points <= 0)
throw new DomainException("EXP points must be positive");
int oldLevel = _currentLevel;
_currentExp += points;
_totalExpEarned += points;
_updatedAt = DateTime.UtcNow;
// Business logic: Calculate new level
var newLevel = CalculateLevel(_currentExp, levelRules);
if (newLevel > oldLevel)
{
_currentLevel = newLevel;
// Raise domain event for side effects
AddDomainEvent(new MemberLevelUpDomainEvent(this, oldLevel, newLevel));
}
AddDomainEvent(new MemberExperienceAddedDomainEvent(this, points));
}
private static int CalculateLevel(int exp, IReadOnlyList<LevelDefinition> rules)
{
return rules
.Where(r => r.IsActive && r.RequiredExp <= exp)
.OrderByDescending(r => r.LevelNumber)
.FirstOrDefault()?.LevelNumber ?? 1;
}
}
```
> [!NOTE]
> Member entity không lưu trữ profile fields (phone, avatar, address, DOB). Những fields này được quản lý bởi IAM Service's UserProfile.
> **Rich Domain Model**: Logic nghiệp vụ về EXP và Level được đóng gói trong Member entity, không phải ở Service Layer. Điều này tránh mô hình "Anemic Domain Model".
### LevelDefinition Aggregate (Customizable Levels)
```csharp
public class LevelDefinition : Entity, IAggregateRoot
{
public int LevelNumber { get; private set; } // 1, 2, 3...
public string Name { get; private set; } // "Bronze", "Silver", "Gold"
public int RequiredExp { get; private set; } // 0, 100, 300, 600...
public string? Description { get; private set; }
public string? IconUrl { get; private set; }
public string? Benefits { get; private set; } // JSON benefits
public bool IsActive { get; private set; }
public void UpdateRequiredExp(int newRequiredExp)
{
if (newRequiredExp < 0)
throw new DomainException("Required EXP cannot be negative");
RequiredExp = newRequiredExp;
}
}
```
### Domain Events
@@ -111,53 +194,102 @@ classDiagram
|-------|---------|----------|
| `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 |
| `MemberExperienceAddedDomainEvent` | Nhận EXP | Logging, analytics |
| `MemberLevelUpDomainEvent` | Lên level | **Rewards, notifications, unlock features** |
```mermaid
sequenceDiagram
participant Client
participant Handler
participant Member
participant EventDispatcher
participant WalletService
Client->>Handler: AddExperienceCommand
Handler->>Member: AddExperience(100, rules)
Member->>Member: _currentExp += 100
Member->>Member: CalculateLevel()
alt Level Up
Member->>Member: AddDomainEvent(LevelUpEvent)
end
Handler->>EventDispatcher: SaveEntitiesAsync()
EventDispatcher->>WalletService: Publish LevelUpEvent
WalletService-->>WalletService: Grant Rewards
```
## CQRS Pattern
### Write Side (Commands)
```mermaid
flowchart LR
subgraph Commands["✏️ Write Side"]
CMD1["CreateMemberCommand"]
CMD2["UpdateMemberProfileCommand"]
CMD3["ChangeMembershipLevelCommand"]
subgraph Commands["✏️ Commands"]
CMD1["AddExperienceCommand"]
CMD2["CreateMemberCommand"]
CMD3["UpdateMemberProfileCommand"]
CMD4["CreateLevelDefinitionCommand"]
end
subgraph Handlers["⚙️ Command Handlers"]
H1["CreateMemberHandler"]
H2["UpdateProfileHandler"]
H3["ChangeLevelHandler"]
H1["AddExperienceHandler"]
H2["CreateMemberHandler"]
H3["UpdateProfileHandler"]
H4["CreateLevelHandler"]
end
subgraph Queries["📖 Read Side"]
Q1["GetMemberByIdQuery"]
Q2["GetMembersQuery"]
end
subgraph QueryHandlers["🔍 Query Handlers"]
QH1["GetMemberByIdHandler"]
QH2["GetMembersHandler"]
subgraph Domain["🏛️ Domain Model"]
DM["Rich Domain Model<br/>EF Core"]
end
DB[("🐘 Database")]
CMD1 --> H1 --> DB
CMD2 --> H2 --> DB
CMD3 --> H3 --> DB
Q1 --> QH1 --> DB
Q2 --> QH2 --> DB
CMD1 --> H1 --> DM --> DB
CMD2 --> H2 --> DM
CMD3 --> H3 --> DM
CMD4 --> H4 --> DM
style CMD1 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD2 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD3 fill:#ef4444,stroke:#b91c1c,color:#fff
style CMD4 fill:#ef4444,stroke:#b91c1c,color:#fff
```
### Read Side (Queries)
```mermaid
flowchart LR
subgraph Queries["📖 Queries"]
Q1["GetMemberByIdQuery"]
Q2["GetMemberProgressQuery"]
Q3["GetLevelDefinitionsQuery"]
end
subgraph Handlers["🔍 Query Handlers"]
QH1["GetMemberByIdHandler"]
QH2["GetMemberProgressHandler"]
QH3["GetLevelDefinitionsHandler"]
end
subgraph ReadModel["📊 Read Model"]
RM["Dapper / Raw SQL"]
end
DB[("🐘 Database")]
Q1 --> QH1 --> RM --> DB
Q2 --> QH2 --> RM
Q3 --> QH3 --> RM
style Q1 fill:#22c55e,stroke:#15803d,color:#fff
style Q2 fill:#22c55e,stroke:#15803d,color:#fff
style Q3 fill:#22c55e,stroke:#15803d,color:#fff
```
## Luồng Request
### Luồng Tạo Thành Viên
### Luồng Add Experience
```mermaid
sequenceDiagram
@@ -165,23 +297,58 @@ sequenceDiagram
participant Controller
participant MediatR
participant Handler
participant Repository
participant MemberRepo
participant LevelRepo
participant DB
Client->>Controller: POST /api/v1/members
Controller->>MediatR: Send(CreateMemberCommand)
Client->>Controller: POST /api/v1/members/{id}/experience
Note over Client,Controller: { "points": 100, "source": "purchase" }
Controller->>MediatR: Send(AddExperienceCommand)
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
Handler->>MemberRepo: GetAsync(memberId)
MemberRepo->>DB: SELECT member
DB-->>MemberRepo: member data
Handler->>LevelRepo: GetAllActiveAsync()
LevelRepo->>DB: SELECT level_definitions
DB-->>LevelRepo: level rules
Handler->>Handler: member.AddExperience(100, rules)
Note over Handler: Business logic in Domain
Handler->>MemberRepo: UnitOfWork.SaveEntitiesAsync()
MemberRepo->>DB: UPDATE + Dispatch Domain Events
DB-->>Handler: OK
Handler-->>Controller: AddExperienceResult
Controller-->>Client: 200 OK
Note over Client,Controller: { "currentExp": 150, "currentLevel": 2, "leveledUp": true }
```
### Luồng Get Member Progress
```mermaid
sequenceDiagram
participant Client
participant Controller
participant Handler
participant Dapper
participant DB
Client->>Controller: GET /api/v1/members/{id}/progress
Controller->>Handler: GetMemberProgressQuery
Handler->>Dapper: Raw SQL Query
Note over Dapper,DB: SELECT m.current_exp, m.current_level,<br/>l.name, l.required_exp, next_level.required_exp<br/>FROM members m<br/>JOIN level_definitions l ON ...
Dapper->>DB: Execute
DB-->>Dapper: Result
Dapper-->>Handler: MemberProgressDto
Handler-->>Controller: Result
Controller-->>Client: 200 OK
Note over Client,Controller: { "currentLevel": 2, "levelName": "Silver",<br/>"currentExp": 150, "expToNextLevel": 150,<br/>"progressPercent": 50 }
```
## Database Schema
@@ -192,35 +359,107 @@ erDiagram
uuid id PK "Same as IAM UserId"
varchar country_code "ISO 3166-1 alpha-2"
varchar gender "male/female/other"
int membership_level_id FK
int current_level "Current member level"
int current_exp "Current experience points"
int total_exp_earned "Total EXP ever earned"
jsonb preferences "User preferences"
boolean is_deleted "Soft delete flag"
timestamp created_at
timestamp updated_at
}
MEMBERSHIP_LEVELS {
int id PK
varchar name
LEVEL_DEFINITIONS {
uuid id PK
int level_number UK "Level 1, 2, 3..."
varchar name "Bronze, Silver, Gold..."
int required_exp "EXP needed for this level"
text description
varchar icon_url
jsonb benefits "Level benefits/rewards"
boolean is_active
timestamp created_at
timestamp updated_at
}
MEMBERS }o--|| MEMBERSHIP_LEVELS : has
MEMBERS }o..|| LEVEL_DEFINITIONS : "current_level references"
```
### SQL Schema
```sql
-- Bảng Level Definitions (Admin configurable)
CREATE TABLE level_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
level_number INT NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
required_exp INT NOT NULL DEFAULT 0,
description TEXT,
icon_url VARCHAR(500),
benefits JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Bảng Members
CREATE TABLE members (
id UUID PRIMARY KEY,
country_code VARCHAR(2) NOT NULL DEFAULT 'VN',
gender VARCHAR(10),
current_level INT NOT NULL DEFAULT 1,
current_exp INT NOT NULL DEFAULT 0,
total_exp_earned INT NOT NULL DEFAULT 0,
preferences JSONB,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX ix_members_current_level ON members(current_level);
CREATE INDEX ix_members_current_exp ON members(current_exp);
CREATE INDEX ix_level_definitions_level_number ON level_definitions(level_number);
CREATE INDEX ix_level_definitions_is_active ON level_definitions(is_active);
-- Default Level Definitions
INSERT INTO level_definitions (level_number, name, required_exp, description, is_active)
VALUES
(1, 'Bronze', 0, 'Starting level', true),
(2, 'Silver', 100, 'Reach 100 EXP', true),
(3, 'Gold', 300, 'Reach 300 EXP', true),
(4, 'Platinum', 600, 'Reach 600 EXP', true),
(5, 'Diamond', 1000, 'Reach 1000 EXP', true);
```
## Thiết Kế API
### RESTful Endpoints
#### Member Endpoints
```
GET /api/v1/members # Danh sách (phân trang, search)
GET /api/v1/members/{id} # Lấy theo ID
GET /api/v1/members/me # Lấy user hiện tại
GET /api/v1/members/{id}/progress # Lấy tiến trình level
POST /api/v1/members # Tạo mới
POST /api/v1/members/{id}/experience # Thêm EXP
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
#### Level Endpoints (Admin)
```
GET /api/v1/levels # Danh sách level definitions
GET /api/v1/levels/{id} # Lấy level definition
POST /api/v1/levels # Tạo level definition (Admin)
PUT /api/v1/levels/{id} # Cập nhật level definition (Admin)
DELETE /api/v1/levels/{id} # Deactivate level (Admin)
```
### Response Formats
#### Member Response
```json
{
@@ -228,76 +467,113 @@ PUT /api/v1/members/{id}/level # Thay đổi cấp độ
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"countryCode": "VN",
"gender": "male",
"preferences": "{\"theme\": \"dark\", \"language\": \"vi\"}",
"membershipLevel": {
"id": 1,
"name": "Free"
},
"currentLevel": 2,
"currentExp": 150,
"totalExpEarned": 150,
"preferences": "{\"theme\": \"dark\"}",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
#### Member Progress Response
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"currentLevel": 2,
"levelName": "Silver",
"levelIconUrl": "/icons/silver.png",
"currentExp": 150,
"expToNextLevel": 150,
"nextLevelRequiredExp": 300,
"progressPercent": 50.0
}
```
#### Add Experience Response
```json
{
"currentExp": 250,
"currentLevel": 2,
"totalExpEarned": 250,
"leveledUp": false,
"newLevel": null
}
```
#### Level Definition Response
```json
{
"id": "a1b2c3d4-...",
"levelNumber": 3,
"name": "Gold",
"requiredExp": 300,
"description": "Reach 300 EXP to unlock Gold status",
"iconUrl": "/icons/gold.png",
"benefits": {
"discountPercent": 10,
"prioritySupport": true,
"freeShipping": false
},
"isActive": true
}
```
## Inter-Service Communication
### IAM Service Client
Membership Service giao tiếp với IAM Service thông qua HTTP Client:
Membership Service giao tiếp với IAM Service thông qua HTTP Client để validate users.
### Integration Events (Planned)
Khi member lên level, publish Integration Event để các services khác xử lý:
```mermaid
sequenceDiagram
participant Membership
participant IamClient["IIamServiceClient"]
participant Cache["In-Memory Cache"]
participant IAM["IAM Service"]
flowchart LR
MEMBER["👥 Membership Service"]
BUS["📬 Event Bus<br/>(RabbitMQ)"]
WALLET["💰 Wallet Service"]
NOTIF["🔔 Notification Service"]
Membership->>IamClient: ValidateUserAsync(token)
IamClient->>Cache: TryGetFromCache(cacheKey)
MEMBER -->|MemberLevelUpIntegrationEvent| BUS
BUS -->|Subscribe| WALLET
BUS -->|Subscribe| NOTIF
alt Cache Hit
Cache-->>IamClient: cached user info
else Cache Miss
IamClient->>IAM: GET /api/v1/users/me
IAM-->>IamClient: user info
IamClient->>Cache: AddToCache(user, 5min)
end
IamClient-->>Membership: IamUserInfo
WALLET -->|Grant Rewards| WALLET
NOTIF -->|Send Notification| NOTIF
style MEMBER fill:#22c55e,stroke:#15803d,color:#fff
style BUS fill:#ef4444,stroke:#b91c1c,color:#fff
style WALLET fill:#a855f7,stroke:#7e22ce,color:#fff
style NOTIF fill:#eab308,stroke:#a16207,color:#000
```
### IAM Client Features
| Feature | Mô tả |
|---------|-------|
| User Validation | Xác thực token và lấy user info |
| Role/Permission Check | Kiểm tra roles và permissions của user |
| Health Check | Kiểm tra IAM Service availability |
| Caching | In-memory cache với TTL 5 phút |
## 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 (`sub` hoặc `id`)
- **Admin endpoints** (`/api/v1/levels` POST/PUT/DELETE) yêu cầu role `admin`
### Experience Source Validation
```csharp
// Validate experience source để tránh abuse
public enum ExperienceSource
{
Purchase, // Từ đơn hàng
Referral, // Giới thiệu bạn bè
Activity, // Hoạt động trên app
Promotion, // Khuyến mãi
Admin // Admin manual grant
}
```
## Hạ Tầng
@@ -315,7 +591,7 @@ membership-service-net:
ports:
- "5002:8080"
labels:
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`)"
- "traefik.http.routers.membership.rule=PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`)"
```
### Kubernetes Deployment
@@ -349,26 +625,6 @@ spec:
httpGet:
path: /health/ready
port: 8080
env:
- name: IamService__BaseUrl
value: "http://iam-service: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
@@ -383,27 +639,28 @@ spec:
### Metrics (Prometheus)
- `membership_experience_added_total` - Tổng EXP đã cộng
- `membership_level_ups_total` - Số lần lên level
- `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
- Domain event logging (Level Up, EXP Added)
- Error tracking với stack traces
## Xử Lý Lỗi
### Định Dạng Error Response
### Error Response Format
```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'.",
"title": "Bad Request",
"status": 400,
"detail": "EXP points must be positive.",
"traceId": "00-1234567890abcdef-1234567890abcdef-00"
}
```
@@ -412,17 +669,19 @@ Structured Serilog logging với:
| HTTP Status | Tình huống |
|-------------|------------|
| 400 | Lỗi validation |
| 400 | Validation error (EXP âm, etc.) |
| 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 |
| 403 | Không có quyền (Admin endpoints) |
| 404 | Không tìm thấy member/level |
| 409 | Level number đã 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
- [ ] Redis caching cho level definitions
- [ ] Event sourcing cho EXP history audit trail
- [ ] Leaderboard với ranking theo EXP
- [ ] Achievement/Badge system
- [ ] EXP decay (giảm EXP nếu không hoạt động)
- [ ] Seasonal levels (reset theo mùa)
- [ ] Integration Events với RabbitMQ