From fe910a13bbd6a3e6557b4ccd39e44a5f62f144f2 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 15 Jan 2026 17:31:05 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Tri=E1=BB=83n=20khai=20h=E1=BB=87=20th?= =?UTF-8?q?=E1=BB=91ng=20c=E1=BA=A5p=20=C4=91=E1=BB=99=20v=C3=A0=20kinh=20?= =?UTF-8?q?nghi=E1=BB=87m=20(EXP)=20cho=20th=C3=A0nh=20vi=C3=AAn,=20bao=20?= =?UTF-8?q?g=E1=BB=93m=20LevelDefinition=20aggregate=20v=C3=A0=20t=C3=ADch?= =?UTF-8?q?=20h=E1=BB=A3p=20s=E1=BB=B1=20ki=E1=BB=87n=20l=C3=AAn=20c?= =?UTF-8?q?=E1=BA=A5p=20v=E1=BB=9Bi=20Wallet=20Service.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/en/ARCHITECTURE.md | 545 ++++++++++++----- .../docs/vi/ARCHITECTURE.md | 547 +++++++++++++----- 2 files changed, 805 insertions(+), 287 deletions(-) diff --git a/services/membership-service-net/docs/en/ARCHITECTURE.md b/services/membership-service-net/docs/en/ARCHITECTURE.md index 5541a661..97967076 100644 --- a/services/membership-service-net/docs/en/ARCHITECTURE.md +++ b/services/membership-service-net/docs/en/ARCHITECTURE.md @@ -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
(User & Profile)"] - MEMBER["👥 Membership Service
(Membership Data)"] + MEMBER["👥 Membership Service
(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 { - <> - +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; + + /// + /// 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. + /// + public void AddExperience(int points, IReadOnlyList 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 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
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,
l.name, l.required_exp, next_level.required_exp
FROM members m
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",
"currentExp": 150, "expToNextLevel": 150,
"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
(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 diff --git a/services/membership-service-net/docs/vi/ARCHITECTURE.md b/services/membership-service-net/docs/vi/ARCHITECTURE.md index 207fd4a9..c2e5b6f4 100644 --- a/services/membership-service-net/docs/vi/ARCHITECTURE.md +++ b/services/membership-service-net/docs/vi/ARCHITECTURE.md @@ -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
(User & Profile)"] - MEMBER["👥 Membership Service
(Membership Data)"] + MEMBER["👥 Membership Service
(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 { - <> - +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; + + /// + /// 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. + /// + public void AddExperience(int points, IReadOnlyList 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 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
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,
l.name, l.required_exp, next_level.required_exp
FROM members m
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",
"currentExp": 150, "expToNextLevel": 150,
"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
(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